diff --git a/apps/backend/cmd/kandev/gateway.go b/apps/backend/cmd/kandev/gateway.go
index fa56d15760..46d775926d 100644
--- a/apps/backend/cmd/kandev/gateway.go
+++ b/apps/backend/cmd/kandev/gateway.go
@@ -12,6 +12,7 @@ import (
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/common/scripts"
"github.com/kandev/kandev/internal/events"
+ lspinstaller "github.com/kandev/kandev/internal/lsp/installer"
"github.com/kandev/kandev/internal/events/bus"
gateways "github.com/kandev/kandev/internal/gateway/websocket"
notificationcontroller "github.com/kandev/kandev/internal/notifications/controller"
@@ -53,16 +54,16 @@ func provideGateway(
notificationRepo notificationstore.Repository,
taskRepo repository.Repository,
) (*gateways.Gateway, *notificationservice.Service, *notificationcontroller.Controller, error) {
- gateway, cleanup, err := gateways.Provide(log)
+ gateway, err := gateways.Provide(log)
if err != nil {
return nil, nil, nil, err
}
- _ = cleanup
// Enable dedicated terminal WebSocket for passthrough mode
scriptSvc := &scriptServiceAdapter{taskSvc: taskSvc}
if lifecycleMgr != nil {
gateway.SetLifecycleManager(lifecycleMgr, userSvc, scriptSvc)
+ gateway.SetLSPHandler(lifecycleMgr, userSvc, lspinstaller.NewRegistry(log))
}
orchestratorHandlers := orchestratorhandlers.NewHandlers(orchestratorSvc, log)
diff --git a/apps/backend/internal/gateway/websocket/lsp_handler.go b/apps/backend/internal/gateway/websocket/lsp_handler.go
new file mode 100644
index 0000000000..a6698d59d0
--- /dev/null
+++ b/apps/backend/internal/gateway/websocket/lsp_handler.go
@@ -0,0 +1,426 @@
+package websocket
+
+import (
+ "bufio"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "os/exec"
+ "strconv"
+ "strings"
+ "sync"
+
+ "github.com/gin-gonic/gin"
+ gorillaws "github.com/gorilla/websocket"
+ "go.uber.org/zap"
+
+ "github.com/kandev/kandev/internal/agent/lifecycle"
+ "github.com/kandev/kandev/internal/common/logger"
+ "github.com/kandev/kandev/internal/lsp/installer"
+ "github.com/kandev/kandev/internal/user/models"
+)
+
+// Custom WebSocket close codes for LSP connections.
+const (
+ lspCloseBinaryNotFound = 4001
+ lspCloseSessionNotFound = 4002
+ lspCloseInstallFailed = 4003
+)
+
+// LSPUserService provides user settings for the LSP handler.
+type LSPUserService interface {
+ GetUserSettings(ctx context.Context) (*models.UserSettings, error)
+}
+
+// LSPHandler handles WebSocket connections for LSP (Language Server Protocol) proxying.
+// Each WebSocket connection gets its own dedicated LSP server process (1:1 mapping).
+// This avoids LSP protocol issues with shared servers (duplicate initialize,
+// request ID collisions between clients).
+type LSPHandler struct {
+ lifecycleMgr *lifecycle.Manager
+ userService LSPUserService
+ installer *installer.Registry
+ logger *logger.Logger
+ installMu sync.Mutex
+ installing map[string]chan struct{}
+}
+
+// lspServerProcess represents a single LSP server process for one WebSocket client.
+type lspServerProcess struct {
+ cmd *exec.Cmd
+ stdin io.WriteCloser
+ stdout io.ReadCloser
+ cancel context.CancelFunc
+}
+
+var lspUpgrader = gorillaws.Upgrader{
+ ReadBufferSize: 8192,
+ WriteBufferSize: 8192,
+ CheckOrigin: checkWebSocketOrigin,
+}
+
+// NewLSPHandler creates a new LSPHandler.
+func NewLSPHandler(lifecycleMgr *lifecycle.Manager, userService LSPUserService, installerRegistry *installer.Registry, log *logger.Logger) *LSPHandler {
+ return &LSPHandler{
+ lifecycleMgr: lifecycleMgr,
+ userService: userService,
+ installer: installerRegistry,
+ logger: log.WithFields(zap.String("component", "lsp_handler")),
+ installing: make(map[string]chan struct{}),
+ }
+}
+
+// HandleLSPConnection handles WebSocket connections at /lsp/:sessionId?language=...
+func (h *LSPHandler) HandleLSPConnection(c *gin.Context) {
+ sessionID := c.Param("sessionId")
+ if sessionID == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "sessionId is required"})
+ return
+ }
+
+ language := c.Query("language")
+ if language == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "language query parameter is required"})
+ return
+ }
+
+ // Validate language against the registry
+ if !installer.IsSupported(language) {
+ c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("unsupported language: %s", language)})
+ return
+ }
+
+ h.logger.Info("LSP WebSocket connection request",
+ zap.String("session_id", sessionID),
+ zap.String("language", language))
+
+ // Get workspace path from execution
+ execution, exists := h.lifecycleMgr.GetExecutionBySessionID(sessionID)
+ if !exists {
+ h.logger.Warn("LSP: session not found in lifecycle manager",
+ zap.String("session_id", sessionID))
+ conn, err := lspUpgrader.Upgrade(c.Writer, c.Request, nil)
+ if err != nil {
+ h.logger.Error("LSP: failed to upgrade WebSocket for close", zap.Error(err))
+ return
+ }
+ _ = conn.WriteMessage(gorillaws.CloseMessage,
+ gorillaws.FormatCloseMessage(lspCloseSessionNotFound, "session not found"))
+ _ = conn.Close()
+ return
+ }
+
+ workspacePath := execution.WorkspacePath
+ h.logger.Debug("LSP: found execution",
+ zap.String("session_id", sessionID),
+ zap.String("workspace_path", workspacePath))
+
+ // Check if binary is available
+ binaryPath, err := h.installer.BinaryPath(language)
+ if err != nil {
+ h.logger.Info("LSP: binary not found",
+ zap.String("language", language),
+ zap.Error(err))
+ // Binary not found — check auto-install setting
+ settings, settingsErr := h.userService.GetUserSettings(c.Request.Context())
+ autoInstall := false
+ if settingsErr == nil && settings != nil {
+ for _, lang := range settings.LspAutoInstallLanguages {
+ if lang == language {
+ autoInstall = true
+ break
+ }
+ }
+ }
+ h.logger.Debug("LSP: auto-install check",
+ zap.String("language", language),
+ zap.Bool("auto_install", autoInstall),
+ zap.Error(settingsErr))
+
+ if !autoInstall {
+ h.logger.Info("LSP: closing with binary not found (auto-install off)",
+ zap.String("language", language),
+ zap.String("error", err.Error()))
+ conn, upgradeErr := lspUpgrader.Upgrade(c.Writer, c.Request, nil)
+ if upgradeErr != nil {
+ h.logger.Error("LSP: failed to upgrade WebSocket for close", zap.Error(upgradeErr))
+ return
+ }
+ _ = conn.WriteMessage(gorillaws.CloseMessage,
+ gorillaws.FormatCloseMessage(lspCloseBinaryNotFound, err.Error()))
+ _ = conn.Close()
+ return
+ }
+
+ // Auto-install: upgrade WS, send status messages, install
+ conn, upgradeErr := lspUpgrader.Upgrade(c.Writer, c.Request, nil)
+ if upgradeErr != nil {
+ return
+ }
+
+ if err := writeJSONMessage(conn, map[string]string{"status": "installing", "language": language}); err != nil {
+ h.logger.Warn("failed to send status message", zap.Error(err))
+ }
+
+ // Concurrency protection: wait if another install for the same language is in progress
+ binaryPath, err = h.awaitOrInstall(c.Request.Context(), language)
+ if err != nil {
+ h.logger.Error("LSP auto-install failed",
+ zap.String("language", language),
+ zap.Error(err))
+ if err := writeJSONMessage(conn, map[string]string{"status": "install_failed", "error": err.Error()}); err != nil {
+ h.logger.Warn("failed to send status message", zap.Error(err))
+ }
+ _ = conn.WriteMessage(gorillaws.CloseMessage,
+ gorillaws.FormatCloseMessage(lspCloseInstallFailed, "install failed"))
+ _ = conn.Close()
+ return
+ }
+
+ if err := writeJSONMessage(conn, map[string]string{"status": "installed"}); err != nil {
+ h.logger.Warn("failed to send status message", zap.Error(err))
+ }
+
+ // Continue with the already-upgraded connection
+ h.handleLSPBridge(conn, sessionID, language, binaryPath, workspacePath)
+ return
+ }
+
+ // Binary found — upgrade and bridge
+ h.logger.Info("LSP: binary found, upgrading WebSocket",
+ zap.String("language", language),
+ zap.String("binary_path", binaryPath))
+ conn, upgradeErr := lspUpgrader.Upgrade(c.Writer, c.Request, nil)
+ if upgradeErr != nil {
+ h.logger.Error("LSP: failed to upgrade to WebSocket",
+ zap.String("session_id", sessionID),
+ zap.Error(upgradeErr))
+ return
+ }
+
+ h.handleLSPBridge(conn, sessionID, language, binaryPath, workspacePath)
+}
+
+// awaitOrInstall either waits for an in-progress installation to finish or
+// performs the installation itself, broadcasting completion to other waiters.
+func (h *LSPHandler) awaitOrInstall(ctx context.Context, language string) (string, error) {
+ h.installMu.Lock()
+ if ch, ok := h.installing[language]; ok {
+ // Another goroutine is already installing — wait for it
+ h.installMu.Unlock()
+ select {
+ case <-ch:
+ // Install completed; look up the binary path
+ return h.installer.BinaryPath(language)
+ case <-ctx.Done():
+ return "", ctx.Err()
+ }
+ }
+ // Claim the install slot
+ ch := make(chan struct{})
+ h.installing[language] = ch
+ h.installMu.Unlock()
+
+ defer func() {
+ h.installMu.Lock()
+ delete(h.installing, language)
+ close(ch)
+ h.installMu.Unlock()
+ }()
+
+ strategy, err := h.installer.StrategyFor(language)
+ if err != nil {
+ return "", err
+ }
+ result, err := strategy.Install(ctx)
+ if err != nil {
+ return "", err
+ }
+ return result.BinaryPath, nil
+}
+
+func (h *LSPHandler) handleLSPBridge(conn *gorillaws.Conn, sessionID, language, binaryPath, workspacePath string) {
+ server, err := h.startServer(language, binaryPath, workspacePath)
+ if err != nil {
+ h.logger.Error("LSP: failed to start language server",
+ zap.String("session_id", sessionID),
+ zap.String("language", language),
+ zap.Error(err))
+ _ = conn.WriteMessage(gorillaws.CloseMessage,
+ gorillaws.FormatCloseMessage(gorillaws.CloseInternalServerErr, err.Error()))
+ _ = conn.Close()
+ return
+ }
+
+ h.logger.Info("LSP: server started, sending ready signal",
+ zap.String("session_id", sessionID),
+ zap.String("language", language),
+ zap.Int("pid", server.cmd.Process.Pid))
+
+ // Signal the frontend that the language server is ready.
+ // Include workspace path so the frontend can set rootUri in the LSP initialize request.
+ if err := writeJSONMessage(conn, map[string]string{"status": "ready", "workspacePath": workspacePath}); err != nil {
+ h.logger.Error("LSP: failed to send ready message", zap.Error(err))
+ h.stopServer(server)
+ _ = conn.Close()
+ return
+ }
+
+ h.runLSPBridge(conn, sessionID, language, server)
+ h.stopServer(server)
+
+ h.logger.Info("LSP: connection closed",
+ zap.String("session_id", sessionID),
+ zap.String("language", language))
+}
+
+func (h *LSPHandler) startServer(language, binaryPath, workspacePath string) (*lspServerProcess, error) {
+ binary, args := installer.LspCommand(language)
+ if binaryPath != "" {
+ binary = binaryPath
+ }
+
+ ctx, cancel := context.WithCancel(context.Background())
+ cmd := exec.CommandContext(ctx, binary, args...)
+ cmd.Dir = workspacePath
+
+ stdin, err := cmd.StdinPipe()
+ if err != nil {
+ cancel()
+ return nil, fmt.Errorf("failed to create stdin pipe: %w", err)
+ }
+
+ stdout, err := cmd.StdoutPipe()
+ if err != nil {
+ cancel()
+ return nil, fmt.Errorf("failed to create stdout pipe: %w", err)
+ }
+
+ if err := cmd.Start(); err != nil {
+ cancel()
+ return nil, fmt.Errorf("failed to start %s: %w", binary, err)
+ }
+
+ return &lspServerProcess{cmd: cmd, stdin: stdin, stdout: stdout, cancel: cancel}, nil
+}
+
+func (h *LSPHandler) stopServer(server *lspServerProcess) {
+ server.cancel()
+ _ = server.stdin.Close()
+ _ = server.cmd.Wait()
+}
+
+// runLSPBridge runs bidirectional proxying between a WebSocket and an LSP server.
+// Blocks until the WebSocket closes or the server exits.
+func (h *LSPHandler) runLSPBridge(conn *gorillaws.Conn, sessionID, language string, server *lspServerProcess) {
+ done := make(chan struct{})
+
+ // stdout → WebSocket
+ go func() {
+ defer close(done)
+ reader := bufio.NewReader(server.stdout)
+ for {
+ msg, err := readLSPMessage(reader)
+ if err != nil {
+ if err != io.EOF {
+ h.logger.Debug("LSP stdout read error",
+ zap.String("session_id", sessionID),
+ zap.String("language", language),
+ zap.Error(err))
+ }
+ // Server exited — close WebSocket so WS→stdin loop exits
+ _ = conn.WriteMessage(gorillaws.CloseMessage,
+ gorillaws.FormatCloseMessage(gorillaws.CloseNormalClosure, "language server exited"))
+ _ = conn.Close()
+ return
+ }
+ if wErr := conn.WriteMessage(gorillaws.TextMessage, msg); wErr != nil {
+ h.logger.Debug("LSP WebSocket write error",
+ zap.String("session_id", sessionID),
+ zap.String("language", language),
+ zap.Error(wErr))
+ return
+ }
+ }
+ }()
+
+ // WebSocket → stdin
+ for {
+ _, msg, err := conn.ReadMessage()
+ if err != nil {
+ if !gorillaws.IsCloseError(err, gorillaws.CloseNormalClosure, gorillaws.CloseGoingAway) {
+ h.logger.Debug("LSP WebSocket read error",
+ zap.String("session_id", sessionID),
+ zap.String("language", language),
+ zap.Error(err))
+ }
+ break
+ }
+
+ // Wrap with Content-Length header for the language server
+ header := fmt.Sprintf("Content-Length: %d\r\n\r\n", len(msg))
+ if _, err := server.stdin.Write([]byte(header)); err != nil {
+ h.logger.Debug("LSP stdin write error",
+ zap.String("session_id", sessionID),
+ zap.String("language", language),
+ zap.Error(err))
+ break
+ }
+ if _, err := server.stdin.Write(msg); err != nil {
+ h.logger.Debug("LSP stdin write error",
+ zap.String("session_id", sessionID),
+ zap.String("language", language),
+ zap.Error(err))
+ break
+ }
+ }
+
+ // Kill server so stdout goroutine exits, then wait for it
+ server.cancel()
+ _ = server.stdin.Close()
+ <-done
+}
+
+// readLSPMessage reads a single LSP message from a reader, parsing Content-Length headers.
+func readLSPMessage(reader *bufio.Reader) ([]byte, error) {
+ contentLength := -1
+ for {
+ line, err := reader.ReadString('\n')
+ if err != nil {
+ return nil, err
+ }
+ line = strings.TrimRight(line, "\r\n")
+ if line == "" {
+ // End of headers
+ break
+ }
+ if after, found := strings.CutPrefix(line, "Content-Length: "); found {
+ n, err := strconv.Atoi(after)
+ if err != nil {
+ return nil, fmt.Errorf("invalid Content-Length: %s", after)
+ }
+ contentLength = n
+ }
+ }
+
+ if contentLength < 0 {
+ return nil, fmt.Errorf("missing Content-Length header")
+ }
+
+ body := make([]byte, contentLength)
+ if _, err := io.ReadFull(reader, body); err != nil {
+ return nil, err
+ }
+
+ return body, nil
+}
+
+func writeJSONMessage(conn *gorillaws.Conn, data any) error {
+ msg, err := json.Marshal(data)
+ if err != nil {
+ return err
+ }
+ return conn.WriteMessage(gorillaws.TextMessage, msg)
+}
diff --git a/apps/backend/internal/gateway/websocket/lsp_handler_test.go b/apps/backend/internal/gateway/websocket/lsp_handler_test.go
new file mode 100644
index 0000000000..400f7280d8
--- /dev/null
+++ b/apps/backend/internal/gateway/websocket/lsp_handler_test.go
@@ -0,0 +1,176 @@
+package websocket
+
+import (
+ "bufio"
+ "bytes"
+ "fmt"
+ "io"
+ "strings"
+ "testing"
+
+ "github.com/kandev/kandev/internal/lsp/installer"
+)
+
+func TestReadLSPMessage_Valid(t *testing.T) {
+ body := `{"jsonrpc":"2.0","id":1,"method":"initialize"}`
+ raw := fmt.Sprintf("Content-Length: %d\r\n\r\n%s", len(body), body)
+ reader := bufio.NewReader(strings.NewReader(raw))
+
+ msg, err := readLSPMessage(reader)
+ if err != nil {
+ t.Fatalf("readLSPMessage() error = %v", err)
+ }
+ if string(msg) != body {
+ t.Errorf("readLSPMessage() = %q, want %q", string(msg), body)
+ }
+}
+
+func TestReadLSPMessage_WithExtraHeaders(t *testing.T) {
+ body := `{"jsonrpc":"2.0","id":2}`
+ raw := fmt.Sprintf("Content-Length: %d\r\nContent-Type: application/vscode-jsonrpc; charset=utf-8\r\n\r\n%s", len(body), body)
+ reader := bufio.NewReader(strings.NewReader(raw))
+
+ msg, err := readLSPMessage(reader)
+ if err != nil {
+ t.Fatalf("readLSPMessage() error = %v", err)
+ }
+ if string(msg) != body {
+ t.Errorf("readLSPMessage() = %q, want %q", string(msg), body)
+ }
+}
+
+func TestReadLSPMessage_MissingContentLength(t *testing.T) {
+ raw := "Content-Type: application/json\r\n\r\n{}"
+ reader := bufio.NewReader(strings.NewReader(raw))
+
+ _, err := readLSPMessage(reader)
+ if err == nil {
+ t.Fatal("readLSPMessage() should return error for missing Content-Length")
+ }
+ if !strings.Contains(err.Error(), "missing Content-Length") {
+ t.Errorf("readLSPMessage() error = %q, want 'missing Content-Length'", err.Error())
+ }
+}
+
+func TestReadLSPMessage_InvalidContentLength(t *testing.T) {
+ raw := "Content-Length: abc\r\n\r\n{}"
+ reader := bufio.NewReader(strings.NewReader(raw))
+
+ _, err := readLSPMessage(reader)
+ if err == nil {
+ t.Fatal("readLSPMessage() should return error for invalid Content-Length")
+ }
+ if !strings.Contains(err.Error(), "invalid Content-Length") {
+ t.Errorf("readLSPMessage() error = %q, want 'invalid Content-Length'", err.Error())
+ }
+}
+
+func TestReadLSPMessage_EOF(t *testing.T) {
+ reader := bufio.NewReader(bytes.NewReader(nil))
+
+ _, err := readLSPMessage(reader)
+ if err == nil {
+ t.Fatal("readLSPMessage() should return error on EOF")
+ }
+ if err != io.EOF {
+ t.Errorf("readLSPMessage() error = %v, want io.EOF", err)
+ }
+}
+
+func TestReadLSPMessage_MultipleMessages(t *testing.T) {
+ body1 := `{"jsonrpc":"2.0","id":1}`
+ body2 := `{"jsonrpc":"2.0","id":2}`
+ raw := fmt.Sprintf("Content-Length: %d\r\n\r\n%sContent-Length: %d\r\n\r\n%s",
+ len(body1), body1, len(body2), body2)
+ reader := bufio.NewReader(strings.NewReader(raw))
+
+ msg1, err := readLSPMessage(reader)
+ if err != nil {
+ t.Fatalf("first readLSPMessage() error = %v", err)
+ }
+ if string(msg1) != body1 {
+ t.Errorf("first readLSPMessage() = %q, want %q", string(msg1), body1)
+ }
+
+ msg2, err := readLSPMessage(reader)
+ if err != nil {
+ t.Fatalf("second readLSPMessage() error = %v", err)
+ }
+ if string(msg2) != body2 {
+ t.Errorf("second readLSPMessage() = %q, want %q", string(msg2), body2)
+ }
+}
+
+func TestLspCommand_ViaRegistry(t *testing.T) {
+ tests := []struct {
+ language string
+ wantBinary string
+ wantArgs []string
+ }{
+ {"typescript", "typescript-language-server", []string{"--stdio"}},
+ {"go", "gopls", []string{"serve"}},
+ {"rust", "rust-analyzer", nil},
+ {"python", "pyright-langserver", []string{"--stdio"}},
+ {"unknown", "", nil},
+ }
+ for _, tc := range tests {
+ binary, args := installer.LspCommand(tc.language)
+ if binary != tc.wantBinary {
+ t.Errorf("LspCommand(%q) binary = %q, want %q", tc.language, binary, tc.wantBinary)
+ }
+ if len(args) != len(tc.wantArgs) {
+ t.Errorf("LspCommand(%q) args = %v, want %v", tc.language, args, tc.wantArgs)
+ continue
+ }
+ for i := range args {
+ if args[i] != tc.wantArgs[i] {
+ t.Errorf("LspCommand(%q) args[%d] = %q, want %q", tc.language, i, args[i], tc.wantArgs[i])
+ }
+ }
+ }
+}
+
+func TestIsValidLSPLanguage_ViaRegistry(t *testing.T) {
+ tests := []struct {
+ language string
+ want bool
+ }{
+ {"typescript", true},
+ {"go", true},
+ {"rust", true},
+ {"python", true},
+ {"java", false},
+ {"", false},
+ {"ruby", false},
+ }
+ for _, tc := range tests {
+ if got := installer.IsSupported(tc.language); got != tc.want {
+ t.Errorf("IsSupported(%q) = %v, want %v", tc.language, got, tc.want)
+ }
+ }
+}
+
+func TestCloseCodeConstants(t *testing.T) {
+ // Verify close codes are in the expected range (4000-4999 for application-specific)
+ codes := []struct {
+ name string
+ code int
+ }{
+ {"lspCloseBinaryNotFound", lspCloseBinaryNotFound},
+ {"lspCloseSessionNotFound", lspCloseSessionNotFound},
+ {"lspCloseInstallFailed", lspCloseInstallFailed},
+ }
+ for _, tc := range codes {
+ if tc.code < 4000 || tc.code > 4999 {
+ t.Errorf("%s = %d, want value in range 4000-4999", tc.name, tc.code)
+ }
+ }
+ // Verify they're distinct
+ seen := make(map[int]string)
+ for _, tc := range codes {
+ if prev, ok := seen[tc.code]; ok {
+ t.Errorf("%s and %s have the same code %d", prev, tc.name, tc.code)
+ }
+ seen[tc.code] = tc.name
+ }
+}
diff --git a/apps/backend/internal/gateway/websocket/provider.go b/apps/backend/internal/gateway/websocket/provider.go
index dee3dd6dbf..d693514bfc 100644
--- a/apps/backend/internal/gateway/websocket/provider.go
+++ b/apps/backend/internal/gateway/websocket/provider.go
@@ -3,7 +3,7 @@ package websocket
import "github.com/kandev/kandev/internal/common/logger"
// Provide creates the unified WebSocket gateway.
-func Provide(log *logger.Logger) (*Gateway, func() error, error) {
+func Provide(log *logger.Logger) (*Gateway, error) {
gateway := NewGateway(log)
- return gateway, func() error { return nil }, nil
+ return gateway, nil
}
diff --git a/apps/backend/internal/gateway/websocket/setup.go b/apps/backend/internal/gateway/websocket/setup.go
index 800afcdfae..23cd9970b3 100644
--- a/apps/backend/internal/gateway/websocket/setup.go
+++ b/apps/backend/internal/gateway/websocket/setup.go
@@ -6,6 +6,7 @@ import (
"github.com/kandev/kandev/internal/agent/lifecycle"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/common/scripts"
+ "github.com/kandev/kandev/internal/lsp/installer"
ws "github.com/kandev/kandev/pkg/websocket"
)
@@ -15,6 +16,7 @@ type Gateway struct {
Dispatcher *ws.Dispatcher
Handler *Handler
TerminalHandler *TerminalHandler
+ LSPHandler *LSPHandler
logger *logger.Logger
}
@@ -41,6 +43,11 @@ func (g *Gateway) SetLifecycleManager(lifecycleMgr *lifecycle.Manager, userServi
g.TerminalHandler = NewTerminalHandler(lifecycleMgr, userService, scriptService, g.logger)
}
+// SetLSPHandler enables the LSP WebSocket handler.
+func (g *Gateway) SetLSPHandler(lifecycleMgr *lifecycle.Manager, userService LSPUserService, installerRegistry *installer.Registry) {
+ g.LSPHandler = NewLSPHandler(lifecycleMgr, userService, installerRegistry, g.logger)
+}
+
// SetupRoutes adds the WebSocket routes to the Gin engine
func (g *Gateway) SetupRoutes(router *gin.Engine) {
router.GET("/ws", g.Handler.HandleConnection)
@@ -49,5 +56,10 @@ func (g *Gateway) SetupRoutes(router *gin.Engine) {
if g.TerminalHandler != nil {
router.GET("/xterm.js/:sessionId", g.TerminalHandler.HandleTerminalWS)
}
+
+ // Add LSP routes if LSP handler is configured
+ if g.LSPHandler != nil {
+ router.GET("/lsp/:sessionId", g.LSPHandler.HandleLSPConnection)
+ }
}
diff --git a/apps/backend/internal/lsp/installer/github_release_strategy.go b/apps/backend/internal/lsp/installer/github_release_strategy.go
new file mode 100644
index 0000000000..457625fe69
--- /dev/null
+++ b/apps/backend/internal/lsp/installer/github_release_strategy.go
@@ -0,0 +1,127 @@
+package installer
+
+import (
+ "compress/gzip"
+ "context"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "path/filepath"
+ "runtime"
+ "strings"
+
+ "github.com/kandev/kandev/internal/common/logger"
+ "go.uber.org/zap"
+)
+
+// GithubReleaseConfig configures a GitHub release download.
+type GithubReleaseConfig struct {
+ Owner string // e.g. "rust-lang"
+ Repo string // e.g. "rust-analyzer"
+ AssetPattern string // e.g. "rust-analyzer-{target}.gz"
+ Targets map[string]string // "darwin/arm64" → "aarch64-apple-darwin"
+}
+
+// GithubReleaseStrategy downloads prebuilt binaries from GitHub releases.
+// Used for Rust (rust-analyzer).
+type GithubReleaseStrategy struct {
+ binDir string
+ binary string // "rust-analyzer"
+ config GithubReleaseConfig
+ logger *logger.Logger
+}
+
+// NewGithubReleaseStrategy creates a new GitHub release download strategy.
+func NewGithubReleaseStrategy(binDir, binary string, config GithubReleaseConfig, log *logger.Logger) *GithubReleaseStrategy {
+ return &GithubReleaseStrategy{
+ binDir: binDir,
+ binary: binary,
+ config: config,
+ logger: log,
+ }
+}
+
+func (s *GithubReleaseStrategy) Name() string {
+ return fmt.Sprintf("github release %s/%s", s.config.Owner, s.config.Repo)
+}
+
+func (s *GithubReleaseStrategy) Install(ctx context.Context) (*InstallResult, error) {
+ // Resolve target from runtime OS/ARCH
+ targetKey := runtime.GOOS + "/" + runtime.GOARCH
+ target, ok := s.config.Targets[targetKey]
+ if !ok {
+ return nil, fmt.Errorf("unsupported platform: %s", targetKey)
+ }
+
+ // Build download URL
+ asset := strings.Replace(s.config.AssetPattern, "{target}", target, 1)
+ url := fmt.Sprintf("https://github.com/%s/%s/releases/latest/download/%s",
+ s.config.Owner, s.config.Repo, asset)
+
+ s.logger.Info("downloading from GitHub releases",
+ zap.String("url", url),
+ zap.String("target", target))
+
+ // Download
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request: %w", err)
+ }
+
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("download failed: %w", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("download failed with status %d", resp.StatusCode)
+ }
+
+ // Ensure bin directory exists
+ if err := os.MkdirAll(s.binDir, 0o755); err != nil {
+ return nil, fmt.Errorf("failed to create directory %s: %w", s.binDir, err)
+ }
+
+ outPath := filepath.Join(s.binDir, s.binary)
+
+ // Decompress .gz
+ if strings.HasSuffix(asset, ".gz") {
+ gzReader, err := gzip.NewReader(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create gzip reader: %w", err)
+ }
+ defer func() { _ = gzReader.Close() }()
+
+ outFile, err := os.Create(outPath)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create output file: %w", err)
+ }
+ defer func() { _ = outFile.Close() }()
+
+ if _, err := io.Copy(outFile, gzReader); err != nil {
+ return nil, fmt.Errorf("failed to decompress: %w", err)
+ }
+ } else {
+ outFile, err := os.Create(outPath)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create output file: %w", err)
+ }
+ defer func() { _ = outFile.Close() }()
+
+ if _, err := io.Copy(outFile, resp.Body); err != nil {
+ return nil, fmt.Errorf("failed to write binary: %w", err)
+ }
+ }
+
+ // Make executable
+ if err := os.Chmod(outPath, 0o755); err != nil {
+ return nil, fmt.Errorf("failed to chmod: %w", err)
+ }
+
+ s.logger.Info("GitHub release download completed", zap.String("binary", outPath))
+ return &InstallResult{
+ BinaryPath: outPath,
+ }, nil
+}
diff --git a/apps/backend/internal/lsp/installer/go_install_strategy.go b/apps/backend/internal/lsp/installer/go_install_strategy.go
new file mode 100644
index 0000000000..cf0bf853f1
--- /dev/null
+++ b/apps/backend/internal/lsp/installer/go_install_strategy.go
@@ -0,0 +1,57 @@
+package installer
+
+import (
+ "context"
+ "fmt"
+ "os/exec"
+
+ "github.com/kandev/kandev/internal/common/logger"
+ "go.uber.org/zap"
+)
+
+// GoInstallStrategy installs language servers via `go install`.
+// Used for Go (gopls).
+type GoInstallStrategy struct {
+ binary string // "gopls"
+ importPath string // "golang.org/x/tools/gopls@latest"
+ logger *logger.Logger
+}
+
+// NewGoInstallStrategy creates a new go install strategy.
+func NewGoInstallStrategy(binary, importPath string, log *logger.Logger) *GoInstallStrategy {
+ return &GoInstallStrategy{
+ binary: binary,
+ importPath: importPath,
+ logger: log,
+ }
+}
+
+func (s *GoInstallStrategy) Name() string {
+ return fmt.Sprintf("go install %s", s.importPath)
+}
+
+func (s *GoInstallStrategy) Install(ctx context.Context) (*InstallResult, error) {
+ goPath, err := exec.LookPath("go")
+ if err != nil {
+ return nil, fmt.Errorf("go not found: %w", err)
+ }
+
+ s.logger.Info("installing via go install", zap.String("import_path", s.importPath))
+
+ cmd := exec.CommandContext(ctx, goPath, "install", s.importPath)
+ output, err := cmd.CombinedOutput()
+ if err != nil {
+ return nil, fmt.Errorf("go install failed: %w\nOutput: %s", err, string(output))
+ }
+
+ // Find the installed binary using the shared Go binary lookup
+ binaryPath, err := findGoBinary(s.binary)
+ if err != nil {
+ return nil, err
+ }
+
+ s.logger.Info("go install completed", zap.String("binary", binaryPath))
+ return &InstallResult{
+ BinaryPath: binaryPath,
+ }, nil
+}
diff --git a/apps/backend/internal/lsp/installer/installer.go b/apps/backend/internal/lsp/installer/installer.go
new file mode 100644
index 0000000000..cf041e3260
--- /dev/null
+++ b/apps/backend/internal/lsp/installer/installer.go
@@ -0,0 +1,16 @@
+package installer
+
+import "context"
+
+// InstallResult contains information about an installed language server binary.
+type InstallResult struct {
+ BinaryPath string // absolute path to installed binary
+}
+
+// Strategy is the abstraction for different install methods.
+type Strategy interface {
+ // Install downloads/installs the language server. Blocks until done.
+ Install(ctx context.Context) (*InstallResult, error)
+ // Name returns a human-readable name for logging.
+ Name() string
+}
diff --git a/apps/backend/internal/lsp/installer/npm_strategy.go b/apps/backend/internal/lsp/installer/npm_strategy.go
new file mode 100644
index 0000000000..8b93366a04
--- /dev/null
+++ b/apps/backend/internal/lsp/installer/npm_strategy.go
@@ -0,0 +1,69 @@
+package installer
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+
+ "github.com/kandev/kandev/internal/common/logger"
+ "go.uber.org/zap"
+)
+
+// NpmStrategy installs language servers via npm.
+// Used for TypeScript (typescript-language-server) and Python (pyright).
+type NpmStrategy struct {
+ binDir string // ~/.kandev/lsp-servers
+ binary string // e.g. "typescript-language-server"
+ packages []string // e.g. ["typescript-language-server", "typescript"]
+ logger *logger.Logger
+}
+
+// NewNpmStrategy creates a new npm install strategy.
+func NewNpmStrategy(binDir, binary string, packages []string, log *logger.Logger) *NpmStrategy {
+ return &NpmStrategy{
+ binDir: binDir,
+ binary: binary,
+ packages: packages,
+ logger: log,
+ }
+}
+
+func (s *NpmStrategy) Name() string {
+ return fmt.Sprintf("npm install %s", strings.Join(s.packages, " "))
+}
+
+func (s *NpmStrategy) Install(ctx context.Context) (*InstallResult, error) {
+ npmPath, err := exec.LookPath("npm")
+ if err != nil {
+ return nil, fmt.Errorf("npm not found: %w", err)
+ }
+
+ // Ensure bin directory exists
+ if err := os.MkdirAll(s.binDir, 0o755); err != nil {
+ return nil, fmt.Errorf("failed to create directory %s: %w", s.binDir, err)
+ }
+
+ args := append([]string{"install", "--prefix", s.binDir}, s.packages...)
+ cmd := exec.CommandContext(ctx, npmPath, args...)
+ cmd.Dir = s.binDir
+
+ s.logger.Info("installing via npm", zap.Strings("packages", s.packages), zap.String("prefix", s.binDir))
+
+ output, err := cmd.CombinedOutput()
+ if err != nil {
+ return nil, fmt.Errorf("npm install failed: %w\nOutput: %s", err, string(output))
+ }
+
+ binaryPath := filepath.Join(s.binDir, "node_modules", ".bin", s.binary)
+ if _, err := os.Stat(binaryPath); err != nil {
+ return nil, fmt.Errorf("binary not found after install: %s", binaryPath)
+ }
+
+ s.logger.Info("npm install completed", zap.String("binary", binaryPath))
+ return &InstallResult{
+ BinaryPath: binaryPath,
+ }, nil
+}
diff --git a/apps/backend/internal/lsp/installer/registry.go b/apps/backend/internal/lsp/installer/registry.go
new file mode 100644
index 0000000000..1d99d38e8d
--- /dev/null
+++ b/apps/backend/internal/lsp/installer/registry.go
@@ -0,0 +1,164 @@
+package installer
+
+import (
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+
+ "github.com/kandev/kandev/internal/common/logger"
+ "go.uber.org/zap"
+)
+
+// DefaultBinDir is where LSP binaries installed by Kandev are placed.
+const DefaultBinDir = ".kandev/lsp-servers"
+
+// languageConfig holds the binary name and CLI arguments for a language server.
+type languageConfig struct {
+ binary string
+ args []string
+}
+
+// languages is the single source of truth for supported LSP languages.
+var languages = map[string]languageConfig{
+ "typescript": {binary: "typescript-language-server", args: []string{"--stdio"}},
+ "go": {binary: "gopls", args: []string{"serve"}},
+ "rust": {binary: "rust-analyzer", args: nil},
+ "python": {binary: "pyright-langserver", args: []string{"--stdio"}},
+}
+
+// SupportedLanguages returns the set of supported LSP language identifiers.
+func SupportedLanguages() map[string]struct{} {
+ result := make(map[string]struct{}, len(languages))
+ for lang := range languages {
+ result[lang] = struct{}{}
+ }
+ return result
+}
+
+// IsSupported returns true if the language has a registered LSP configuration.
+func IsSupported(language string) bool {
+ _, ok := languages[language]
+ return ok
+}
+
+// LspCommand returns the binary name and arguments for a language server.
+func LspCommand(language string) (binary string, args []string) {
+ cfg, ok := languages[language]
+ if !ok {
+ return "", nil
+ }
+ return cfg.binary, cfg.args
+}
+
+// binaryName returns the expected binary name for a language.
+func binaryName(language string) (string, error) {
+ cfg, ok := languages[language]
+ if !ok {
+ return "", fmt.Errorf("unsupported language: %s", language)
+ }
+ return cfg.binary, nil
+}
+
+// findGoBinary looks for a Go binary in GOBIN, GOPATH/bin, and ~/go/bin.
+func findGoBinary(binary string) (string, error) {
+ if gobin := os.Getenv("GOBIN"); gobin != "" {
+ p := filepath.Join(gobin, binary)
+ if _, err := os.Stat(p); err == nil {
+ return p, nil
+ }
+ }
+ if gopath := os.Getenv("GOPATH"); gopath != "" {
+ p := filepath.Join(gopath, "bin", binary)
+ if _, err := os.Stat(p); err == nil {
+ return p, nil
+ }
+ }
+ home, _ := os.UserHomeDir()
+ if home != "" {
+ p := filepath.Join(home, "go", "bin", binary)
+ if _, err := os.Stat(p); err == nil {
+ return p, nil
+ }
+ }
+ return "", fmt.Errorf("%s not found in GOBIN/GOPATH/~/go/bin", binary)
+}
+
+// Registry maps language IDs to install strategies and resolves binary paths.
+type Registry struct {
+ binDir string // resolved absolute path
+ logger *logger.Logger
+}
+
+// NewRegistry creates a new installer registry.
+func NewRegistry(log *logger.Logger) *Registry {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ home = "."
+ }
+ return &Registry{
+ binDir: filepath.Join(home, DefaultBinDir),
+ logger: log.WithFields(zap.String("component", "lsp-installer")),
+ }
+}
+
+// StrategyFor returns the install strategy for a language.
+func (r *Registry) StrategyFor(language string) (Strategy, error) {
+ switch language {
+ case "typescript":
+ return NewNpmStrategy(r.binDir, "typescript-language-server", []string{"typescript-language-server", "typescript"}, r.logger), nil
+ case "go":
+ return NewGoInstallStrategy("gopls", "golang.org/x/tools/gopls@latest", r.logger), nil
+ case "rust":
+ return NewGithubReleaseStrategy(r.binDir, "rust-analyzer", GithubReleaseConfig{
+ Owner: "rust-lang",
+ Repo: "rust-analyzer",
+ AssetPattern: "rust-analyzer-{target}.gz",
+ Targets: map[string]string{
+ "darwin/arm64": "aarch64-apple-darwin",
+ "darwin/amd64": "x86_64-apple-darwin",
+ "linux/amd64": "x86_64-unknown-linux-gnu",
+ "linux/arm64": "aarch64-unknown-linux-gnu",
+ },
+ }, r.logger), nil
+ case "python":
+ return NewNpmStrategy(r.binDir, "pyright-langserver", []string{"pyright"}, r.logger), nil
+ default:
+ return nil, fmt.Errorf("no installer for language: %s", language)
+ }
+}
+
+// BinaryPath checks if a language server binary is installed.
+// It checks the system PATH, the Kandev bin directory, and Go-specific paths.
+func (r *Registry) BinaryPath(language string) (string, error) {
+ binary, err := binaryName(language)
+ if err != nil {
+ return "", err
+ }
+
+ // Check system PATH first
+ if p, err := exec.LookPath(binary); err == nil {
+ return p, nil
+ }
+
+ // Check Kandev bin directory (npm node_modules/.bin/)
+ npmBinPath := filepath.Join(r.binDir, "node_modules", ".bin", binary)
+ if _, err := os.Stat(npmBinPath); err == nil {
+ return npmBinPath, nil
+ }
+
+ // Check Kandev bin directory (direct binary)
+ directPath := filepath.Join(r.binDir, binary)
+ if _, err := os.Stat(directPath); err == nil {
+ return directPath, nil
+ }
+
+ // Check Go-specific paths for Go binaries
+ if language == "go" {
+ if p, err := findGoBinary(binary); err == nil {
+ return p, nil
+ }
+ }
+
+ return "", fmt.Errorf("%s not found", binary)
+}
diff --git a/apps/backend/internal/lsp/installer/registry_test.go b/apps/backend/internal/lsp/installer/registry_test.go
new file mode 100644
index 0000000000..0cbe63ba84
--- /dev/null
+++ b/apps/backend/internal/lsp/installer/registry_test.go
@@ -0,0 +1,255 @@
+package installer
+
+import (
+ "os"
+ "os/exec"
+ "path/filepath"
+ "testing"
+
+ "github.com/kandev/kandev/internal/common/logger"
+)
+
+func testLogger() *logger.Logger {
+ log, _ := logger.NewLogger(logger.LoggingConfig{
+ Level: "error",
+ Format: "json",
+ OutputPath: os.DevNull,
+ })
+ return log
+}
+
+func TestSupportedLanguages(t *testing.T) {
+ langs := SupportedLanguages()
+ expected := []string{"typescript", "go", "rust", "python"}
+ for _, lang := range expected {
+ if _, ok := langs[lang]; !ok {
+ t.Errorf("expected %q in SupportedLanguages()", lang)
+ }
+ }
+ if _, ok := langs["java"]; ok {
+ t.Error("unexpected language 'java' in SupportedLanguages()")
+ }
+}
+
+func TestIsSupported(t *testing.T) {
+ tests := []struct {
+ language string
+ want bool
+ }{
+ {"typescript", true},
+ {"go", true},
+ {"rust", true},
+ {"python", true},
+ {"java", false},
+ {"", false},
+ {"ruby", false},
+ }
+ for _, tc := range tests {
+ if got := IsSupported(tc.language); got != tc.want {
+ t.Errorf("IsSupported(%q) = %v, want %v", tc.language, got, tc.want)
+ }
+ }
+}
+
+func TestLspCommand(t *testing.T) {
+ tests := []struct {
+ language string
+ wantBinary string
+ wantArgs []string
+ }{
+ {"typescript", "typescript-language-server", []string{"--stdio"}},
+ {"go", "gopls", []string{"serve"}},
+ {"rust", "rust-analyzer", nil},
+ {"python", "pyright-langserver", []string{"--stdio"}},
+ {"unknown", "", nil},
+ }
+ for _, tc := range tests {
+ binary, args := LspCommand(tc.language)
+ if binary != tc.wantBinary {
+ t.Errorf("LspCommand(%q) binary = %q, want %q", tc.language, binary, tc.wantBinary)
+ }
+ if len(args) != len(tc.wantArgs) {
+ t.Errorf("LspCommand(%q) args = %v, want %v", tc.language, args, tc.wantArgs)
+ } else {
+ for i := range args {
+ if args[i] != tc.wantArgs[i] {
+ t.Errorf("LspCommand(%q) args[%d] = %q, want %q", tc.language, i, args[i], tc.wantArgs[i])
+ }
+ }
+ }
+ }
+}
+
+func TestBinaryName(t *testing.T) {
+ tests := []struct {
+ language string
+ want string
+ wantErr bool
+ }{
+ {"typescript", "typescript-language-server", false},
+ {"go", "gopls", false},
+ {"rust", "rust-analyzer", false},
+ {"python", "pyright-langserver", false},
+ {"java", "", true},
+ }
+ for _, tc := range tests {
+ got, err := binaryName(tc.language)
+ if (err != nil) != tc.wantErr {
+ t.Errorf("binaryName(%q) error = %v, wantErr %v", tc.language, err, tc.wantErr)
+ continue
+ }
+ if got != tc.want {
+ t.Errorf("binaryName(%q) = %q, want %q", tc.language, got, tc.want)
+ }
+ }
+}
+
+func TestStrategyFor(t *testing.T) {
+ r := NewRegistry(testLogger())
+
+ // Supported languages should return a strategy
+ for _, lang := range []string{"typescript", "go", "rust", "python"} {
+ s, err := r.StrategyFor(lang)
+ if err != nil {
+ t.Errorf("StrategyFor(%q) returned error: %v", lang, err)
+ continue
+ }
+ if s == nil {
+ t.Errorf("StrategyFor(%q) returned nil strategy", lang)
+ continue
+ }
+ if s.Name() == "" {
+ t.Errorf("StrategyFor(%q).Name() is empty", lang)
+ }
+ }
+
+ // Unsupported language should return error
+ _, err := r.StrategyFor("java")
+ if err == nil {
+ t.Error("StrategyFor(\"java\") should return error")
+ }
+}
+
+func TestBinaryPath_InPATH(t *testing.T) {
+ // "ls" should always be in PATH
+ r := NewRegistry(testLogger())
+
+ // Override the languages map temporarily — we can't do that directly,
+ // so we test that BinaryPath returns an error for a language whose binary
+ // is not installed at all.
+ _, err := r.BinaryPath("rust")
+ // We can't guarantee rust-analyzer is installed, but we can verify
+ // the function doesn't panic and returns a valid result or error
+ _ = err
+}
+
+func TestBinaryPath_InBinDir(t *testing.T) {
+ // Create a temp bin directory with a fake binary
+ tmpDir := t.TempDir()
+ fakeBinary := filepath.Join(tmpDir, "node_modules", ".bin", "typescript-language-server")
+ if err := os.MkdirAll(filepath.Dir(fakeBinary), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(fakeBinary, []byte("#!/bin/sh\n"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ r := &Registry{
+ binDir: tmpDir,
+ logger: testLogger(),
+ }
+
+ p, err := r.BinaryPath("typescript")
+ if err != nil {
+ // Only fail if it's not in PATH either
+ if _, lookErr := exec.LookPath("typescript-language-server"); lookErr != nil {
+ t.Errorf("BinaryPath(\"typescript\") error = %v (expected to find in binDir)", err)
+ }
+ return
+ }
+ if p == "" {
+ t.Error("BinaryPath(\"typescript\") returned empty path")
+ }
+}
+
+func TestBinaryPath_DirectBinary(t *testing.T) {
+ tmpDir := t.TempDir()
+ fakeBinary := filepath.Join(tmpDir, "rust-analyzer")
+ if err := os.WriteFile(fakeBinary, []byte("#!/bin/sh\n"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ r := &Registry{
+ binDir: tmpDir,
+ logger: testLogger(),
+ }
+
+ p, err := r.BinaryPath("rust")
+ if err != nil {
+ if _, lookErr := exec.LookPath("rust-analyzer"); lookErr != nil {
+ t.Errorf("BinaryPath(\"rust\") error = %v (expected to find direct binary)", err)
+ }
+ return
+ }
+ if p == "" {
+ t.Error("BinaryPath(\"rust\") returned empty path")
+ }
+}
+
+func TestBinaryPath_NotFound(t *testing.T) {
+ // Use empty binDir so nothing is found there
+ r := &Registry{
+ binDir: t.TempDir(),
+ logger: testLogger(),
+ }
+
+ // Use a language whose binary is unlikely to be in PATH on CI
+ // We test the error case by overriding PATH
+ origPath := os.Getenv("PATH")
+ t.Setenv("PATH", t.TempDir())
+ defer func() { _ = os.Setenv("PATH", origPath) }()
+
+ _, err := r.BinaryPath("rust")
+ if err == nil {
+ t.Error("BinaryPath(\"rust\") should return error when binary not found")
+ }
+}
+
+func TestBinaryPath_UnsupportedLanguage(t *testing.T) {
+ r := NewRegistry(testLogger())
+ _, err := r.BinaryPath("java")
+ if err == nil {
+ t.Error("BinaryPath(\"java\") should return error for unsupported language")
+ }
+}
+
+func TestFindGoBinary(t *testing.T) {
+ // Test with GOBIN set to a temp directory containing a fake binary
+ tmpDir := t.TempDir()
+ fakeBinary := filepath.Join(tmpDir, "gopls")
+ if err := os.WriteFile(fakeBinary, []byte("#!/bin/sh\n"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ t.Setenv("GOBIN", tmpDir)
+ t.Setenv("GOPATH", "")
+
+ p, err := findGoBinary("gopls")
+ if err != nil {
+ t.Errorf("findGoBinary(\"gopls\") error = %v", err)
+ }
+ if p != fakeBinary {
+ t.Errorf("findGoBinary(\"gopls\") = %q, want %q", p, fakeBinary)
+ }
+}
+
+func TestFindGoBinary_NotFound(t *testing.T) {
+ t.Setenv("GOBIN", t.TempDir())
+ t.Setenv("GOPATH", t.TempDir())
+ t.Setenv("HOME", t.TempDir())
+
+ _, err := findGoBinary("nonexistent-binary")
+ if err == nil {
+ t.Error("findGoBinary(\"nonexistent-binary\") should return error")
+ }
+}
diff --git a/apps/backend/internal/user/controller/controller.go b/apps/backend/internal/user/controller/controller.go
index 40f1651d85..9c6d58bcad 100644
--- a/apps/backend/internal/user/controller/controller.go
+++ b/apps/backend/internal/user/controller/controller.go
@@ -52,7 +52,10 @@ func (c *Controller) UpdateUserSettings(ctx context.Context, req dto.UpdateUserS
DefaultEditorID: req.DefaultEditorID,
EnablePreviewOnClick: req.EnablePreviewOnClick,
ChatSubmitKey: req.ChatSubmitKey,
- ReviewAutoMarkOnScroll: req.ReviewAutoMarkOnScroll,
+ ReviewAutoMarkOnScroll: req.ReviewAutoMarkOnScroll,
+ LspAutoStartLanguages: req.LspAutoStartLanguages,
+ LspAutoInstallLanguages: req.LspAutoInstallLanguages,
+ LspServerConfigs: req.LspServerConfigs,
})
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 bcabbb86d7..e2b3c940e3 100644
--- a/apps/backend/internal/user/dto/dto.go
+++ b/apps/backend/internal/user/dto/dto.go
@@ -22,9 +22,12 @@ type UserSettingsDTO struct {
PreferredShell string `json:"preferred_shell"`
DefaultEditorID string `json:"default_editor_id"`
EnablePreviewOnClick bool `json:"enable_preview_on_click"`
- ChatSubmitKey string `json:"chat_submit_key"`
- ReviewAutoMarkOnScroll bool `json:"review_auto_mark_on_scroll"`
- UpdatedAt string `json:"updated_at"`
+ ChatSubmitKey string `json:"chat_submit_key"`
+ ReviewAutoMarkOnScroll bool `json:"review_auto_mark_on_scroll"`
+ LspAutoStartLanguages []string `json:"lsp_auto_start_languages"`
+ LspAutoInstallLanguages []string `json:"lsp_auto_install_languages"`
+ LspServerConfigs map[string]map[string]interface{} `json:"lsp_server_configs,omitempty"`
+ UpdatedAt string `json:"updated_at"`
}
type UserResponse struct {
@@ -51,7 +54,10 @@ type UpdateUserSettingsRequest struct {
DefaultEditorID *string `json:"default_editor_id,omitempty"`
EnablePreviewOnClick *bool `json:"enable_preview_on_click,omitempty"`
ChatSubmitKey *string `json:"chat_submit_key,omitempty"`
- ReviewAutoMarkOnScroll *bool `json:"review_auto_mark_on_scroll,omitempty"`
+ ReviewAutoMarkOnScroll *bool `json:"review_auto_mark_on_scroll,omitempty"`
+ LspAutoStartLanguages *[]string `json:"lsp_auto_start_languages,omitempty"`
+ LspAutoInstallLanguages *[]string `json:"lsp_auto_install_languages,omitempty"`
+ LspServerConfigs *map[string]map[string]interface{} `json:"lsp_server_configs,omitempty"`
}
func FromUser(user *models.User) UserDTO {
@@ -74,7 +80,10 @@ func FromUserSettings(settings *models.UserSettings) UserSettingsDTO {
DefaultEditorID: settings.DefaultEditorID,
EnablePreviewOnClick: settings.EnablePreviewOnClick,
ChatSubmitKey: settings.ChatSubmitKey,
- ReviewAutoMarkOnScroll: settings.ReviewAutoMarkOnScroll,
- UpdatedAt: settings.UpdatedAt.Format(time.RFC3339),
+ ReviewAutoMarkOnScroll: settings.ReviewAutoMarkOnScroll,
+ LspAutoStartLanguages: settings.LspAutoStartLanguages,
+ LspAutoInstallLanguages: settings.LspAutoInstallLanguages,
+ LspServerConfigs: settings.LspServerConfigs,
+ 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 159e271668..204abaa82a 100644
--- a/apps/backend/internal/user/handlers/handlers.go
+++ b/apps/backend/internal/user/handlers/handlers.go
@@ -63,35 +63,13 @@ func (h *Handlers) httpGetUserSettings(c *gin.Context) {
c.JSON(http.StatusOK, resp)
}
-type httpUpdateUserSettingsRequest struct {
- WorkspaceID *string `json:"workspace_id,omitempty"`
- BoardID *string `json:"board_id,omitempty"`
- RepositoryIDs *[]string `json:"repository_ids,omitempty"`
- InitialSetupComplete *bool `json:"initial_setup_complete,omitempty"`
- PreferredShell *string `json:"preferred_shell,omitempty"`
- DefaultEditorID *string `json:"default_editor_id,omitempty"`
- EnablePreviewOnClick *bool `json:"enable_preview_on_click,omitempty"`
- ChatSubmitKey *string `json:"chat_submit_key,omitempty"`
- ReviewAutoMarkOnScroll *bool `json:"review_auto_mark_on_scroll,omitempty"`
-}
-
func (h *Handlers) httpUpdateUserSettings(c *gin.Context) {
- var body httpUpdateUserSettingsRequest
- if err := c.ShouldBindJSON(&body); err != nil {
+ var req dto.UpdateUserSettingsRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
return
}
- resp, err := h.controller.UpdateUserSettings(c.Request.Context(), dto.UpdateUserSettingsRequest{
- WorkspaceID: body.WorkspaceID,
- BoardID: body.BoardID,
- RepositoryIDs: body.RepositoryIDs,
- InitialSetupComplete: body.InitialSetupComplete,
- PreferredShell: body.PreferredShell,
- DefaultEditorID: body.DefaultEditorID,
- EnablePreviewOnClick: body.EnablePreviewOnClick,
- ChatSubmitKey: body.ChatSubmitKey,
- ReviewAutoMarkOnScroll: body.ReviewAutoMarkOnScroll,
- })
+ resp, err := h.controller.UpdateUserSettings(c.Request.Context(), req)
if err != nil {
h.logger.Error("failed to update user settings", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update user settings"})
@@ -108,34 +86,12 @@ func (h *Handlers) wsGetUser(ctx context.Context, msg *ws.Message) (*ws.Message,
return ws.NewResponse(msg.ID, msg.Action, resp)
}
-type wsUpdateUserSettingsRequest struct {
- WorkspaceID *string `json:"workspace_id,omitempty"`
- BoardID *string `json:"board_id,omitempty"`
- RepositoryIDs *[]string `json:"repository_ids,omitempty"`
- InitialSetupComplete *bool `json:"initial_setup_complete,omitempty"`
- PreferredShell *string `json:"preferred_shell,omitempty"`
- DefaultEditorID *string `json:"default_editor_id,omitempty"`
- EnablePreviewOnClick *bool `json:"enable_preview_on_click,omitempty"`
- ChatSubmitKey *string `json:"chat_submit_key,omitempty"`
- ReviewAutoMarkOnScroll *bool `json:"review_auto_mark_on_scroll,omitempty"`
-}
-
func (h *Handlers) wsUpdateUserSettings(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
- var req wsUpdateUserSettingsRequest
+ var req dto.UpdateUserSettingsRequest
if err := msg.ParsePayload(&req); err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
}
- resp, err := h.controller.UpdateUserSettings(ctx, dto.UpdateUserSettingsRequest{
- WorkspaceID: req.WorkspaceID,
- BoardID: req.BoardID,
- RepositoryIDs: req.RepositoryIDs,
- InitialSetupComplete: req.InitialSetupComplete,
- PreferredShell: req.PreferredShell,
- DefaultEditorID: req.DefaultEditorID,
- EnablePreviewOnClick: req.EnablePreviewOnClick,
- ChatSubmitKey: req.ChatSubmitKey,
- ReviewAutoMarkOnScroll: req.ReviewAutoMarkOnScroll,
- })
+ 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)
}
diff --git a/apps/backend/internal/user/models/models.go b/apps/backend/internal/user/models/models.go
index 88da4e7a04..96905b7974 100644
--- a/apps/backend/internal/user/models/models.go
+++ b/apps/backend/internal/user/models/models.go
@@ -19,7 +19,10 @@ type UserSettings struct {
DefaultEditorID string `json:"default_editor_id"`
EnablePreviewOnClick bool `json:"enable_preview_on_click"`
ChatSubmitKey string `json:"chat_submit_key"` // "enter" | "cmd_enter"
- ReviewAutoMarkOnScroll bool `json:"review_auto_mark_on_scroll"`
- CreatedAt time.Time `json:"created_at"`
- UpdatedAt time.Time `json:"updated_at"`
+ ReviewAutoMarkOnScroll bool `json:"review_auto_mark_on_scroll"`
+ LspAutoStartLanguages []string `json:"lsp_auto_start_languages"`
+ LspAutoInstallLanguages []string `json:"lsp_auto_install_languages"`
+ LspServerConfigs map[string]map[string]interface{} `json:"lsp_server_configs"`
+ 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 5f0cfff639..cb44849c63 100644
--- a/apps/backend/internal/user/service/service.go
+++ b/apps/backend/internal/user/service/service.go
@@ -3,12 +3,14 @@ package service
import (
"context"
"errors"
+ "fmt"
"strings"
"time"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/events"
"github.com/kandev/kandev/internal/events/bus"
+ "github.com/kandev/kandev/internal/lsp/installer"
"github.com/kandev/kandev/internal/user/models"
"github.com/kandev/kandev/internal/user/store"
"go.uber.org/zap"
@@ -32,7 +34,10 @@ type UpdateUserSettingsRequest struct {
DefaultEditorID *string
EnablePreviewOnClick *bool
ChatSubmitKey *string
- ReviewAutoMarkOnScroll *bool
+ ReviewAutoMarkOnScroll *bool
+ LspAutoStartLanguages *[]string
+ LspAutoInstallLanguages *[]string
+ LspServerConfigs *map[string]map[string]interface{}
}
func NewService(repo store.Repository, eventBus bus.EventBus, log *logger.Logger) *Service {
@@ -105,6 +110,21 @@ func (s *Service) UpdateUserSettings(ctx context.Context, req *UpdateUserSetting
if req.ReviewAutoMarkOnScroll != nil {
settings.ReviewAutoMarkOnScroll = *req.ReviewAutoMarkOnScroll
}
+ if req.LspAutoStartLanguages != nil {
+ if err := validateLSPLanguages(*req.LspAutoStartLanguages); err != nil {
+ return nil, fmt.Errorf("lsp_auto_start_languages: %w", err)
+ }
+ settings.LspAutoStartLanguages = *req.LspAutoStartLanguages
+ }
+ if req.LspAutoInstallLanguages != nil {
+ if err := validateLSPLanguages(*req.LspAutoInstallLanguages); err != nil {
+ return nil, fmt.Errorf("lsp_auto_install_languages: %w", err)
+ }
+ settings.LspAutoInstallLanguages = *req.LspAutoInstallLanguages
+ }
+ if req.LspServerConfigs != nil {
+ settings.LspServerConfigs = *req.LspServerConfigs
+ }
settings.UpdatedAt = time.Now().UTC()
if err := s.repo.UpsertUserSettings(ctx, settings); err != nil {
return nil, err
@@ -127,7 +147,10 @@ func (s *Service) publishUserSettingsEvent(ctx context.Context, settings *models
"default_editor_id": settings.DefaultEditorID,
"enable_preview_on_click": settings.EnablePreviewOnClick,
"chat_submit_key": settings.ChatSubmitKey,
- "review_auto_mark_on_scroll": settings.ReviewAutoMarkOnScroll,
+ "review_auto_mark_on_scroll": settings.ReviewAutoMarkOnScroll,
+ "lsp_auto_start_languages": settings.LspAutoStartLanguages,
+ "lsp_auto_install_languages": settings.LspAutoInstallLanguages,
+ "lsp_server_configs": settings.LspServerConfigs,
"updated_at": settings.UpdatedAt.Format(time.RFC3339),
}
if err := s.eventBus.Publish(ctx, events.UserSettingsUpdated, bus.NewEvent(events.UserSettingsUpdated, "user-service", data)); err != nil {
@@ -135,6 +158,16 @@ func (s *Service) publishUserSettingsEvent(ctx context.Context, settings *models
}
}
+func validateLSPLanguages(langs []string) error {
+ supported := installer.SupportedLanguages()
+ for _, lang := range langs {
+ if _, ok := supported[lang]; !ok {
+ return fmt.Errorf("unsupported language: %s", lang)
+ }
+ }
+ return nil
+}
+
func (s *Service) ClearDefaultEditorID(ctx context.Context, editorID string) error {
if editorID == "" {
return nil
diff --git a/apps/backend/internal/user/store/sqlite.go b/apps/backend/internal/user/store/sqlite.go
index 016a744bb1..06800633bf 100644
--- a/apps/backend/internal/user/store/sqlite.go
+++ b/apps/backend/internal/user/store/sqlite.go
@@ -111,16 +111,31 @@ func (r *sqliteRepository) UpsertUserSettings(ctx context.Context, settings *mod
if settings.CreatedAt.IsZero() {
settings.CreatedAt = settings.UpdatedAt
}
+ lspAutoStart := settings.LspAutoStartLanguages
+ if lspAutoStart == nil {
+ lspAutoStart = []string{}
+ }
+ lspAutoInstall := settings.LspAutoInstallLanguages
+ if lspAutoInstall == nil {
+ lspAutoInstall = []string{}
+ }
+ lspServerConfigs := settings.LspServerConfigs
+ if lspServerConfigs == nil {
+ lspServerConfigs = map[string]map[string]interface{}{}
+ }
settingsPayload, err := json.Marshal(map[string]interface{}{
- "workspace_id": settings.WorkspaceID,
- "board_id": settings.BoardID,
- "repository_ids": settings.RepositoryIDs,
- "initial_setup_complete": settings.InitialSetupComplete,
- "preferred_shell": settings.PreferredShell,
- "default_editor_id": settings.DefaultEditorID,
- "enable_preview_on_click": settings.EnablePreviewOnClick,
- "chat_submit_key": settings.ChatSubmitKey,
+ "workspace_id": settings.WorkspaceID,
+ "board_id": settings.BoardID,
+ "repository_ids": settings.RepositoryIDs,
+ "initial_setup_complete": settings.InitialSetupComplete,
+ "preferred_shell": settings.PreferredShell,
+ "default_editor_id": settings.DefaultEditorID,
+ "enable_preview_on_click": settings.EnablePreviewOnClick,
+ "chat_submit_key": settings.ChatSubmitKey,
"review_auto_mark_on_scroll": settings.ReviewAutoMarkOnScroll,
+ "lsp_auto_start_languages": lspAutoStart,
+ "lsp_auto_install_languages": lspAutoInstall,
+ "lsp_server_configs": lspServerConfigs,
})
if err != nil {
return err
@@ -130,13 +145,17 @@ func (r *sqliteRepository) UpsertUserSettings(ctx context.Context, settings *mod
SET settings = ?, updated_at = ?
WHERE id = ?
`, string(settingsPayload), settings.UpdatedAt, settings.UserID)
- if err == nil {
- rows, _ := result.RowsAffected()
- if rows == 0 {
- return fmt.Errorf("user not found: %s", settings.UserID)
- }
+ if err != nil {
+ return err
}
- return err
+ rows, err := result.RowsAffected()
+ if err != nil {
+ return fmt.Errorf("failed to check rows affected: %w", err)
+ }
+ if rows == 0 {
+ return fmt.Errorf("user not found: %s", settings.UserID)
+ }
+ return nil
}
func scanUser(scanner interface{ Scan(dest ...any) error }) (*models.User, error) {
@@ -159,15 +178,18 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin
return settings, nil
}
var payload struct {
- WorkspaceID string `json:"workspace_id"`
- BoardID string `json:"board_id"`
- RepositoryIDs []string `json:"repository_ids"`
- InitialSetupComplete bool `json:"initial_setup_complete"`
- PreferredShell string `json:"preferred_shell"`
- DefaultEditorID string `json:"default_editor_id"`
- EnablePreviewOnClick bool `json:"enable_preview_on_click"`
- ChatSubmitKey string `json:"chat_submit_key"`
- ReviewAutoMarkOnScroll *bool `json:"review_auto_mark_on_scroll"`
+ WorkspaceID string `json:"workspace_id"`
+ BoardID string `json:"board_id"`
+ RepositoryIDs []string `json:"repository_ids"`
+ InitialSetupComplete bool `json:"initial_setup_complete"`
+ PreferredShell string `json:"preferred_shell"`
+ DefaultEditorID string `json:"default_editor_id"`
+ EnablePreviewOnClick bool `json:"enable_preview_on_click"`
+ ChatSubmitKey string `json:"chat_submit_key"`
+ ReviewAutoMarkOnScroll *bool `json:"review_auto_mark_on_scroll"`
+ LspAutoStartLanguages []string `json:"lsp_auto_start_languages"`
+ LspAutoInstallLanguages []string `json:"lsp_auto_install_languages"`
+ LspServerConfigs map[string]map[string]interface{} `json:"lsp_server_configs"`
}
if err := json.Unmarshal([]byte(settingsRaw), &payload); err != nil {
return nil, err
@@ -190,5 +212,14 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin
} else {
settings.ReviewAutoMarkOnScroll = true
}
+ settings.LspAutoStartLanguages = payload.LspAutoStartLanguages
+ if settings.LspAutoStartLanguages == nil {
+ settings.LspAutoStartLanguages = []string{}
+ }
+ settings.LspAutoInstallLanguages = payload.LspAutoInstallLanguages
+ if settings.LspAutoInstallLanguages == nil {
+ settings.LspAutoInstallLanguages = []string{}
+ }
+ settings.LspServerConfigs = payload.LspServerConfigs
return settings, nil
}
diff --git a/apps/pnpm-lock.yaml b/apps/pnpm-lock.yaml
index 67c6691806..76a180a06a 100644
--- a/apps/pnpm-lock.yaml
+++ b/apps/pnpm-lock.yaml
@@ -230,33 +230,9 @@ importers:
'@lezer/highlight':
specifier: ^1.2.3
version: 1.2.3
- '@milkdown/core':
- specifier: ^7.18.0
- version: 7.18.0
- '@milkdown/ctx':
- specifier: ^7.18.0
- version: 7.18.0
- '@milkdown/plugin-listener':
- specifier: ^7.18.0
- version: 7.18.0
- '@milkdown/preset-commonmark':
- specifier: ^7.18.0
- version: 7.18.0
- '@milkdown/preset-gfm':
- specifier: ^7.18.0
- version: 7.18.0
- '@milkdown/prose':
- specifier: ^7.18.0
- version: 7.18.0
- '@milkdown/react':
- specifier: ^7.18.0
- version: 7.18.0(@codemirror/language@6.12.1)(@codemirror/state@6.5.3)(@codemirror/view@6.39.10)(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.5)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)
- '@milkdown/theme-nord':
- specifier: ^7.18.0
- version: 7.18.0
- '@milkdown/utils':
- specifier: ^7.18.0
- version: 7.18.0
+ '@monaco-editor/react':
+ specifier: ^4.7.0
+ version: 4.7.0(monaco-editor@0.55.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@pierre/diffs':
specifier: ^1.0.10
version: 1.0.10(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@@ -272,6 +248,9 @@ importers:
'@tiptap/core':
specifier: ^3.19.0
version: 3.19.0(@tiptap/pm@3.19.0)
+ '@tiptap/extension-bubble-menu':
+ specifier: ^3.19.0
+ version: 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)
'@tiptap/extension-code':
specifier: ^3.19.0
version: 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))
@@ -287,6 +266,12 @@ importers:
'@tiptap/extension-history':
specifier: ^3.19.0
version: 3.19.0(@tiptap/extensions@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))
+ '@tiptap/extension-image':
+ specifier: ^3.19.0
+ version: 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))
+ '@tiptap/extension-link':
+ specifier: ^3.19.0
+ version: 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)
'@tiptap/extension-mention':
specifier: ^3.19.0
version: 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)(@tiptap/suggestion@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))
@@ -296,6 +281,24 @@ importers:
'@tiptap/extension-placeholder':
specifier: ^3.19.0
version: 3.19.0(@tiptap/extensions@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))
+ '@tiptap/extension-table':
+ specifier: ^3.19.0
+ version: 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)
+ '@tiptap/extension-table-cell':
+ specifier: ^3.19.0
+ version: 3.19.0(@tiptap/extension-table@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))
+ '@tiptap/extension-table-header':
+ specifier: ^3.19.0
+ version: 3.19.0(@tiptap/extension-table@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))
+ '@tiptap/extension-table-row':
+ specifier: ^3.19.0
+ version: 3.19.0(@tiptap/extension-table@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))
+ '@tiptap/extension-task-item':
+ specifier: ^3.19.0
+ version: 3.19.0(@tiptap/extension-list@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))
+ '@tiptap/extension-task-list':
+ specifier: ^3.19.0
+ version: 3.19.0(@tiptap/extension-list@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))
'@tiptap/extension-text':
specifier: ^3.19.0
version: 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))
@@ -305,6 +308,9 @@ importers:
'@tiptap/react':
specifier: ^3.19.0
version: 3.19.0(@floating-ui/dom@1.7.4)(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ '@tiptap/starter-kit':
+ specifier: ^3.19.0
+ version: 3.19.0
'@tiptap/suggestion':
specifier: ^3.19.0
version: 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)
@@ -365,6 +371,9 @@ importers:
mermaid:
specifier: ^11.12.2
version: 11.12.2
+ monaco-editor:
+ specifier: ^0.55.1
+ version: 0.55.1
next:
specifier: 16.1.1
version: 16.1.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@@ -410,6 +419,9 @@ importers:
tailwind-merge:
specifier: ^3.4.0
version: 3.4.0
+ tiptap-markdown:
+ specifier: ^0.9.0
+ version: 0.9.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))
tw-animate-css:
specifier: ^1.4.0
version: 1.4.0
@@ -648,9 +660,6 @@ packages:
'@codemirror/commands@6.10.1':
resolution: {integrity: sha512-uWDWFypNdQmz2y1LaNJzK7fL7TYKLeUAU0npEC685OKTF3KcQ2Vu3klIM78D7I6wGhktme0lh3CuQLv0ZCrD9Q==}
- '@codemirror/lang-angular@0.1.4':
- resolution: {integrity: sha512-oap+gsltb/fzdlTQWD6BFF4bSLKcDnlxDsLdePiJpCVNKWXSTAbiiQeYI3UmES+BLAdkmIC1WjyztC1pi/bX4g==}
-
'@codemirror/lang-cpp@6.0.3':
resolution: {integrity: sha512-URM26M3vunFFn9/sm6rzqrBzDgfWuDixp85uTY49wKudToc2jTHUrKIGGKs+QWND+YLofNNZpxcNGRynFJfvgA==}
@@ -669,57 +678,24 @@ packages:
'@codemirror/lang-javascript@6.2.4':
resolution: {integrity: sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA==}
- '@codemirror/lang-jinja@6.0.0':
- resolution: {integrity: sha512-47MFmRcR8UAxd8DReVgj7WJN1WSAMT7OJnewwugZM4XiHWkOjgJQqvEM1NpMj9ALMPyxmlziEI1opH9IaEvmaw==}
-
'@codemirror/lang-json@6.0.2':
resolution: {integrity: sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==}
- '@codemirror/lang-less@6.0.2':
- resolution: {integrity: sha512-EYdQTG22V+KUUk8Qq582g7FMnCZeEHsyuOJisHRft/mQ+ZSZ2w51NupvDUHiqtsOy7It5cHLPGfHQLpMh9bqpQ==}
-
- '@codemirror/lang-liquid@6.3.1':
- resolution: {integrity: sha512-S/jE/D7iij2Pu70AC65ME6AYWxOOcX20cSJvaPgY5w7m2sfxsArAcUAuUgm/CZCVmqoi9KiOlS7gj/gyLipABw==}
-
'@codemirror/lang-markdown@6.5.0':
resolution: {integrity: sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw==}
- '@codemirror/lang-php@6.0.2':
- resolution: {integrity: sha512-ZKy2v1n8Fc8oEXj0Th0PUMXzQJ0AIR6TaZU+PbDHExFwdu+guzOA4jmCHS1Nz4vbFezwD7LyBdDnddSJeScMCA==}
-
'@codemirror/lang-python@6.2.1':
resolution: {integrity: sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==}
'@codemirror/lang-rust@6.0.2':
resolution: {integrity: sha512-EZaGjCUegtiU7kSMvOfEZpaCReowEf3yNidYu7+vfuGTm9ow4mthAparY5hisJqOHmJowVH3Upu+eJlUji6qqA==}
- '@codemirror/lang-sass@6.0.2':
- resolution: {integrity: sha512-l/bdzIABvnTo1nzdY6U+kPAC51czYQcOErfzQ9zSm9D8GmNPD0WTW8st/CJwBTPLO8jlrbyvlSEcN20dc4iL0Q==}
-
- '@codemirror/lang-sql@6.10.0':
- resolution: {integrity: sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w==}
-
- '@codemirror/lang-vue@0.1.3':
- resolution: {integrity: sha512-QSKdtYTDRhEHCfo5zOShzxCmqKJvgGrZwDQSdbvCRJ5pRLWBS7pD/8e/tH44aVQT6FKm0t6RVNoSUWHOI5vNug==}
-
- '@codemirror/lang-wast@6.0.2':
- resolution: {integrity: sha512-Imi2KTpVGm7TKuUkqyJ5NRmeFWF7aMpNiwHnLQe0x9kmrxElndyH0K6H/gXtWwY6UshMRAhpENsgfpSwsgmC6Q==}
-
- '@codemirror/lang-xml@6.1.0':
- resolution: {integrity: sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg==}
-
'@codemirror/lang-yaml@6.1.2':
resolution: {integrity: sha512-dxrfG8w5Ce/QbT7YID7mWZFKhdhsaTNOYjOkSIMt1qmC4VQnXSDSYVHHHn8k6kJUfIhtLo8t1JJgltlxWdsITw==}
- '@codemirror/language-data@6.5.2':
- resolution: {integrity: sha512-CPkWBKrNS8stYbEU5kwBwTf3JB1kghlbh4FSAwzGW2TEscdeHHH4FGysREW86Mqnj3Qn09s0/6Ea/TutmoTobg==}
-
'@codemirror/language@6.12.1':
resolution: {integrity: sha512-Fa6xkSiuGKc8XC8Cn96T+TQHYj4ZZ7RdFmXA3i9xe/3hLHfwPZdM+dqfX0Cp0zQklBKhVD8Yzc8LS45rkqcwpQ==}
- '@codemirror/legacy-modes@6.5.2':
- resolution: {integrity: sha512-/jJbwSTazlQEDOQw2FJ8LEEKVS72pU0lx6oM54kGpL8t/NJ2Jda3CZ4pcltiKTdqYSRk3ug1B3pil1gsjA6+8Q==}
-
'@codemirror/lint@6.9.2':
resolution: {integrity: sha512-sv3DylBiIyi+xKwRCJAAsBZZZWo82shJ/RTMymLabAdtbkV5cSKwWDeCgtUq3v8flTaXS2y1kKkICuRYtUswyQ==}
@@ -1387,21 +1363,12 @@ packages:
'@lezer/markdown@1.6.3':
resolution: {integrity: sha512-jpGm5Ps+XErS+xA4urw7ogEGkeZOahVQF21Z6oECF0sj+2liwZopd2+I8uH5I/vZsRuuze3OxBREIANLf6KKUw==}
- '@lezer/php@1.0.5':
- resolution: {integrity: sha512-W7asp9DhM6q0W6DYNwIkLSKOvxlXRrif+UXBMxzsJUuqmhE7oVU+gS3THO4S/Puh7Xzgm858UNaFi6dxTP8dJA==}
-
'@lezer/python@1.1.18':
resolution: {integrity: sha512-31FiUrU7z9+d/ElGQLJFXl+dKOdx0jALlP3KEOsGTex8mvj+SoE1FgItcHWK/axkxCHGUSpqIHt6JAWfWu9Rhg==}
'@lezer/rust@1.0.2':
resolution: {integrity: sha512-Lz5sIPBdF2FUXcWeCu1//ojFAZqzTQNRga0aYv6dYXqJqPfMdCAI0NzajWUd4Xijj1IKJLtjoXRPMvTKWBcqKg==}
- '@lezer/sass@1.1.0':
- resolution: {integrity: sha512-3mMGdCTUZ/84ArHOuXWQr37pnf7f+Nw9ycPUeKX+wu19b7pSMcZGLbaXwvD2APMBDOGxPmpK/O6S1v1EvLoqgQ==}
-
- '@lezer/xml@1.0.6':
- resolution: {integrity: sha512-CdDwirL0OEaStFue/66ZmFSeppuL6Dwjlk8qk153mSQwiSH/Dlri4GNymrNWnUmPl2Um7QfV1FO9KFUyX3Twww==}
-
'@lezer/yaml@1.0.3':
resolution: {integrity: sha512-GuBLekbw9jDBDhGur82nuwkxKQ+a3W5H0GfaAthDXcAu+XdpS43VlnxA9E9hllkpSP5ellRDKjLLj7Lu9Wr6xA==}
@@ -1411,82 +1378,6 @@ packages:
'@mermaid-js/parser@0.6.3':
resolution: {integrity: sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA==}
- '@milkdown/components@7.18.0':
- resolution: {integrity: sha512-Zu/GMqy1byyxul/+/RWcpe02b7luhtW1SfTYNFZnaWPvIap5M9vG7pFeQNRqJe5cbfKI+bvW8Ubyb5BG2kb9Ug==}
- peerDependencies:
- '@codemirror/language': ^6
- '@codemirror/state': 6.5.3
- '@codemirror/view': 6.39.10
-
- '@milkdown/core@7.18.0':
- resolution: {integrity: sha512-BUVR/72XwrtM3qHTTtXtmCtGfuaAexvSxosYIXw7d6ElbLiLIe3bOXjGwwgLHW3xsq23VKmYMsFqWLUFt6uGDQ==}
-
- '@milkdown/crepe@7.18.0':
- resolution: {integrity: sha512-GcHW6Use0MCRvFg6RQVN5EaeyMlxFxDEGbGwqApnBblxZi5PV9nlAAn0AfOhYvFHSDkQ3rQa5fuHQ0Bd0KobQQ==}
-
- '@milkdown/ctx@7.18.0':
- resolution: {integrity: sha512-F+t8U/akpY7Vw+KD+z32Itr6lrVLAGTVO79DN436BnFK/J9kiPzTRfTet6fMOj3NlwO/24lUluiPZd7qbCmn8A==}
-
- '@milkdown/exception@7.18.0':
- resolution: {integrity: sha512-sAyi4IqdChh4+lpgucmgDZNGjYuIRvJimZeMj0SdfdeHDABan5Nco3X+5yOGaBq1z9QOJG90+vEcEvUASHBmFw==}
-
- '@milkdown/kit@7.18.0':
- resolution: {integrity: sha512-6C8c/bU+3Md/rlZFTqMmdVen2xSC80LYBOZ/G4+W39gsV7x/ux/HRdd8xk75a4IrHKgq6EJpGJ1yH8BvT7P+1A==}
-
- '@milkdown/plugin-block@7.18.0':
- resolution: {integrity: sha512-+x00o7Vh5nQesw4j6QwtwCThdjSiH/jUvAzrTpwr8xvRmQnmztdfdJhPHxp48pK/sIEct3660HWuwDpdeAlmRw==}
-
- '@milkdown/plugin-clipboard@7.18.0':
- resolution: {integrity: sha512-Gnp+GqkoLS1pKG9S2QfdvZQjfoJosQek5Yv5zOIj5X388yfVlguKNtCwnDCJKVEVws9e8PnhfPBmzr06713dZw==}
-
- '@milkdown/plugin-cursor@7.18.0':
- resolution: {integrity: sha512-SsvFEeFMv1jrzVBnuAMyAwZzhjwCk4wmGjJEug41Ic+CT0YMUtVPJn5QVn7fjixR13kzkfaNDUPZ+sGNqIR2xw==}
-
- '@milkdown/plugin-history@7.18.0':
- resolution: {integrity: sha512-hWM3rpad/THy267dXgEWRu9Arf+3j2KE8UN3jhqsUvVLZZ2ZetaPc2imHowJaLR8PwCb649+1RxL+IKrXizNKQ==}
-
- '@milkdown/plugin-indent@7.18.0':
- resolution: {integrity: sha512-LAVMSsy6lWvy/QjvSazojUeW6v1lLFj5Fjv3YvqDNtP6/RSOIhHJs75aXbv92Kx43aRJnkh7EVy9Wu4OxSC70Q==}
-
- '@milkdown/plugin-listener@7.18.0':
- resolution: {integrity: sha512-F2iPKdWYGJX5kMnmIeZeybQ5gZUwT/smNBbt/itPBn5cD4YRF1qmY/MxDs0+nvoN2NSxtEx5pHOtd5/E4mCf2A==}
-
- '@milkdown/plugin-slash@7.18.0':
- resolution: {integrity: sha512-jBcaLswX1yKG97s0V1qFqk/0aR+LpWnTCHIrryNVRIRFYm7B6tITekkqwALlV2bqE1eykeN2j8yEyRQ63Wv05Q==}
-
- '@milkdown/plugin-tooltip@7.18.0':
- resolution: {integrity: sha512-Z8WYSEFANhHPS2A8uMIcKGJ3vt0KKCJ80hffuJffudJT9FSIXieh1f8OKcKQuhcRHxRCRUApMcOOjOptiVaHvQ==}
-
- '@milkdown/plugin-trailing@7.18.0':
- resolution: {integrity: sha512-AusCWoZSRfgsStdlmg+4sYZ08HLDDiHhesDCqiLCdo1bklNhzK/9q6gxdL1HP5xTn5a4xV9hUrI7E7M0JaKdug==}
-
- '@milkdown/plugin-upload@7.18.0':
- resolution: {integrity: sha512-fsWwd6g6FX35Wg12KVE1Yu3wU8vM5hA567DufeHcik9LckdLJcZKf35JMJDUOAOkEdU3V91BKO47KUhBPFt1jA==}
-
- '@milkdown/preset-commonmark@7.18.0':
- resolution: {integrity: sha512-L/F9vmhQKOjKJZTEEsKjDu/2KkMTDxBVQISk4w+j8KFWx9OpHBwqWqyHiDLTREbT7pJqLfyB96eXvfuMG4za5g==}
-
- '@milkdown/preset-gfm@7.18.0':
- resolution: {integrity: sha512-NLfkd7HOaaMCMImXmBh8TX8KNkgKecM7YRHFEwb5D/SMLyBLyZs7lDfLEKPU9N52+vzgwMz8ceUSlCElmneTJg==}
-
- '@milkdown/prose@7.18.0':
- resolution: {integrity: sha512-bRDfgVM6uKRaejvju/FWdQMryQc4kSSso+fnABUbvbCKitXnsgRPvclsddbt3J92anQwLRDWr/qotx1NcyDM1Q==}
-
- '@milkdown/react@7.18.0':
- resolution: {integrity: sha512-hk7CN6YqhazUBOdY0Iyh3RjvRyjsl2vBsJyf54ua38hxmaAD13KbTnEWZs30OnryoP6cv9z74bHPMIc2UnSVIQ==}
- peerDependencies:
- react: '*'
- react-dom: '*'
-
- '@milkdown/theme-nord@7.18.0':
- resolution: {integrity: sha512-3t5Fb0Zwsmf2VDkhZm6U5C6/CWiU7UN+Z+/tpbOC1Stsid4CJ3y7j7m/3oRZlQyUEI07dHvAAOHqzyGoYl0FZw==}
-
- '@milkdown/transformer@7.18.0':
- resolution: {integrity: sha512-AzTgqDktQw9nzgrpICjYNxScYwwnxmALPSyZ39Y0wNZJafi8QMVqLv4w2bhyYkxITXolPHdLAAsZXPKuMjrmNA==}
-
- '@milkdown/utils@7.18.0':
- resolution: {integrity: sha512-+o/1sky+QwbS0Y92HthTupMFziJKhZUgF7IBS55Ft4Wjt63kX8PHaLC9KtewNawpzyM/CjPJ9ySCIa+C/06Bsg==}
-
'@modelcontextprotocol/sdk@1.25.2':
resolution: {integrity: sha512-LZFeo4F9M5qOhC/Uc1aQSrBHxMrvxett+9KLHt7OhcExtoiRN9DKgbZffMP/nxjutWDQpfMDfP3nkHI4X9ijww==}
engines: {node: '>=18'}
@@ -1497,6 +1388,16 @@ packages:
'@cfworker/json-schema':
optional: true
+ '@monaco-editor/loader@1.7.0':
+ resolution: {integrity: sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==}
+
+ '@monaco-editor/react@4.7.0':
+ resolution: {integrity: sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==}
+ peerDependencies:
+ monaco-editor: '>= 0.25.0 < 1'
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
'@mswjs/interceptors@0.40.0':
resolution: {integrity: sha512-EFd6cVbHsgLa6wa4RljGj6Wk75qoHxUSyc5asLyyPSyuhIcdS2Q3Phw6ImS1q+CkALthJRShiYfKANcQMuMqsQ==}
engines: {node: '>=18'}
@@ -1586,9 +1487,6 @@ packages:
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
engines: {node: '>=12.4.0'}
- '@ocavue/utils@1.3.1':
- resolution: {integrity: sha512-jd8D6fUdkspMUYr2EE8mTj1GgPivXeHMyPeI2S9v7DEJcNN1DN2Wo/7xZ5H7vUycgfpr8ucv63aKh3NKURHc1Q==}
-
'@open-draft/deferred-promise@2.2.0':
resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==}
@@ -2631,12 +2529,27 @@ packages:
peerDependencies:
'@tiptap/pm': ^3.19.0
+ '@tiptap/extension-blockquote@3.19.0':
+ resolution: {integrity: sha512-y3UfqY9KD5XwWz3ndiiJ089Ij2QKeiXy/g1/tlAN/F1AaWsnkHEHMLxCP1BIqmMpwsX7rZjMLN7G5Lp7c9682A==}
+ peerDependencies:
+ '@tiptap/core': ^3.19.0
+
+ '@tiptap/extension-bold@3.19.0':
+ resolution: {integrity: sha512-UZgb1d0XK4J/JRIZ7jW+s4S6KjuEDT2z1PPM6ugcgofgJkWQvRZelCPbmtSFd3kwsD+zr9UPVgTh9YIuGQ8t+Q==}
+ peerDependencies:
+ '@tiptap/core': ^3.19.0
+
'@tiptap/extension-bubble-menu@3.19.0':
resolution: {integrity: sha512-klNVIYGCdznhFkrRokzGd6cwzoi8J7E5KbuOfZBwFwhMKZhlz/gJfKmYg9TJopeUhrr2Z9yHgWTk8dh/YIJCdQ==}
peerDependencies:
'@tiptap/core': ^3.19.0
'@tiptap/pm': ^3.19.0
+ '@tiptap/extension-bullet-list@3.19.0':
+ resolution: {integrity: sha512-F9uNnqd0xkJbMmRxVI5RuVxwB9JaCH/xtRqOUNQZnRBt7IdAElCY+Dvb4hMCtiNv+enGM/RFGJuFHR9TxmI7rw==}
+ peerDependencies:
+ '@tiptap/extension-list': ^3.19.0
+
'@tiptap/extension-code-block-lowlight@3.19.0':
resolution: {integrity: sha512-P8O8i1J+XozEVA7bF/Ijwf/r1rVqrh1DBQ7dXxBcrUvLpIGyVjtxX228jBF/kD4kf2xOlphvjDhV2fLa8XOVsg==}
peerDependencies:
@@ -2662,6 +2575,11 @@ packages:
peerDependencies:
'@tiptap/core': ^3.19.0
+ '@tiptap/extension-dropcursor@3.19.0':
+ resolution: {integrity: sha512-sf3dEZXiLvsGqVK2maUIzXY6qtYYCvBumag7+VPTMGQ0D4hiZ1X/4ukt4+6VXDg5R2WP1CoIt/QvUetUjWNhbQ==}
+ peerDependencies:
+ '@tiptap/extensions': ^3.19.0
+
'@tiptap/extension-floating-menu@3.19.0':
resolution: {integrity: sha512-JaoEkVRkt+Slq3tySlIsxnMnCjS0L5n1CA1hctjLy0iah8edetj3XD5mVv5iKqDzE+LIjF4nwLRRVKJPc8hFBg==}
peerDependencies:
@@ -2669,16 +2587,64 @@ packages:
'@tiptap/core': ^3.19.0
'@tiptap/pm': ^3.19.0
+ '@tiptap/extension-gapcursor@3.19.0':
+ resolution: {integrity: sha512-w7DACS4oSZaDWjz7gropZHPc9oXqC9yERZTcjWxyORuuIh1JFf0TRYspleK+OK28plK/IftojD/yUDn1MTRhvA==}
+ peerDependencies:
+ '@tiptap/extensions': ^3.19.0
+
'@tiptap/extension-hard-break@3.19.0':
resolution: {integrity: sha512-lAmQraYhPS5hafvCl74xDB5+bLuNwBKIEsVoim35I0sDJj5nTrfhaZgMJ91VamMvT+6FF5f1dvBlxBxAWa8jew==}
peerDependencies:
'@tiptap/core': ^3.19.0
+ '@tiptap/extension-heading@3.19.0':
+ resolution: {integrity: sha512-uLpLlfyp086WYNOc0ekm1gIZNlEDfmzOhKzB0Hbyi6jDagTS+p9mxUNYeYOn9jPUxpFov43+Wm/4E24oY6B+TQ==}
+ peerDependencies:
+ '@tiptap/core': ^3.19.0
+
'@tiptap/extension-history@3.19.0':
resolution: {integrity: sha512-hhN5nL7Pqcd9iomzeUUMKnmSQieKNlJ2FUgf2dHUqqvn4pWvcHA6P6UwdDNhuKFivSJNNMqtajkOvO4bYq1KPw==}
peerDependencies:
'@tiptap/extensions': ^3.19.0
+ '@tiptap/extension-horizontal-rule@3.19.0':
+ resolution: {integrity: sha512-iqUHmgMGhMgYGwG6L/4JdelVQ5Mstb4qHcgTGd/4dkcUOepILvhdxajPle7OEdf9sRgjQO6uoAU5BVZVC26+ng==}
+ peerDependencies:
+ '@tiptap/core': ^3.19.0
+ '@tiptap/pm': ^3.19.0
+
+ '@tiptap/extension-image@3.19.0':
+ resolution: {integrity: sha512-/rGl8nBziBPVJJ/9639eQWFDKcI3RQsDM3s+cqYQMFQfMqc7sQB9h4o4sHCBpmKxk3Y0FV/0NjnjLbBVm8OKdQ==}
+ peerDependencies:
+ '@tiptap/core': ^3.19.0
+
+ '@tiptap/extension-italic@3.19.0':
+ resolution: {integrity: sha512-6GffxOnS/tWyCbDkirWNZITiXRta9wrCmrfa4rh+v32wfaOL1RRQNyqo9qN6Wjyl1R42Js+yXTzTTzZsOaLMYA==}
+ peerDependencies:
+ '@tiptap/core': ^3.19.0
+
+ '@tiptap/extension-link@3.19.0':
+ resolution: {integrity: sha512-HEGDJnnCPfr7KWu7Dsq+eRRe/mBCsv6DuI+7fhOCLDJjjKzNgrX2abbo/zG3D/4lCVFaVb+qawgJubgqXR/Smw==}
+ peerDependencies:
+ '@tiptap/core': ^3.19.0
+ '@tiptap/pm': ^3.19.0
+
+ '@tiptap/extension-list-item@3.19.0':
+ resolution: {integrity: sha512-VsSKuJz4/Tb6ZmFkXqWpDYkRzmaLTyE6dNSEpNmUpmZ32sMqo58mt11/huADNwfBFB0Ve7siH/VnFNIJYY3xvg==}
+ peerDependencies:
+ '@tiptap/extension-list': ^3.19.0
+
+ '@tiptap/extension-list-keymap@3.19.0':
+ resolution: {integrity: sha512-bxgmAgA3RzBGA0GyTwS2CC1c+QjkJJq9hC+S6PSOWELGRiTbwDN3MANksFXLjntkTa0N5fOnL27vBHtMStURqw==}
+ peerDependencies:
+ '@tiptap/extension-list': ^3.19.0
+
+ '@tiptap/extension-list@3.19.0':
+ resolution: {integrity: sha512-N6nKbFB2VwMsPlCw67RlAtYSK48TAsAUgjnD+vd3ieSlIufdQnLXDFUP6hFKx9mwoUVUgZGz02RA6bkxOdYyTw==}
+ peerDependencies:
+ '@tiptap/core': ^3.19.0
+ '@tiptap/pm': ^3.19.0
+
'@tiptap/extension-mention@3.19.0':
resolution: {integrity: sha512-iBWX6mUouvDe9F75C2fJnFzvBFYVF8fcOa7UvzqWHRSCt8WxqSIp6C1B9Y0npP4TbIZySHzPV4NQQJhtmWwKww==}
peerDependencies:
@@ -2686,6 +2652,11 @@ packages:
'@tiptap/pm': ^3.19.0
'@tiptap/suggestion': ^3.19.0
+ '@tiptap/extension-ordered-list@3.19.0':
+ resolution: {integrity: sha512-cxGsINquwHYE1kmhAcLNLHAofmoDEG6jbesR5ybl7tU5JwtKVO7S/xZatll2DU1dsDAXWPWEeeMl4e/9svYjCg==}
+ peerDependencies:
+ '@tiptap/extension-list': ^3.19.0
+
'@tiptap/extension-paragraph@3.19.0':
resolution: {integrity: sha512-xWa6gj82l5+AzdYyrSk9P4ynySaDzg/SlR1FarXE5yPXibYzpS95IWaVR0m2Qaz7Rrk+IiYOTGxGRxcHLOelNg==}
peerDependencies:
@@ -2696,11 +2667,52 @@ packages:
peerDependencies:
'@tiptap/extensions': ^3.19.0
+ '@tiptap/extension-strike@3.19.0':
+ resolution: {integrity: sha512-xYpabHsv7PccLUBQaP8AYiFCnYbx6P93RHPd0lgNwhdOjYFd931Zy38RyoxPHAgbYVmhf1iyx7lpuLtBnhS5dA==}
+ peerDependencies:
+ '@tiptap/core': ^3.19.0
+
+ '@tiptap/extension-table-cell@3.19.0':
+ resolution: {integrity: sha512-T67EDWmiRdOGctolaUpMPXffDkEFL+NUppSV61cPU3jQtDwGg01meauy5u67u6OUM8ICiZ+Sc7Xd2DIt+7Nv5g==}
+ peerDependencies:
+ '@tiptap/extension-table': ^3.19.0
+
+ '@tiptap/extension-table-header@3.19.0':
+ resolution: {integrity: sha512-fVHJUCfZp/JOE96hJ3paElRynpIVdukOiAjgZbaY9WuCKoXd3xYqdsbUSdf+XFmdPqwvQKhnkaY/qXW3FIxO3w==}
+ peerDependencies:
+ '@tiptap/extension-table': ^3.19.0
+
+ '@tiptap/extension-table-row@3.19.0':
+ resolution: {integrity: sha512-L3DeIXQARCs+m4rsQWiUPJso1z6xZ6awa/Kc+PCoq7rbPLtwhWUIld3sm9gdLac61Mf/TVwb18EdfygHcU7jCA==}
+ peerDependencies:
+ '@tiptap/extension-table': ^3.19.0
+
+ '@tiptap/extension-table@3.19.0':
+ resolution: {integrity: sha512-Lg8DlkkDUMYE/CcGOxoCWF98B2i7VWh+AGgqlF+XWrHjhlKHfENLRXm1a0vWuyyP3NknRYILoaaZ1s7QzmXKRA==}
+ peerDependencies:
+ '@tiptap/core': ^3.19.0
+ '@tiptap/pm': ^3.19.0
+
+ '@tiptap/extension-task-item@3.19.0':
+ resolution: {integrity: sha512-1il70SoaoEA5jKr2QS30CpZoB7EzdSLugROMBPRUPc0feIBKAf6yUPhxlFyU4ez/uT4Pazsf1HAgHI3AOr+MtQ==}
+ peerDependencies:
+ '@tiptap/extension-list': ^3.19.0
+
+ '@tiptap/extension-task-list@3.19.0':
+ resolution: {integrity: sha512-Slb6YZi7XpVT966oAJhqzZu4LVuHtUeZgMxA0bdc8FSZBxntcr8OQWmPbqvR437RAR/Xd7b5quXS3JmSeksyvA==}
+ peerDependencies:
+ '@tiptap/extension-list': ^3.19.0
+
'@tiptap/extension-text@3.19.0':
resolution: {integrity: sha512-K95+SnbZy0h6hNFtfy23n8t/nOcTFEf69In9TSFVVmwn/Nwlke+IfiESAkqbt1/7sKJeegRXYO7WzFEmFl9Q/g==}
peerDependencies:
'@tiptap/core': ^3.19.0
+ '@tiptap/extension-underline@3.19.0':
+ resolution: {integrity: sha512-800MGEWfG49j10wQzAFiW/ele1HT04MamcL8iyuPNu7ZbjbGN2yknvdrJlRy7hZlzIrVkZMr/1tz62KN33VHIw==}
+ peerDependencies:
+ '@tiptap/core': ^3.19.0
+
'@tiptap/extensions@3.19.0':
resolution: {integrity: sha512-ZmGUhLbMWaGqnJh2Bry+6V4M6gMpUDYo4D1xNux5Gng/E/eYtc+PMxMZ/6F7tNTAuujLBOQKj6D+4SsSm457jw==}
peerDependencies:
@@ -2720,6 +2732,9 @@ packages:
react: ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0
+ '@tiptap/starter-kit@3.19.0':
+ resolution: {integrity: sha512-dTCkHEz+Y8ADxX7h+xvl6caAj+3nII/wMB1rTQchSuNKqJTOrzyUsCWm094+IoZmLT738wANE0fRIgziNHs/ug==}
+
'@tiptap/suggestion@3.19.0':
resolution: {integrity: sha512-tUZwMRFqTVPIo566ZmHNRteyZxJy2EE4FA+S3IeIUOOvY6AW0h1imhbpBO7sXV8CeEQvpa+2DWwLvy7L3vmstA==}
peerDependencies:
@@ -2853,17 +2868,14 @@ packages:
'@types/json5@0.0.29':
resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
- '@types/katex@0.16.8':
- resolution: {integrity: sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==}
+ '@types/linkify-it@3.0.5':
+ resolution: {integrity: sha512-yg6E+u0/+Zjva+buc3EIb+29XEg4wltq7cSmd4Uc2EE/1nUVmxyzpX6gUXD0V8jIrG0r7YeOGVIbYRkxeooCtw==}
'@types/linkify-it@5.0.0':
resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==}
- '@types/lodash-es@4.17.12':
- resolution: {integrity: sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==}
-
- '@types/lodash@4.17.23':
- resolution: {integrity: sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==}
+ '@types/markdown-it@13.0.9':
+ resolution: {integrity: sha512-1XPwR0+MgXLWfTn9gCsZ55AHOKW1WN+P9vr0PaQh5aerR9LLQXUbjfEAFhjmEmyoYFWAyuN2Mqkn40MZ4ukjBw==}
'@types/markdown-it@14.1.2':
resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==}
@@ -2871,6 +2883,9 @@ packages:
'@types/mdast@4.0.4':
resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
+ '@types/mdurl@1.0.5':
+ resolution: {integrity: sha512-6L6VymKTzYSrEf4Nev4Xa1LCHKrlTlYCBMTlQKFuddo1CvQcE52I0mwfOJayueUC7MJuXOeHTcIU683lzd0cUA==}
+
'@types/mdurl@2.0.0':
resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==}
@@ -3119,35 +3134,6 @@ packages:
'@vitest/utils@1.6.1':
resolution: {integrity: sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g==}
- '@vue/compiler-core@3.5.27':
- resolution: {integrity: sha512-gnSBQjZA+//qDZen+6a2EdHqJ68Z7uybrMf3SPjEGgG4dicklwDVmMC1AeIHxtLVPT7sn6sH1KOO+tS6gwOUeQ==}
-
- '@vue/compiler-dom@3.5.27':
- resolution: {integrity: sha512-oAFea8dZgCtVVVTEC7fv3T5CbZW9BxpFzGGxC79xakTr6ooeEqmRuvQydIiDAkglZEAd09LgVf1RoDnL54fu5w==}
-
- '@vue/compiler-sfc@3.5.27':
- resolution: {integrity: sha512-sHZu9QyDPeDmN/MRoshhggVOWE5WlGFStKFwu8G52swATgSny27hJRWteKDSUUzUH+wp+bmeNbhJnEAel/auUQ==}
-
- '@vue/compiler-ssr@3.5.27':
- resolution: {integrity: sha512-Sj7h+JHt512fV1cTxKlYhg7qxBvack+BGncSpH+8vnN+KN95iPIcqB5rsbblX40XorP+ilO7VIKlkuu3Xq2vjw==}
-
- '@vue/reactivity@3.5.27':
- resolution: {integrity: sha512-vvorxn2KXfJ0nBEnj4GYshSgsyMNFnIQah/wczXlsNXt+ijhugmW+PpJ2cNPe4V6jpnBcs0MhCODKllWG+nvoQ==}
-
- '@vue/runtime-core@3.5.27':
- resolution: {integrity: sha512-fxVuX/fzgzeMPn/CLQecWeDIFNt3gQVhxM0rW02Tvp/YmZfXQgcTXlakq7IMutuZ/+Ogbn+K0oct9J3JZfyk3A==}
-
- '@vue/runtime-dom@3.5.27':
- resolution: {integrity: sha512-/QnLslQgYqSJ5aUmb5F0z0caZPGHRB8LEAQ1s81vHFM5CBfnun63rxhvE/scVb/j3TbBuoZwkJyiLCkBluMpeg==}
-
- '@vue/server-renderer@3.5.27':
- resolution: {integrity: sha512-qOz/5thjeP1vAFc4+BY3Nr6wxyLhpeQgAE/8dDtKo6a6xdk+L4W46HDZgNmLOBUDEkFXV3G7pRiUqxjX0/2zWA==}
- peerDependencies:
- vue: 3.5.27
-
- '@vue/shared@3.5.27':
- resolution: {integrity: sha512-dXr/3CgqXsJkZ0n9F3I4elY8wM9jMJpP3pvRG52r6m0tu/MsAFIe6JpXVGeNMd/D9F4hQynWT8Rfuj0bdm9kFQ==}
-
'@xterm/addon-attach@0.12.0':
resolution: {integrity: sha512-1lxvXM4JYSm60lbFmE8WMOy2oF2ip3Ye8jWorSAmwy7x8FiC53netEJ5RguL8+FSRj79MUQsNCb2hprY2QA2ig==}
@@ -3815,6 +3801,9 @@ packages:
dom-helpers@5.2.1:
resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==}
+ dompurify@3.2.7:
+ resolution: {integrity: sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==}
+
dompurify@3.3.1:
resolution: {integrity: sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==}
@@ -3870,10 +3859,6 @@ packages:
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
engines: {node: '>=0.12'}
- entities@7.0.1:
- resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
- engines: {node: '>=0.12'}
-
env-paths@2.2.1:
resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==}
engines: {node: '>=6'}
@@ -4058,9 +4043,6 @@ packages:
estree-util-is-identifier-name@3.0.0:
resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==}
- estree-walker@2.0.2:
- resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
-
estree-walker@3.0.3:
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
@@ -4808,6 +4790,9 @@ packages:
linkify-it@5.0.0:
resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==}
+ linkifyjs@4.3.2:
+ resolution: {integrity: sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==}
+
local-pkg@0.5.1:
resolution: {integrity: sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==}
engines: {node: '>=14'}
@@ -4854,6 +4839,9 @@ packages:
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
+ markdown-it-task-lists@2.1.1:
+ resolution: {integrity: sha512-TxFAc76Jnhb2OUu+n3yz9RMu4CwGfaT788br6HhEDlvWfdeJcLUsxk1Hgw2yJio0OXsxv7pyIPmvECY7bMbluA==}
+
markdown-it@14.1.1:
resolution: {integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==}
hasBin: true
@@ -4861,6 +4849,11 @@ packages:
markdown-table@3.0.4:
resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==}
+ marked@14.0.0:
+ resolution: {integrity: sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==}
+ engines: {node: '>= 18'}
+ hasBin: true
+
marked@16.4.2:
resolution: {integrity: sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==}
engines: {node: '>= 20'}
@@ -4870,9 +4863,6 @@ packages:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
- mdast-util-definitions@6.0.0:
- resolution: {integrity: sha512-scTllyX6pnYNZH/AIp/0ePz6s4cZtARxImwoPJ7kS42n+MnVsI4XbnG6d4ibehRIldYMWM2LD7ImQblVhUejVQ==}
-
mdast-util-find-and-replace@3.0.2:
resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==}
@@ -4897,9 +4887,6 @@ packages:
mdast-util-gfm@3.1.0:
resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==}
- mdast-util-math@3.0.0:
- resolution: {integrity: sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w==}
-
mdast-util-mdx-expression@2.0.1:
resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==}
@@ -4969,9 +4956,6 @@ packages:
micromark-extension-gfm@3.0.0:
resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==}
- micromark-extension-math@3.1.0:
- resolution: {integrity: sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==}
-
micromark-factory-destination@2.0.1:
resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==}
@@ -5073,6 +5057,9 @@ packages:
mlly@1.8.0:
resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==}
+ monaco-editor@0.55.1:
+ resolution: {integrity: sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==}
+
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -5095,11 +5082,6 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
- nanoid@5.1.6:
- resolution: {integrity: sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==}
- engines: {node: ^18 || >=20}
- hasBin: true
-
napi-postinstall@0.3.4:
resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==}
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
@@ -5391,9 +5373,6 @@ packages:
prosemirror-commands@1.7.1:
resolution: {integrity: sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==}
- prosemirror-drop-indicator@0.1.3:
- resolution: {integrity: sha512-fJV6G2tHIVXZLUuc60fS9ly1/GuGOlAZUm67S1El+kGFUYh27Hyv6hcGx3rrJ+Q/JZL5jnyAibIZYYWpPqE45g==}
-
prosemirror-dropcursor@1.8.2:
resolution: {integrity: sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==}
@@ -5418,9 +5397,6 @@ packages:
prosemirror-model@1.25.4:
resolution: {integrity: sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==}
- prosemirror-safari-ime-span@1.0.2:
- resolution: {integrity: sha512-QJqD8s1zE/CuK56kDsUhndh5hiHh/gFnAuPOA9ytva2s85/ZEt2tNWeALTJN48DtWghSKOmiBsvVn2OlnJ5H2w==}
-
prosemirror-schema-basic@1.2.4:
resolution: {integrity: sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==}
@@ -5446,20 +5422,6 @@ packages:
prosemirror-view@1.41.5:
resolution: {integrity: sha512-UDQbIPnDrjE8tqUBbPmCOZgtd75htE6W3r0JCmY9bL6W1iemDM37MZEKC49d+tdQ0v/CKx4gjxLoLsfkD2NiZA==}
- prosemirror-virtual-cursor@0.4.2:
- resolution: {integrity: sha512-pUMKnIuOhhnMcgIJUjhIQTVJruBEGxfMBVQSrK0g2qhGPDm1i12KdsVaFw15dYk+29tZcxjMeR7P5VDKwmbwJg==}
- peerDependencies:
- prosemirror-model: ^1.0.0
- prosemirror-state: ^1.0.0
- prosemirror-view: ^1.0.0
- peerDependenciesMeta:
- prosemirror-model:
- optional: true
- prosemirror-state:
- optional: true
- prosemirror-view:
- optional: true
-
proxy-addr@2.0.7:
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
engines: {node: '>= 0.10'}
@@ -5612,12 +5574,6 @@ packages:
remark-gfm@4.0.1:
resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==}
- remark-inline-links@7.0.0:
- resolution: {integrity: sha512-4uj1pPM+F495ySZhTIB6ay2oSkTsKgmYaKk/q5HIdhX2fuyLEegpjWa0VdJRJ01sgOqAFo7MBKdDUejIYBMVMQ==}
-
- remark-math@6.0.0:
- resolution: {integrity: sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA==}
-
remark-parse@11.0.0:
resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==}
@@ -5627,9 +5583,6 @@ packages:
remark-stringify@11.0.0:
resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==}
- remark@15.0.1:
- resolution: {integrity: sha512-Eht5w30ruCXgFmxVUSlNWQ9iiimq07URKeFS3hNc8cUWy1llX4KDWfyEDZRycMc+znsN9Ux5/tJ/BFdgdOwA3A==}
-
require-directory@2.1.1:
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
engines: {node: '>=0.10.0'}
@@ -5817,6 +5770,9 @@ packages:
stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
+ state-local@1.0.7:
+ resolution: {integrity: sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==}
+
statuses@2.0.2:
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
engines: {node: '>= 0.8'}
@@ -5979,6 +5935,11 @@ packages:
resolution: {integrity: sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==}
engines: {node: '>=14.0.0'}
+ tiptap-markdown@0.9.0:
+ resolution: {integrity: sha512-dKLQ9iiuGNgrlGVjrNauF/UBzWu4LYOx5pkD0jNkmQt/GOwfCJsBuzZTsf1jZ204ANHOm572mZ9PYvGh1S7tpQ==}
+ peerDependencies:
+ '@tiptap/core': ^3.0.1
+
tldts-core@7.0.19:
resolution: {integrity: sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==}
@@ -6109,9 +6070,6 @@ packages:
unist-util-position@5.0.0:
resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==}
- unist-util-remove-position@5.0.0:
- resolution: {integrity: sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==}
-
unist-util-stringify-position@4.0.0:
resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==}
@@ -6280,14 +6238,6 @@ packages:
vscode-uri@3.0.8:
resolution: {integrity: sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==}
- vue@3.5.27:
- resolution: {integrity: sha512-aJ/UtoEyFySPBGarREmN4z6qNKpbEguYHMmXSiOGk69czc+zhs0NF6tEFrY8TZKAl8N/LYAkd4JHVd5E/AsSmw==}
- peerDependencies:
- typescript: '*'
- peerDependenciesMeta:
- typescript:
- optional: true
-
w3c-keyname@2.2.8:
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
@@ -6692,15 +6642,6 @@ snapshots:
'@codemirror/view': 6.39.10
'@lezer/common': 1.5.0
- '@codemirror/lang-angular@0.1.4':
- dependencies:
- '@codemirror/lang-html': 6.4.11
- '@codemirror/lang-javascript': 6.2.4
- '@codemirror/language': 6.12.1
- '@lezer/common': 1.5.0
- '@lezer/highlight': 1.2.3
- '@lezer/lr': 1.4.7
-
'@codemirror/lang-cpp@6.0.3':
dependencies:
'@codemirror/language': 6.12.1
@@ -6749,38 +6690,11 @@ snapshots:
'@lezer/common': 1.5.0
'@lezer/javascript': 1.5.4
- '@codemirror/lang-jinja@6.0.0':
- dependencies:
- '@codemirror/lang-html': 6.4.11
- '@codemirror/language': 6.12.1
- '@lezer/common': 1.5.0
- '@lezer/highlight': 1.2.3
- '@lezer/lr': 1.4.7
-
'@codemirror/lang-json@6.0.2':
dependencies:
'@codemirror/language': 6.12.1
'@lezer/json': 1.0.3
- '@codemirror/lang-less@6.0.2':
- dependencies:
- '@codemirror/lang-css': 6.3.1
- '@codemirror/language': 6.12.1
- '@lezer/common': 1.5.0
- '@lezer/highlight': 1.2.3
- '@lezer/lr': 1.4.7
-
- '@codemirror/lang-liquid@6.3.1':
- dependencies:
- '@codemirror/autocomplete': 6.20.0
- '@codemirror/lang-html': 6.4.11
- '@codemirror/language': 6.12.1
- '@codemirror/state': 6.5.3
- '@codemirror/view': 6.39.10
- '@lezer/common': 1.5.0
- '@lezer/highlight': 1.2.3
- '@lezer/lr': 1.4.7
-
'@codemirror/lang-markdown@6.5.0':
dependencies:
'@codemirror/autocomplete': 6.20.0
@@ -6791,14 +6705,6 @@ snapshots:
'@lezer/common': 1.5.0
'@lezer/markdown': 1.6.3
- '@codemirror/lang-php@6.0.2':
- dependencies:
- '@codemirror/lang-html': 6.4.11
- '@codemirror/language': 6.12.1
- '@codemirror/state': 6.5.3
- '@lezer/common': 1.5.0
- '@lezer/php': 1.0.5
-
'@codemirror/lang-python@6.2.1':
dependencies:
'@codemirror/autocomplete': 6.20.0
@@ -6812,48 +6718,6 @@ snapshots:
'@codemirror/language': 6.12.1
'@lezer/rust': 1.0.2
- '@codemirror/lang-sass@6.0.2':
- dependencies:
- '@codemirror/lang-css': 6.3.1
- '@codemirror/language': 6.12.1
- '@codemirror/state': 6.5.3
- '@lezer/common': 1.5.0
- '@lezer/sass': 1.1.0
-
- '@codemirror/lang-sql@6.10.0':
- dependencies:
- '@codemirror/autocomplete': 6.20.0
- '@codemirror/language': 6.12.1
- '@codemirror/state': 6.5.3
- '@lezer/common': 1.5.0
- '@lezer/highlight': 1.2.3
- '@lezer/lr': 1.4.7
-
- '@codemirror/lang-vue@0.1.3':
- dependencies:
- '@codemirror/lang-html': 6.4.11
- '@codemirror/lang-javascript': 6.2.4
- '@codemirror/language': 6.12.1
- '@lezer/common': 1.5.0
- '@lezer/highlight': 1.2.3
- '@lezer/lr': 1.4.7
-
- '@codemirror/lang-wast@6.0.2':
- dependencies:
- '@codemirror/language': 6.12.1
- '@lezer/common': 1.5.0
- '@lezer/highlight': 1.2.3
- '@lezer/lr': 1.4.7
-
- '@codemirror/lang-xml@6.1.0':
- dependencies:
- '@codemirror/autocomplete': 6.20.0
- '@codemirror/language': 6.12.1
- '@codemirror/state': 6.5.3
- '@codemirror/view': 6.39.10
- '@lezer/common': 1.5.0
- '@lezer/xml': 1.0.6
-
'@codemirror/lang-yaml@6.1.2':
dependencies:
'@codemirror/autocomplete': 6.20.0
@@ -6864,32 +6728,6 @@ snapshots:
'@lezer/lr': 1.4.7
'@lezer/yaml': 1.0.3
- '@codemirror/language-data@6.5.2':
- dependencies:
- '@codemirror/lang-angular': 0.1.4
- '@codemirror/lang-cpp': 6.0.3
- '@codemirror/lang-css': 6.3.1
- '@codemirror/lang-go': 6.0.1
- '@codemirror/lang-html': 6.4.11
- '@codemirror/lang-java': 6.0.2
- '@codemirror/lang-javascript': 6.2.4
- '@codemirror/lang-jinja': 6.0.0
- '@codemirror/lang-json': 6.0.2
- '@codemirror/lang-less': 6.0.2
- '@codemirror/lang-liquid': 6.3.1
- '@codemirror/lang-markdown': 6.5.0
- '@codemirror/lang-php': 6.0.2
- '@codemirror/lang-python': 6.2.1
- '@codemirror/lang-rust': 6.0.2
- '@codemirror/lang-sass': 6.0.2
- '@codemirror/lang-sql': 6.10.0
- '@codemirror/lang-vue': 0.1.3
- '@codemirror/lang-wast': 6.0.2
- '@codemirror/lang-xml': 6.1.0
- '@codemirror/lang-yaml': 6.1.2
- '@codemirror/language': 6.12.1
- '@codemirror/legacy-modes': 6.5.2
-
'@codemirror/language@6.12.1':
dependencies:
'@codemirror/state': 6.5.3
@@ -6899,10 +6737,6 @@ snapshots:
'@lezer/lr': 1.4.7
style-mod: 4.1.3
- '@codemirror/legacy-modes@6.5.2':
- dependencies:
- '@codemirror/language': 6.12.1
-
'@codemirror/lint@6.9.2':
dependencies:
'@codemirror/state': 6.5.3
@@ -7436,12 +7270,6 @@ snapshots:
'@lezer/common': 1.5.0
'@lezer/highlight': 1.2.3
- '@lezer/php@1.0.5':
- dependencies:
- '@lezer/common': 1.5.0
- '@lezer/highlight': 1.2.3
- '@lezer/lr': 1.4.7
-
'@lezer/python@1.1.18':
dependencies:
'@lezer/common': 1.5.0
@@ -7454,18 +7282,6 @@ snapshots:
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.7
- '@lezer/sass@1.1.0':
- dependencies:
- '@lezer/common': 1.5.0
- '@lezer/highlight': 1.2.3
- '@lezer/lr': 1.4.7
-
- '@lezer/xml@1.0.6':
- dependencies:
- '@lezer/common': 1.5.0
- '@lezer/highlight': 1.2.3
- '@lezer/lr': 1.4.7
-
'@lezer/yaml@1.0.3':
dependencies:
'@lezer/common': 1.5.0
@@ -7478,289 +7294,6 @@ snapshots:
dependencies:
langium: 3.3.1
- '@milkdown/components@7.18.0(@codemirror/language@6.12.1)(@codemirror/state@6.5.3)(@codemirror/view@6.39.10)(typescript@5.9.3)':
- dependencies:
- '@codemirror/language': 6.12.1
- '@codemirror/state': 6.5.3
- '@codemirror/view': 6.39.10
- '@floating-ui/dom': 1.7.4
- '@milkdown/core': 7.18.0
- '@milkdown/ctx': 7.18.0
- '@milkdown/exception': 7.18.0
- '@milkdown/plugin-tooltip': 7.18.0
- '@milkdown/preset-commonmark': 7.18.0
- '@milkdown/preset-gfm': 7.18.0
- '@milkdown/prose': 7.18.0
- '@milkdown/transformer': 7.18.0
- '@milkdown/utils': 7.18.0
- '@types/lodash-es': 4.17.12
- clsx: 2.1.1
- dompurify: 3.3.1
- lodash-es: 4.17.23
- nanoid: 5.1.6
- unist-util-visit: 5.0.0
- vue: 3.5.27(typescript@5.9.3)
- transitivePeerDependencies:
- - supports-color
- - typescript
-
- '@milkdown/core@7.18.0':
- dependencies:
- '@milkdown/ctx': 7.18.0
- '@milkdown/exception': 7.18.0
- '@milkdown/prose': 7.18.0
- '@milkdown/transformer': 7.18.0
- remark-parse: 11.0.0
- remark-stringify: 11.0.0
- unified: 11.0.5
- transitivePeerDependencies:
- - supports-color
-
- '@milkdown/crepe@7.18.0(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.5)(typescript@5.9.3)':
- dependencies:
- '@codemirror/commands': 6.10.1
- '@codemirror/language': 6.12.1
- '@codemirror/language-data': 6.5.2
- '@codemirror/state': 6.5.3
- '@codemirror/theme-one-dark': 6.1.3
- '@codemirror/view': 6.39.10
- '@milkdown/kit': 7.18.0(@codemirror/language@6.12.1)(@codemirror/state@6.5.3)(@codemirror/view@6.39.10)(typescript@5.9.3)
- '@types/lodash-es': 4.17.12
- clsx: 2.1.1
- codemirror: 6.0.2
- katex: 0.16.28
- lodash-es: 4.17.23
- prosemirror-virtual-cursor: 0.4.2(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.5)
- remark-math: 6.0.0
- unist-util-visit: 5.0.0
- vue: 3.5.27(typescript@5.9.3)
- transitivePeerDependencies:
- - prosemirror-model
- - prosemirror-state
- - prosemirror-view
- - supports-color
- - typescript
-
- '@milkdown/ctx@7.18.0':
- dependencies:
- '@milkdown/exception': 7.18.0
-
- '@milkdown/exception@7.18.0': {}
-
- '@milkdown/kit@7.18.0(@codemirror/language@6.12.1)(@codemirror/state@6.5.3)(@codemirror/view@6.39.10)(typescript@5.9.3)':
- dependencies:
- '@milkdown/components': 7.18.0(@codemirror/language@6.12.1)(@codemirror/state@6.5.3)(@codemirror/view@6.39.10)(typescript@5.9.3)
- '@milkdown/core': 7.18.0
- '@milkdown/ctx': 7.18.0
- '@milkdown/plugin-block': 7.18.0
- '@milkdown/plugin-clipboard': 7.18.0
- '@milkdown/plugin-cursor': 7.18.0
- '@milkdown/plugin-history': 7.18.0
- '@milkdown/plugin-indent': 7.18.0
- '@milkdown/plugin-listener': 7.18.0
- '@milkdown/plugin-slash': 7.18.0
- '@milkdown/plugin-tooltip': 7.18.0
- '@milkdown/plugin-trailing': 7.18.0
- '@milkdown/plugin-upload': 7.18.0
- '@milkdown/preset-commonmark': 7.18.0
- '@milkdown/preset-gfm': 7.18.0
- '@milkdown/prose': 7.18.0
- '@milkdown/transformer': 7.18.0
- '@milkdown/utils': 7.18.0
- transitivePeerDependencies:
- - '@codemirror/language'
- - '@codemirror/state'
- - '@codemirror/view'
- - supports-color
- - typescript
-
- '@milkdown/plugin-block@7.18.0':
- dependencies:
- '@floating-ui/dom': 1.7.4
- '@milkdown/core': 7.18.0
- '@milkdown/ctx': 7.18.0
- '@milkdown/prose': 7.18.0
- '@milkdown/utils': 7.18.0
- '@types/lodash-es': 4.17.12
- lodash-es: 4.17.23
- transitivePeerDependencies:
- - supports-color
-
- '@milkdown/plugin-clipboard@7.18.0':
- dependencies:
- '@milkdown/core': 7.18.0
- '@milkdown/ctx': 7.18.0
- '@milkdown/prose': 7.18.0
- '@milkdown/utils': 7.18.0
- transitivePeerDependencies:
- - supports-color
-
- '@milkdown/plugin-cursor@7.18.0':
- dependencies:
- '@milkdown/ctx': 7.18.0
- '@milkdown/prose': 7.18.0
- '@milkdown/utils': 7.18.0
- prosemirror-drop-indicator: 0.1.3
- transitivePeerDependencies:
- - supports-color
-
- '@milkdown/plugin-history@7.18.0':
- dependencies:
- '@milkdown/core': 7.18.0
- '@milkdown/ctx': 7.18.0
- '@milkdown/prose': 7.18.0
- '@milkdown/utils': 7.18.0
- transitivePeerDependencies:
- - supports-color
-
- '@milkdown/plugin-indent@7.18.0':
- dependencies:
- '@milkdown/ctx': 7.18.0
- '@milkdown/prose': 7.18.0
- '@milkdown/utils': 7.18.0
- transitivePeerDependencies:
- - supports-color
-
- '@milkdown/plugin-listener@7.18.0':
- dependencies:
- '@milkdown/core': 7.18.0
- '@milkdown/ctx': 7.18.0
- '@milkdown/prose': 7.18.0
- '@types/lodash-es': 4.17.12
- lodash-es: 4.17.23
- transitivePeerDependencies:
- - supports-color
-
- '@milkdown/plugin-slash@7.18.0':
- dependencies:
- '@floating-ui/dom': 1.7.4
- '@milkdown/ctx': 7.18.0
- '@milkdown/prose': 7.18.0
- '@milkdown/utils': 7.18.0
- '@types/lodash-es': 4.17.12
- lodash-es: 4.17.23
- transitivePeerDependencies:
- - supports-color
-
- '@milkdown/plugin-tooltip@7.18.0':
- dependencies:
- '@floating-ui/dom': 1.7.4
- '@milkdown/ctx': 7.18.0
- '@milkdown/prose': 7.18.0
- '@milkdown/utils': 7.18.0
- '@types/lodash-es': 4.17.12
- lodash-es: 4.17.23
- transitivePeerDependencies:
- - supports-color
-
- '@milkdown/plugin-trailing@7.18.0':
- dependencies:
- '@milkdown/ctx': 7.18.0
- '@milkdown/prose': 7.18.0
- '@milkdown/utils': 7.18.0
- transitivePeerDependencies:
- - supports-color
-
- '@milkdown/plugin-upload@7.18.0':
- dependencies:
- '@milkdown/core': 7.18.0
- '@milkdown/ctx': 7.18.0
- '@milkdown/exception': 7.18.0
- '@milkdown/prose': 7.18.0
- '@milkdown/utils': 7.18.0
- transitivePeerDependencies:
- - supports-color
-
- '@milkdown/preset-commonmark@7.18.0':
- dependencies:
- '@milkdown/core': 7.18.0
- '@milkdown/ctx': 7.18.0
- '@milkdown/exception': 7.18.0
- '@milkdown/prose': 7.18.0
- '@milkdown/transformer': 7.18.0
- '@milkdown/utils': 7.18.0
- remark-inline-links: 7.0.0
- unist-util-visit: 5.0.0
- unist-util-visit-parents: 6.0.2
- transitivePeerDependencies:
- - supports-color
-
- '@milkdown/preset-gfm@7.18.0':
- dependencies:
- '@milkdown/core': 7.18.0
- '@milkdown/ctx': 7.18.0
- '@milkdown/exception': 7.18.0
- '@milkdown/preset-commonmark': 7.18.0
- '@milkdown/prose': 7.18.0
- '@milkdown/transformer': 7.18.0
- '@milkdown/utils': 7.18.0
- prosemirror-safari-ime-span: 1.0.2
- remark-gfm: 4.0.1
- transitivePeerDependencies:
- - supports-color
-
- '@milkdown/prose@7.18.0':
- dependencies:
- '@milkdown/exception': 7.18.0
- prosemirror-changeset: 2.3.1
- prosemirror-commands: 1.7.1
- prosemirror-dropcursor: 1.8.2
- prosemirror-gapcursor: 1.4.0
- prosemirror-history: 1.5.0
- prosemirror-inputrules: 1.5.1
- prosemirror-keymap: 1.2.3
- prosemirror-model: 1.25.4
- prosemirror-schema-list: 1.5.1
- prosemirror-state: 1.4.4
- prosemirror-tables: 1.8.5
- prosemirror-transform: 1.11.0
- prosemirror-view: 1.41.5
-
- '@milkdown/react@7.18.0(@codemirror/language@6.12.1)(@codemirror/state@6.5.3)(@codemirror/view@6.39.10)(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.5)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)':
- dependencies:
- '@milkdown/crepe': 7.18.0(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.5)(typescript@5.9.3)
- '@milkdown/kit': 7.18.0(@codemirror/language@6.12.1)(@codemirror/state@6.5.3)(@codemirror/view@6.39.10)(typescript@5.9.3)
- react: 19.2.3
- react-dom: 19.2.3(react@19.2.3)
- transitivePeerDependencies:
- - '@codemirror/language'
- - '@codemirror/state'
- - '@codemirror/view'
- - prosemirror-model
- - prosemirror-state
- - prosemirror-view
- - supports-color
- - typescript
-
- '@milkdown/theme-nord@7.18.0':
- dependencies:
- '@milkdown/core': 7.18.0
- '@milkdown/ctx': 7.18.0
- '@milkdown/prose': 7.18.0
- clsx: 2.1.1
- transitivePeerDependencies:
- - supports-color
-
- '@milkdown/transformer@7.18.0':
- dependencies:
- '@milkdown/exception': 7.18.0
- '@milkdown/prose': 7.18.0
- remark: 15.0.1
- unified: 11.0.5
- transitivePeerDependencies:
- - supports-color
-
- '@milkdown/utils@7.18.0':
- dependencies:
- '@milkdown/core': 7.18.0
- '@milkdown/ctx': 7.18.0
- '@milkdown/exception': 7.18.0
- '@milkdown/prose': 7.18.0
- '@milkdown/transformer': 7.18.0
- nanoid: 5.1.6
- transitivePeerDependencies:
- - supports-color
-
'@modelcontextprotocol/sdk@1.25.2(hono@4.11.3)(zod@3.25.76)':
dependencies:
'@hono/node-server': 1.19.8(hono@4.11.3)
@@ -7783,6 +7316,17 @@ snapshots:
- hono
- supports-color
+ '@monaco-editor/loader@1.7.0':
+ dependencies:
+ state-local: 1.0.7
+
+ '@monaco-editor/react@4.7.0(monaco-editor@0.55.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
+ dependencies:
+ '@monaco-editor/loader': 1.7.0
+ monaco-editor: 0.55.1
+ react: 19.2.3
+ react-dom: 19.2.3(react@19.2.3)
+
'@mswjs/interceptors@0.40.0':
dependencies:
'@open-draft/deferred-promise': 2.2.0
@@ -7851,8 +7395,6 @@ snapshots:
'@nolyfill/is-core-module@1.0.39': {}
- '@ocavue/utils@1.3.1': {}
-
'@open-draft/deferred-promise@2.2.0': {}
'@open-draft/logger@0.3.0':
@@ -8878,12 +8420,23 @@ snapshots:
dependencies:
'@tiptap/pm': 3.19.0
+ '@tiptap/extension-blockquote@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))':
+ dependencies:
+ '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0)
+
+ '@tiptap/extension-bold@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))':
+ dependencies:
+ '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0)
+
'@tiptap/extension-bubble-menu@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)':
dependencies:
'@floating-ui/dom': 1.7.4
'@tiptap/core': 3.19.0(@tiptap/pm@3.19.0)
'@tiptap/pm': 3.19.0
- optional: true
+
+ '@tiptap/extension-bullet-list@3.19.0(@tiptap/extension-list@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))':
+ dependencies:
+ '@tiptap/extension-list': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)
'@tiptap/extension-code-block-lowlight@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/extension-code-block@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)(highlight.js@11.11.1)(lowlight@3.3.0)':
dependencies:
@@ -8906,6 +8459,10 @@ snapshots:
dependencies:
'@tiptap/core': 3.19.0(@tiptap/pm@3.19.0)
+ '@tiptap/extension-dropcursor@3.19.0(@tiptap/extensions@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))':
+ dependencies:
+ '@tiptap/extensions': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)
+
'@tiptap/extension-floating-menu@3.19.0(@floating-ui/dom@1.7.4)(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)':
dependencies:
'@floating-ui/dom': 1.7.4
@@ -8913,20 +8470,64 @@ snapshots:
'@tiptap/pm': 3.19.0
optional: true
+ '@tiptap/extension-gapcursor@3.19.0(@tiptap/extensions@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))':
+ dependencies:
+ '@tiptap/extensions': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)
+
'@tiptap/extension-hard-break@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))':
dependencies:
'@tiptap/core': 3.19.0(@tiptap/pm@3.19.0)
+ '@tiptap/extension-heading@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))':
+ dependencies:
+ '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0)
+
'@tiptap/extension-history@3.19.0(@tiptap/extensions@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))':
dependencies:
'@tiptap/extensions': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)
+ '@tiptap/extension-horizontal-rule@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)':
+ dependencies:
+ '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0)
+ '@tiptap/pm': 3.19.0
+
+ '@tiptap/extension-image@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))':
+ dependencies:
+ '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0)
+
+ '@tiptap/extension-italic@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))':
+ dependencies:
+ '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0)
+
+ '@tiptap/extension-link@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)':
+ dependencies:
+ '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0)
+ '@tiptap/pm': 3.19.0
+ linkifyjs: 4.3.2
+
+ '@tiptap/extension-list-item@3.19.0(@tiptap/extension-list@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))':
+ dependencies:
+ '@tiptap/extension-list': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)
+
+ '@tiptap/extension-list-keymap@3.19.0(@tiptap/extension-list@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))':
+ dependencies:
+ '@tiptap/extension-list': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)
+
+ '@tiptap/extension-list@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)':
+ dependencies:
+ '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0)
+ '@tiptap/pm': 3.19.0
+
'@tiptap/extension-mention@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)(@tiptap/suggestion@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))':
dependencies:
'@tiptap/core': 3.19.0(@tiptap/pm@3.19.0)
'@tiptap/pm': 3.19.0
'@tiptap/suggestion': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)
+ '@tiptap/extension-ordered-list@3.19.0(@tiptap/extension-list@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))':
+ dependencies:
+ '@tiptap/extension-list': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)
+
'@tiptap/extension-paragraph@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))':
dependencies:
'@tiptap/core': 3.19.0(@tiptap/pm@3.19.0)
@@ -8935,10 +8536,43 @@ snapshots:
dependencies:
'@tiptap/extensions': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)
+ '@tiptap/extension-strike@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))':
+ dependencies:
+ '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0)
+
+ '@tiptap/extension-table-cell@3.19.0(@tiptap/extension-table@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))':
+ dependencies:
+ '@tiptap/extension-table': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)
+
+ '@tiptap/extension-table-header@3.19.0(@tiptap/extension-table@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))':
+ dependencies:
+ '@tiptap/extension-table': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)
+
+ '@tiptap/extension-table-row@3.19.0(@tiptap/extension-table@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))':
+ dependencies:
+ '@tiptap/extension-table': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)
+
+ '@tiptap/extension-table@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)':
+ dependencies:
+ '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0)
+ '@tiptap/pm': 3.19.0
+
+ '@tiptap/extension-task-item@3.19.0(@tiptap/extension-list@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))':
+ dependencies:
+ '@tiptap/extension-list': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)
+
+ '@tiptap/extension-task-list@3.19.0(@tiptap/extension-list@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))':
+ dependencies:
+ '@tiptap/extension-list': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)
+
'@tiptap/extension-text@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))':
dependencies:
'@tiptap/core': 3.19.0(@tiptap/pm@3.19.0)
+ '@tiptap/extension-underline@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))':
+ dependencies:
+ '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0)
+
'@tiptap/extensions@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)':
dependencies:
'@tiptap/core': 3.19.0(@tiptap/pm@3.19.0)
@@ -8982,6 +8616,33 @@ snapshots:
transitivePeerDependencies:
- '@floating-ui/dom'
+ '@tiptap/starter-kit@3.19.0':
+ dependencies:
+ '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0)
+ '@tiptap/extension-blockquote': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))
+ '@tiptap/extension-bold': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))
+ '@tiptap/extension-bullet-list': 3.19.0(@tiptap/extension-list@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))
+ '@tiptap/extension-code': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))
+ '@tiptap/extension-code-block': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)
+ '@tiptap/extension-document': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))
+ '@tiptap/extension-dropcursor': 3.19.0(@tiptap/extensions@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))
+ '@tiptap/extension-gapcursor': 3.19.0(@tiptap/extensions@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))
+ '@tiptap/extension-hard-break': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))
+ '@tiptap/extension-heading': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))
+ '@tiptap/extension-horizontal-rule': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)
+ '@tiptap/extension-italic': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))
+ '@tiptap/extension-link': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)
+ '@tiptap/extension-list': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)
+ '@tiptap/extension-list-item': 3.19.0(@tiptap/extension-list@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))
+ '@tiptap/extension-list-keymap': 3.19.0(@tiptap/extension-list@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))
+ '@tiptap/extension-ordered-list': 3.19.0(@tiptap/extension-list@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))
+ '@tiptap/extension-paragraph': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))
+ '@tiptap/extension-strike': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))
+ '@tiptap/extension-text': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))
+ '@tiptap/extension-underline': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))
+ '@tiptap/extensions': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)
+ '@tiptap/pm': 3.19.0
+
'@tiptap/suggestion@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)':
dependencies:
'@tiptap/core': 3.19.0(@tiptap/pm@3.19.0)
@@ -9143,15 +8804,14 @@ snapshots:
'@types/json5@0.0.29': {}
- '@types/katex@0.16.8': {}
+ '@types/linkify-it@3.0.5': {}
'@types/linkify-it@5.0.0': {}
- '@types/lodash-es@4.17.12':
+ '@types/markdown-it@13.0.9':
dependencies:
- '@types/lodash': 4.17.23
-
- '@types/lodash@4.17.23': {}
+ '@types/linkify-it': 3.0.5
+ '@types/mdurl': 1.0.5
'@types/markdown-it@14.1.2':
dependencies:
@@ -9162,6 +8822,8 @@ snapshots:
dependencies:
'@types/unist': 3.0.3
+ '@types/mdurl@1.0.5': {}
+
'@types/mdurl@2.0.0': {}
'@types/ms@2.1.0': {}
@@ -9427,60 +9089,6 @@ snapshots:
loupe: 2.3.7
pretty-format: 29.7.0
- '@vue/compiler-core@3.5.27':
- dependencies:
- '@babel/parser': 7.28.6
- '@vue/shared': 3.5.27
- entities: 7.0.1
- estree-walker: 2.0.2
- source-map-js: 1.2.1
-
- '@vue/compiler-dom@3.5.27':
- dependencies:
- '@vue/compiler-core': 3.5.27
- '@vue/shared': 3.5.27
-
- '@vue/compiler-sfc@3.5.27':
- dependencies:
- '@babel/parser': 7.28.6
- '@vue/compiler-core': 3.5.27
- '@vue/compiler-dom': 3.5.27
- '@vue/compiler-ssr': 3.5.27
- '@vue/shared': 3.5.27
- estree-walker: 2.0.2
- magic-string: 0.30.21
- postcss: 8.5.6
- source-map-js: 1.2.1
-
- '@vue/compiler-ssr@3.5.27':
- dependencies:
- '@vue/compiler-dom': 3.5.27
- '@vue/shared': 3.5.27
-
- '@vue/reactivity@3.5.27':
- dependencies:
- '@vue/shared': 3.5.27
-
- '@vue/runtime-core@3.5.27':
- dependencies:
- '@vue/reactivity': 3.5.27
- '@vue/shared': 3.5.27
-
- '@vue/runtime-dom@3.5.27':
- dependencies:
- '@vue/reactivity': 3.5.27
- '@vue/runtime-core': 3.5.27
- '@vue/shared': 3.5.27
- csstype: 3.2.3
-
- '@vue/server-renderer@3.5.27(vue@3.5.27(typescript@5.9.3))':
- dependencies:
- '@vue/compiler-ssr': 3.5.27
- '@vue/shared': 3.5.27
- vue: 3.5.27(typescript@5.9.3)
-
- '@vue/shared@3.5.27': {}
-
'@xterm/addon-attach@0.12.0': {}
'@xterm/addon-fit@0.11.0': {}
@@ -10163,6 +9771,10 @@ snapshots:
'@babel/runtime': 7.28.6
csstype: 3.2.3
+ dompurify@3.2.7:
+ optionalDependencies:
+ '@types/trusted-types': 2.0.7
+
dompurify@3.3.1:
optionalDependencies:
'@types/trusted-types': 2.0.7
@@ -10213,8 +9825,6 @@ snapshots:
entities@4.5.0: {}
- entities@7.0.1: {}
-
env-paths@2.2.1: {}
error-ex@1.3.4:
@@ -10590,8 +10200,6 @@ snapshots:
estree-util-is-identifier-name@3.0.0: {}
- estree-walker@2.0.2: {}
-
estree-walker@3.0.3:
dependencies:
'@types/estree': 1.0.8
@@ -11337,6 +10945,8 @@ snapshots:
dependencies:
uc.micro: 2.1.0
+ linkifyjs@4.3.2: {}
+
local-pkg@0.5.1:
dependencies:
mlly: 1.8.0
@@ -11385,6 +10995,8 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
+ markdown-it-task-lists@2.1.1: {}
+
markdown-it@14.1.1:
dependencies:
argparse: 2.0.1
@@ -11396,16 +11008,12 @@ snapshots:
markdown-table@3.0.4: {}
+ marked@14.0.0: {}
+
marked@16.4.2: {}
math-intrinsics@1.1.0: {}
- mdast-util-definitions@6.0.0:
- dependencies:
- '@types/mdast': 4.0.4
- '@types/unist': 3.0.3
- unist-util-visit: 5.0.0
-
mdast-util-find-and-replace@3.0.2:
dependencies:
'@types/mdast': 4.0.4
@@ -11487,18 +11095,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
- mdast-util-math@3.0.0:
- dependencies:
- '@types/hast': 3.0.4
- '@types/mdast': 4.0.4
- devlop: 1.1.0
- longest-streak: 3.1.0
- mdast-util-from-markdown: 2.0.2
- mdast-util-to-markdown: 2.1.2
- unist-util-remove-position: 5.0.0
- transitivePeerDependencies:
- - supports-color
-
mdast-util-mdx-expression@2.0.1:
dependencies:
'@types/estree-jsx': 1.0.5
@@ -11686,16 +11282,6 @@ snapshots:
micromark-util-combine-extensions: 2.0.1
micromark-util-types: 2.0.2
- micromark-extension-math@3.1.0:
- dependencies:
- '@types/katex': 0.16.8
- devlop: 1.1.0
- katex: 0.16.28
- micromark-factory-space: 2.0.1
- micromark-util-character: 2.1.1
- micromark-util-symbol: 2.0.1
- micromark-util-types: 2.0.2
-
micromark-factory-destination@2.0.1:
dependencies:
micromark-util-character: 2.1.1
@@ -11848,6 +11434,11 @@ snapshots:
pkg-types: 1.3.1
ufo: 1.6.2
+ monaco-editor@0.55.1:
+ dependencies:
+ dompurify: 3.2.7
+ marked: 14.0.0
+
ms@2.1.3: {}
msw@2.12.7(@types/node@20.19.28)(typescript@5.9.3):
@@ -11879,8 +11470,6 @@ snapshots:
nanoid@3.3.11: {}
- nanoid@5.1.6: {}
-
napi-postinstall@0.3.4: {}
natural-compare@1.4.0: {}
@@ -12193,13 +11782,6 @@ snapshots:
prosemirror-state: 1.4.4
prosemirror-transform: 1.11.0
- prosemirror-drop-indicator@0.1.3:
- dependencies:
- '@ocavue/utils': 1.3.1
- prosemirror-model: 1.25.4
- prosemirror-state: 1.4.4
- prosemirror-view: 1.41.5
-
prosemirror-dropcursor@1.8.2:
dependencies:
prosemirror-state: 1.4.4
@@ -12247,11 +11829,6 @@ snapshots:
dependencies:
orderedmap: 2.1.1
- prosemirror-safari-ime-span@1.0.2:
- dependencies:
- prosemirror-state: 1.4.4
- prosemirror-view: 1.41.5
-
prosemirror-schema-basic@1.2.4:
dependencies:
prosemirror-model: 1.25.4
@@ -12294,12 +11871,6 @@ snapshots:
prosemirror-state: 1.4.4
prosemirror-transform: 1.11.0
- prosemirror-virtual-cursor@0.4.2(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.5):
- optionalDependencies:
- prosemirror-model: 1.25.4
- prosemirror-state: 1.4.4
- prosemirror-view: 1.41.5
-
proxy-addr@2.0.7:
dependencies:
forwarded: 0.2.0
@@ -12544,21 +12115,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
- remark-inline-links@7.0.0:
- dependencies:
- '@types/mdast': 4.0.4
- mdast-util-definitions: 6.0.0
- unist-util-visit: 5.0.0
-
- remark-math@6.0.0:
- dependencies:
- '@types/mdast': 4.0.4
- mdast-util-math: 3.0.0
- micromark-extension-math: 3.1.0
- unified: 11.0.5
- transitivePeerDependencies:
- - supports-color
-
remark-parse@11.0.0:
dependencies:
'@types/mdast': 4.0.4
@@ -12582,15 +12138,6 @@ snapshots:
mdast-util-to-markdown: 2.1.2
unified: 11.0.5
- remark@15.0.1:
- dependencies:
- '@types/mdast': 4.0.4
- remark-parse: 11.0.0
- remark-stringify: 11.0.0
- unified: 11.0.5
- transitivePeerDependencies:
- - supports-color
-
require-directory@2.1.1: {}
require-from-string@2.0.2: {}
@@ -12902,6 +12449,8 @@ snapshots:
stackback@0.0.2: {}
+ state-local@1.0.7: {}
+
statuses@2.0.2: {}
std-env@3.10.0: {}
@@ -13060,6 +12609,14 @@ snapshots:
tinyspy@2.2.1: {}
+ tiptap-markdown@0.9.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0)):
+ dependencies:
+ '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0)
+ '@types/markdown-it': 13.0.9
+ markdown-it: 14.1.1
+ markdown-it-task-lists: 2.1.1
+ prosemirror-markdown: 1.13.4
+
tldts-core@7.0.19: {}
tldts@7.0.19:
@@ -13212,11 +12769,6 @@ snapshots:
dependencies:
'@types/unist': 3.0.3
- unist-util-remove-position@5.0.0:
- dependencies:
- '@types/unist': 3.0.3
- unist-util-visit: 5.0.0
-
unist-util-stringify-position@4.0.0:
dependencies:
'@types/unist': 3.0.3
@@ -13415,16 +12967,6 @@ snapshots:
vscode-uri@3.0.8: {}
- vue@3.5.27(typescript@5.9.3):
- dependencies:
- '@vue/compiler-dom': 3.5.27
- '@vue/compiler-sfc': 3.5.27
- '@vue/runtime-dom': 3.5.27
- '@vue/server-renderer': 3.5.27(vue@3.5.27(typescript@5.9.3))
- '@vue/shared': 3.5.27
- optionalDependencies:
- typescript: 5.9.3
-
w3c-keyname@2.2.8: {}
web-streams-polyfill@3.3.3: {}
diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css
index 25e1c5aa6b..83f51ac4ea 100644
--- a/apps/web/app/globals.css
+++ b/apps/web/app/globals.css
@@ -515,229 +515,7 @@
background: color-mix(in oklab, var(--primary) 30%, var(--background));
}
-/* Milkdown Editor Styles */
-.milkdown-wrapper {
- height: 100%;
- overflow: auto;
- border-radius: 0;
-}
-
-.milkdown-wrapper:not(.dark) {
- background: #ffffff;
-}
-
-.milkdown-wrapper .milkdown {
- height: 100%;
- background: transparent;
- display: flex;
- flex-direction: column;
-}
-
-.milkdown-wrapper .milkdown [data-milkdown-root] {
- flex: 1;
- display: flex;
- flex-direction: column;
-}
-
-.milkdown-wrapper .editor {
- padding: 0.75rem;
- flex: 1;
- min-height: 100%;
- outline: none;
- background: transparent;
- cursor: text;
-}
-
-.milkdown-wrapper .editor:focus {
- outline: none;
-}
-
-/* Override nord theme colors to match app theme */
-.milkdown-wrapper .milkdown {
- --nord-background: transparent;
- --nord-foreground: var(--foreground);
- --nord-outline: var(--border);
- --nord-primary: var(--primary);
- --nord-secondary: var(--muted);
- --nord-neutral: var(--muted-foreground);
- --nord-solid: var(--card);
- --nord-line: var(--border);
- --nord-surface: var(--muted);
- --nord-shadow: var(--border);
-}
-
-/* Base text styling - match chat text-xs */
-.milkdown-wrapper .editor,
-.milkdown-wrapper .editor *:not(.mermaid-container):not(.mermaid-container *) {
- font-size: 0.75rem !important;
- line-height: 1.625;
- color: var(--foreground);
-}
-
-/* Typography - headings */
-.milkdown-wrapper .editor h1,
-.milkdown-wrapper .editor h2,
-.milkdown-wrapper .editor h3,
-.milkdown-wrapper .editor h4,
-.milkdown-wrapper .editor h5,
-.milkdown-wrapper .editor h6 {
- font-weight: 700 !important;
- color: var(--foreground) !important;
- margin-top: 0.5rem !important;
- margin-bottom: 0.25rem !important;
- line-height: 1.3 !important;
-}
-
-.milkdown-wrapper .editor h1 { font-size: 0.875rem !important; }
-.milkdown-wrapper .editor h2 { font-size: 0.8125rem !important; }
-.milkdown-wrapper .editor h3 { font-size: 0.75rem !important; }
-
-/* Paragraphs */
-.milkdown-wrapper .editor p {
- margin-top: 0.5rem !important;
- margin-bottom: 0.5rem !important;
- line-height: 1.625 !important;
- font-size: 0.75rem !important;
-}
-
-/* Lists */
-.milkdown-wrapper .editor ul {
- list-style-type: disc !important;
- padding-left: 1.25rem !important;
- margin-top: 0.375rem !important;
- margin-bottom: 0.375rem !important;
- font-size: 0.75rem !important;
-}
-
-.milkdown-wrapper .editor ol {
- list-style-type: decimal !important;
- padding-left: 1.25rem !important;
- margin-top: 0.375rem !important;
- margin-bottom: 0.375rem !important;
- font-size: 0.75rem !important;
-}
-
-.milkdown-wrapper .editor li {
- margin-top: 0.125rem !important;
- margin-bottom: 0.125rem !important;
- font-size: 0.75rem !important;
- line-height: 1.5 !important;
-}
-
-/* Inline code - matching chat InlineCode component */
-.milkdown-wrapper .editor code {
- background: rgb(59 130 246 / 0.2) !important;
- color: rgb(96 165 250) !important;
- padding: 0.125rem 0.375rem !important;
- border-radius: 0.25rem !important;
- font-size: 0.675rem !important;
- font-family: var(--font-mono) !important;
-}
-
-/* Code blocks */
-.milkdown-wrapper .editor pre {
- background: var(--background) !important;
- padding: 0.5rem 0.75rem !important;
- border-radius: 0.375rem !important;
- overflow-x: auto !important;
- margin-top: 0.5rem !important;
- margin-bottom: 0.5rem !important;
-}
-
-.milkdown-wrapper:not(.dark) .editor pre {
- background: #f5f5f5 !important;
-}
-
-.milkdown-wrapper .editor pre code {
- background: transparent !important;
- color: #d4d4d4 !important;
- padding: 0 !important;
- font-size: 0.625rem !important;
- font-family: var(--font-mono) !important;
-}
-
-.milkdown-wrapper:not(.dark) .editor pre code {
- color: #1e1e1e !important;
-}
-
-/* Blockquote */
-.milkdown-wrapper .editor blockquote {
- border-left: 3px solid var(--primary) !important;
- padding-left: 0.75rem !important;
- margin-left: 0 !important;
- margin-top: 0.5rem !important;
- margin-bottom: 0.5rem !important;
- color: var(--muted-foreground) !important;
- font-style: italic !important;
-}
-
-/* Links */
-.milkdown-wrapper .editor a {
- color: var(--primary) !important;
- text-decoration: underline !important;
- text-underline-offset: 2px !important;
-}
-
-/* Strong/Bold */
-.milkdown-wrapper .editor strong {
- font-weight: 700;
- color: var(--foreground);
-}
-
-/* Emphasis */
-.milkdown-wrapper .editor em {
- font-style: italic;
-}
-
-/* Horizontal rule */
-.milkdown-wrapper .editor hr {
- border: none !important;
- border-top: 1px solid var(--border) !important;
- margin: 1rem 0 !important;
-}
-
-/* Comment highlights in plan editor - inline */
-.milkdown-wrapper .comment-highlight {
- background-color: rgba(59, 130, 246, 0.45);
- border-radius: 3px;
- cursor: pointer;
- transition: background-color 0.15s ease;
- padding: 2px 1px;
- box-decoration-break: clone;
- -webkit-box-decoration-break: clone;
-}
-
-.milkdown-wrapper .comment-highlight:hover {
- background-color: rgba(59, 130, 246, 0.6);
-}
-
-/* Mermaid diagram styles */
-.milkdown-wrapper .mermaid-container {
- margin: 0.75rem 0 !important;
- padding: 0.5rem !important;
- overflow-x: auto !important;
-}
-
-.milkdown-wrapper .mermaid-container svg {
- max-width: 100% !important;
- height: auto !important;
-}
-
-.milkdown-wrapper .mermaid-error {
- color: #ef4444 !important;
- font-size: 0.75rem !important;
- padding: 0.5rem !important;
- white-space: pre-wrap !important;
-}
-
-/* Placeholder for empty editor - shows at cursor position like a real editor */
-.milkdown-wrapper .editor-placeholder {
- color: var(--muted-foreground);
- opacity: 0.5;
- pointer-events: none;
- font-size: 0.75rem;
- user-select: none;
-}
+/* Legacy Milkdown styles removed - plan editor now uses TipTap */
/* TipTap editor styles */
.tiptap p.is-editor-empty:first-child::before {
@@ -870,6 +648,343 @@
font-weight: bold;
}
+/* Monaco Editor - Comment decorations */
+.monaco-comment-line {
+ background-color: rgba(99, 102, 241, 0.15) !important;
+ border-left: 3px solid rgba(99, 102, 241, 0.6) !important;
+}
+
+.monaco-comment-glyph {
+ background-color: rgba(99, 102, 241, 0.2);
+ cursor: pointer;
+}
+
+.monaco-comment-glyph::after {
+ content: '';
+ display: block;
+ width: 12px;
+ height: 12px;
+ margin: auto;
+ background: rgba(99, 102, 241, 0.9);
+ mask-image: url("data:image/svg+xml;utf8,");
+ -webkit-mask-image: url("data:image/svg+xml;utf8,");
+ mask-size: contain;
+ -webkit-mask-size: contain;
+}
+
+.monaco-comment-glyph:hover {
+ background-color: rgba(99, 102, 241, 0.35);
+}
+
+.monaco-comment-glyph-bg {
+ background-color: rgba(99, 102, 241, 0.2);
+}
+
+/* TipTap Plan Editor Styles */
+.tiptap-plan-wrapper {
+ height: 100%;
+ overflow: auto;
+ border-radius: 0;
+}
+
+.tiptap-plan-wrapper:not(.dark) {
+ background: #ffffff;
+}
+
+.tiptap-plan-wrapper .tiptap {
+ height: 100%;
+ padding: 0.75rem;
+ outline: none;
+ cursor: text;
+}
+
+.tiptap-plan-wrapper .tiptap:focus {
+ outline: none;
+}
+
+/* Base text styling */
+.tiptap-plan-wrapper .tiptap,
+.tiptap-plan-wrapper .tiptap *:not(.mermaid-container):not(.mermaid-container *) {
+ font-size: 0.75rem !important;
+ line-height: 1.625;
+ color: var(--foreground);
+}
+
+/* Headings */
+.tiptap-plan-wrapper .tiptap h1,
+.tiptap-plan-wrapper .tiptap h2,
+.tiptap-plan-wrapper .tiptap h3,
+.tiptap-plan-wrapper .tiptap h4 {
+ font-weight: 700 !important;
+ color: var(--foreground) !important;
+ margin-top: 0.5rem !important;
+ margin-bottom: 0.25rem !important;
+ line-height: 1.3 !important;
+}
+
+.tiptap-plan-wrapper .tiptap h1 { font-size: 0.875rem !important; }
+.tiptap-plan-wrapper .tiptap h2 { font-size: 0.8125rem !important; }
+.tiptap-plan-wrapper .tiptap h3 { font-size: 0.75rem !important; }
+
+/* Paragraphs */
+.tiptap-plan-wrapper .tiptap p {
+ margin-top: 0.5rem !important;
+ margin-bottom: 0.5rem !important;
+ line-height: 1.625 !important;
+ font-size: 0.75rem !important;
+}
+
+/* Lists */
+.tiptap-plan-wrapper .tiptap ul {
+ list-style-type: disc !important;
+ padding-left: 1.25rem !important;
+ margin-top: 0.375rem !important;
+ margin-bottom: 0.375rem !important;
+}
+
+.tiptap-plan-wrapper .tiptap ol {
+ list-style-type: decimal !important;
+ padding-left: 1.25rem !important;
+ margin-top: 0.375rem !important;
+ margin-bottom: 0.375rem !important;
+}
+
+.tiptap-plan-wrapper .tiptap li {
+ margin-top: 0.125rem !important;
+ margin-bottom: 0.125rem !important;
+}
+
+/* Task lists */
+.tiptap-plan-wrapper .tiptap ul[data-type="taskList"] {
+ list-style: none !important;
+ padding-left: 0 !important;
+}
+
+.tiptap-plan-wrapper .tiptap ul[data-type="taskList"] li {
+ display: flex;
+ align-items: flex-start;
+ gap: 0.5rem;
+}
+
+.tiptap-plan-wrapper .tiptap ul[data-type="taskList"] li label {
+ margin-top: 0.125rem;
+}
+
+/* Inline code */
+.tiptap-plan-wrapper .tiptap code {
+ background: rgb(59 130 246 / 0.2) !important;
+ color: rgb(96 165 250) !important;
+ padding: 0.125rem 0.375rem !important;
+ border-radius: 0.25rem !important;
+ font-size: 0.675rem !important;
+ font-family: var(--font-mono) !important;
+}
+
+/* Code blocks */
+.tiptap-plan-wrapper .tiptap pre {
+ background: var(--background) !important;
+ padding: 0.5rem 0.75rem !important;
+ border-radius: 0.375rem !important;
+ overflow-x: auto !important;
+ margin-top: 0.5rem !important;
+ margin-bottom: 0.5rem !important;
+}
+
+.tiptap-plan-wrapper:not(.dark) .tiptap pre {
+ background: #f5f5f5 !important;
+}
+
+.tiptap-plan-wrapper .tiptap pre code {
+ background: transparent !important;
+ color: #d4d4d4 !important;
+ padding: 0 !important;
+ font-size: 0.625rem !important;
+ font-family: var(--font-mono) !important;
+}
+
+.tiptap-plan-wrapper:not(.dark) .tiptap pre code {
+ color: #1e1e1e !important;
+}
+
+/* Blockquote */
+.tiptap-plan-wrapper .tiptap blockquote {
+ border-left: 3px solid var(--primary) !important;
+ padding-left: 0.75rem !important;
+ margin-left: 0 !important;
+ margin-top: 0.5rem !important;
+ margin-bottom: 0.5rem !important;
+ color: var(--muted-foreground) !important;
+ font-style: italic !important;
+}
+
+/* Links */
+.tiptap-plan-wrapper .tiptap a {
+ color: var(--primary) !important;
+ text-decoration: underline !important;
+ text-underline-offset: 2px !important;
+}
+
+/* Horizontal rule */
+.tiptap-plan-wrapper .tiptap hr {
+ border: none !important;
+ border-top: 1px solid var(--border) !important;
+ margin: 1rem 0 !important;
+}
+
+/* Tables */
+.tiptap-plan-wrapper .tiptap table {
+ border-collapse: collapse;
+ width: 100%;
+ margin: 0.5rem 0;
+}
+
+.tiptap-plan-wrapper .tiptap th,
+.tiptap-plan-wrapper .tiptap td {
+ border: 1px solid var(--border);
+ padding: 0.25rem 0.5rem;
+ font-size: 0.75rem;
+}
+
+.tiptap-plan-wrapper .tiptap th {
+ background: var(--muted);
+ font-weight: 600;
+}
+
+/* Comment highlights */
+.tiptap-plan-wrapper .comment-highlight {
+ background-color: rgba(59, 130, 246, 0.45);
+ border-radius: 3px;
+ cursor: pointer;
+ transition: background-color 0.15s ease;
+ padding: 2px 1px;
+ box-decoration-break: clone;
+ -webkit-box-decoration-break: clone;
+}
+
+.tiptap-plan-wrapper .comment-highlight:hover {
+ background-color: rgba(59, 130, 246, 0.6);
+}
+
+/* Mermaid diagram styles */
+.tiptap-plan-wrapper .mermaid-container {
+ margin: 0.75rem 0 !important;
+ padding: 0.5rem !important;
+ overflow-x: auto !important;
+}
+
+.tiptap-plan-wrapper .mermaid-container svg {
+ max-width: 100% !important;
+ height: auto !important;
+}
+
+.tiptap-plan-wrapper .mermaid-error {
+ color: #ef4444 !important;
+ font-size: 0.75rem !important;
+ padding: 0.5rem !important;
+ white-space: pre-wrap !important;
+}
+
+/* Placeholder for empty plan editor */
+.tiptap-plan-wrapper .tiptap p.is-editor-empty:first-child::before {
+ content: attr(data-placeholder);
+ float: left;
+ color: var(--muted-foreground);
+ opacity: 0.5;
+ pointer-events: none;
+ height: 0;
+ font-size: 0.75rem;
+}
+
+/* Lowlight syntax colors for TipTap plan code blocks */
+.tiptap-plan-wrapper .tiptap pre code .hljs-comment,
+.tiptap-plan-wrapper .tiptap pre code .hljs-quote {
+ color: var(--muted-foreground);
+ font-style: italic;
+}
+
+.tiptap-plan-wrapper .tiptap pre code .hljs-keyword,
+.tiptap-plan-wrapper .tiptap pre code .hljs-selector-tag,
+.tiptap-plan-wrapper .tiptap pre code .hljs-built_in {
+ color: oklch(0.65 0.2 300);
+}
+
+.tiptap-plan-wrapper .tiptap pre code .hljs-string,
+.tiptap-plan-wrapper .tiptap pre code .hljs-attr,
+.tiptap-plan-wrapper .tiptap pre code .hljs-addition {
+ color: oklch(0.7 0.15 150);
+}
+
+.tiptap-plan-wrapper .tiptap pre code .hljs-number,
+.tiptap-plan-wrapper .tiptap pre code .hljs-literal {
+ color: oklch(0.7 0.15 60);
+}
+
+.tiptap-plan-wrapper .tiptap pre code .hljs-title,
+.tiptap-plan-wrapper .tiptap pre code .hljs-section,
+.tiptap-plan-wrapper .tiptap pre code .hljs-type {
+ color: oklch(0.75 0.15 80);
+}
+
+.tiptap-plan-wrapper .tiptap pre code .hljs-name,
+.tiptap-plan-wrapper .tiptap pre code .hljs-tag {
+ color: oklch(0.65 0.2 20);
+}
+
+/* Monaco Editor - Diff gutter indicators */
+.monaco-diff-added-gutter {
+ width: 3px !important;
+ margin-left: 3px;
+ background-color: #10b981 !important;
+}
+.monaco-diff-modified-gutter {
+ width: 3px !important;
+ margin-left: 3px;
+ background-color: #3b82f6 !important;
+}
+.monaco-diff-deleted-gutter {
+ width: 0 !important;
+ margin-left: 2px;
+ border-left: 4px solid #f43f5e !important;
+}
+
+/* Monaco Editor - Peek view / references widget overrides */
+.monaco-editor .peekview-widget .head {
+ box-sizing: border-box;
+}
+
+.monaco-editor .peekview-widget .head .peekview-title .filename {
+ font-size: 12px;
+ font-weight: 500;
+}
+
+.monaco-editor .peekview-widget .head .peekview-title .dirname {
+ font-size: 11px;
+}
+
+/* Match references list font size to editor font size (12px) */
+.monaco-editor .reference-zone-widget .ref-tree .monaco-list-row {
+ font-size: 12px !important;
+ height: 20px !important;
+ line-height: 20px !important;
+}
+
+.monaco-editor .reference-zone-widget .ref-tree .reference-file {
+ font-size: 12px !important;
+ height: 22px !important;
+ line-height: 22px !important;
+}
+
+.monaco-editor .reference-zone-widget .ref-tree .monaco-tl-contents {
+ padding-left: 4px;
+}
+
+.monaco-editor .reference-zone-widget .ref-tree .reference-file .count {
+ font-size: 10px;
+ line-height: 14px;
+ padding: 0 5px;
+ border-radius: 8px;
+}
+
.debug {
border: 1px solid red !important;
}
diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx
index 3ede91c5db..2d5db20286 100644
--- a/apps/web/app/page.tsx
+++ b/apps/web/app/page.tsx
@@ -60,6 +60,9 @@ export default async function Page({ searchParams }: PageProps) {
enablePreviewOnClick: settingsEnablePreviewOnClick,
chatSubmitKey: userSettings?.chat_submit_key ?? 'cmd_enter',
reviewAutoMarkOnScroll: userSettings?.review_auto_mark_on_scroll ?? true,
+ lspAutoStartLanguages: userSettings?.lsp_auto_start_languages ?? [],
+ lspAutoInstallLanguages: userSettings?.lsp_auto_install_languages ?? [],
+ lspServerConfigs: userSettings?.lsp_server_configs ?? {},
loaded: Boolean(userSettings),
},
};
diff --git a/apps/web/app/s/[sessionId]/page.tsx b/apps/web/app/s/[sessionId]/page.tsx
index d979412404..d859dab2c4 100644
--- a/apps/web/app/s/[sessionId]/page.tsx
+++ b/apps/web/app/s/[sessionId]/page.tsx
@@ -229,6 +229,9 @@ export default async function SessionPage({
enablePreviewOnClick: userSettings?.enable_preview_on_click ?? false,
chatSubmitKey: userSettings?.chat_submit_key ?? 'cmd_enter',
reviewAutoMarkOnScroll: userSettings?.review_auto_mark_on_scroll ?? true,
+ lspAutoStartLanguages: userSettings?.lsp_auto_start_languages ?? [],
+ lspAutoInstallLanguages: userSettings?.lsp_auto_install_languages ?? [],
+ lspServerConfigs: userSettings?.lsp_server_configs ?? {},
loaded: Boolean(userSettings),
},
};
diff --git a/apps/web/components/diff/diff-view-inline.tsx b/apps/web/components/diff/diff-view-inline.tsx
index 4e9414708b..025ce20a14 100644
--- a/apps/web/components/diff/diff-view-inline.tsx
+++ b/apps/web/components/diff/diff-view-inline.tsx
@@ -1,4 +1,4 @@
'use client';
-// Re-export from the unified diff-viewer component
-export { DiffViewInline } from './diff-viewer';
+// Re-export resolver-aware version
+export { DiffViewInlineResolved as DiffViewInline } from './diff-viewer-resolver';
diff --git a/apps/web/components/diff/diff-viewer-resolver.tsx b/apps/web/components/diff/diff-viewer-resolver.tsx
new file mode 100644
index 0000000000..b981d5d987
--- /dev/null
+++ b/apps/web/components/diff/diff-viewer-resolver.tsx
@@ -0,0 +1,39 @@
+'use client';
+
+import { memo } from 'react';
+import { useEditorProvider } from '@/hooks/use-editor-resolver';
+import { DiffViewer as PierreDiffViewer, DiffViewInline as PierreDiffViewInline } from './diff-viewer';
+import { MonacoDiffViewer } from '@/components/editors/monaco/monaco-diff-viewer';
+import type { FileDiffData, DiffComment } from '@/lib/diff/types';
+
+interface DiffViewerResolverProps {
+ data: FileDiffData;
+ enableComments?: boolean;
+ sessionId?: string;
+ onCommentAdd?: (comment: DiffComment) => void;
+ onCommentDelete?: (commentId: string) => void;
+ comments?: DiffComment[];
+ className?: string;
+ compact?: boolean;
+ hideHeader?: boolean;
+ onOpenFile?: (filePath: string) => void;
+ onRevert?: (filePath: string) => void;
+ wordWrap?: boolean;
+}
+
+export const DiffViewerResolved = memo(function DiffViewerResolved(props: DiffViewerResolverProps) {
+ const provider = useEditorProvider('diff-viewer');
+ if (provider === 'monaco') {
+ const { enableComments: _, ...rest } = props;
+ return ;
+ }
+ return ;
+});
+
+export function DiffViewInlineResolved({ data, className }: { data: FileDiffData; className?: string }) {
+ const provider = useEditorProvider('chat-diff');
+ if (provider === 'monaco') {
+ return ;
+ }
+ return ;
+}
diff --git a/apps/web/components/diff/diff-viewer.tsx b/apps/web/components/diff/diff-viewer.tsx
index 3c59fcad8e..ef42727998 100644
--- a/apps/web/components/diff/diff-viewer.tsx
+++ b/apps/web/components/diff/diff-viewer.tsx
@@ -8,7 +8,7 @@ import type { FileDiffOptions, FileDiffMetadata, DiffLineAnnotation, RenderHeade
import { cn } from '@kandev/ui/lib/utils';
import { Button } from '@kandev/ui/button';
import { Tooltip, TooltipTrigger, TooltipContent } from '@kandev/ui/tooltip';
-import { IconCopy, IconTextWrap, IconLayoutRows, IconLayoutColumns, IconExternalLink, IconPlus, IconArrowBackUp } from '@tabler/icons-react';
+import { IconCopy, IconTextWrap, IconLayoutRows, IconLayoutColumns, IconPencil, IconPlus, IconArrowBackUp } from '@tabler/icons-react';
import type { FileDiffData, DiffComment } from '@/lib/diff/types';
import { useDiffComments } from './use-diff-comments';
import { CommentForm } from './comment-form';
@@ -407,10 +407,10 @@ export const DiffViewer = memo(function DiffViewer({
className="h-6 w-6 p-0 cursor-pointer opacity-60 hover:opacity-100"
onClick={() => onOpenFile(filePath)}
>
-
+
- Open in editor
+ Edit
)}
diff --git a/apps/web/components/diff/file-diff-viewer.tsx b/apps/web/components/diff/file-diff-viewer.tsx
index 64cb95cae4..ee6b262354 100644
--- a/apps/web/components/diff/file-diff-viewer.tsx
+++ b/apps/web/components/diff/file-diff-viewer.tsx
@@ -1,7 +1,7 @@
'use client';
import { memo, useMemo } from 'react';
-import { DiffViewer } from './diff-viewer';
+import { DiffViewerResolved as DiffViewer } from './diff-viewer-resolver';
import { transformGitDiff } from '@/lib/diff';
import type { DiffComment } from '@/lib/diff/types';
diff --git a/apps/web/components/diff/index.ts b/apps/web/components/diff/index.ts
index a0f46f911d..d31f473f95 100644
--- a/apps/web/components/diff/index.ts
+++ b/apps/web/components/diff/index.ts
@@ -1,9 +1,13 @@
-// Main components
-export { DiffViewer } from './diff-viewer';
+// Main components - resolver-aware
+export { DiffViewerResolved as DiffViewer } from './diff-viewer-resolver';
+export { DiffViewInlineResolved as DiffViewInline } from './diff-viewer-resolver';
export { FileDiffViewer } from './file-diff-viewer';
-export { DiffViewInline } from './diff-view-inline';
export { CommentForm } from './comment-form';
export { CommentDisplay } from './comment-display';
+// Direct access to Pierre implementation (for internal use)
+export { DiffViewer as PierreDiffViewer } from './diff-viewer';
+export { DiffViewInline as PierreDiffViewInline } from './diff-viewer';
+
// Hooks
export { useDiffComments } from './use-diff-comments';
diff --git a/apps/web/components/editors/codemirror/codemirror-code-block.tsx b/apps/web/components/editors/codemirror/codemirror-code-block.tsx
new file mode 100644
index 0000000000..56c94b9792
--- /dev/null
+++ b/apps/web/components/editors/codemirror/codemirror-code-block.tsx
@@ -0,0 +1,80 @@
+'use client';
+
+import { useTheme } from 'next-themes';
+import CodeMirror from '@uiw/react-codemirror';
+import { vscodeDark, vscodeLight } from '@uiw/codemirror-theme-vscode';
+import { EditorView } from '@codemirror/view';
+import { IconCheck, IconCopy } from '@tabler/icons-react';
+import { cn } from '@/lib/utils';
+import { getCodeMirrorExtension } from '@/lib/languages';
+import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard';
+
+type CodeBlockProps = {
+ children: React.ReactNode;
+ className?: string;
+};
+
+const getLanguageExtension = (language?: string) => {
+ if (!language) return undefined;
+ const lang = language.replace('language-', '').toLowerCase();
+ return getCodeMirrorExtension(lang);
+};
+
+export function CodeMirrorCodeBlock({ children, className }: CodeBlockProps) {
+ const { copied, copy } = useCopyToClipboard();
+ const { theme, systemTheme } = useTheme();
+ const effectiveTheme = theme === 'system' ? systemTheme : theme;
+
+ const code = String(children).replace(/\n$/, '');
+ const languageExtension = getLanguageExtension(className);
+
+ // Custom padding theme
+ const paddingTheme = EditorView.theme({
+ '&': {
+ padding: '2px 2px',
+ },
+ });
+
+ const handleCopy = async () => {
+ await copy(code);
+ };
+
+ return (
+
+ {/* Copy button */}
+
+
+ {/* Code editor */}
+
+
+ );
+}
diff --git a/apps/web/components/editors/codemirror/codemirror-code-editor.tsx b/apps/web/components/editors/codemirror/codemirror-code-editor.tsx
new file mode 100644
index 0000000000..9b9b91ef9c
--- /dev/null
+++ b/apps/web/components/editors/codemirror/codemirror-code-editor.tsx
@@ -0,0 +1,649 @@
+'use client';
+
+import { useCallback, useEffect, useMemo, useState, useRef } from 'react';
+import CodeMirror, { type ReactCodeMirrorRef } from '@uiw/react-codemirror';
+import { EditorView, gutter, GutterMarker, ViewPlugin, type ViewUpdate } from '@codemirror/view';
+import type { Extension } from '@codemirror/state';
+import { Decoration, type DecorationSet } from '@codemirror/view';
+import { getCodeMirrorExtensionFromPath } from '@/lib/languages';
+import { Button } from '@kandev/ui/button';
+import { IconDeviceFloppy, IconLoader2, IconTrash, IconTextWrap, IconTextWrapDisabled, IconMessagePlus } from '@tabler/icons-react';
+import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip';
+import { formatDiffStats } from '@/lib/utils/file-diff';
+import { toRelativePath } from '@/lib/utils';
+import { vscodeDark } from '@uiw/codemirror-theme-vscode';
+import { useDiffCommentsStore, useFileComments } from '@/lib/state/slices/diff-comments/diff-comments-slice';
+import type { DiffComment } from '@/lib/diff/types';
+import { useToast } from '@/components/toast-provider';
+import { EditorCommentPopover } from '@/components/task/editor-comment-popover';
+import { CommentViewPopover } from '@/components/task/comment-view-popover';
+
+type FileEditorContentProps = {
+ path: string;
+ content: string;
+ originalContent: string;
+ isDirty: boolean;
+ isSaving: boolean;
+ sessionId?: string;
+ worktreePath?: string;
+ enableComments?: boolean;
+ onChange: (newContent: string) => void;
+ onSave: () => void;
+ onDelete?: () => void;
+};
+
+type TextSelection = {
+ text: string;
+ startLine: number;
+ endLine: number;
+ position: { x: number; y: number };
+} | null;
+
+type FloatingButtonPosition = {
+ x: number;
+ y: number;
+} | null;
+
+type CommentViewState = {
+ comments: DiffComment[];
+ position: { x: number; y: number };
+} | null;
+
+// Create comment line decorations
+function createCommentDecorations(
+ view: EditorView,
+ comments: DiffComment[]
+): DecorationSet {
+ if (comments.length === 0) {
+ return Decoration.none;
+ }
+
+ const decorations: Array<{ from: number; decoration: Decoration }> = [];
+
+ // Get all lines that have comments
+ const linesWithComments = new Set();
+ for (const comment of comments) {
+ for (let line = comment.startLine; line <= comment.endLine; line++) {
+ linesWithComments.add(line);
+ }
+ }
+
+ // Create line decorations for commented lines
+ for (const lineNum of linesWithComments) {
+ if (lineNum > view.state.doc.lines) continue;
+ const line = view.state.doc.line(lineNum);
+ decorations.push({
+ from: line.from,
+ decoration: Decoration.line({
+ class: 'cm-comment-line',
+ }),
+ });
+ }
+
+ decorations.sort((a, b) => a.from - b.from);
+ return Decoration.set(decorations.map(d => d.decoration.range(d.from)));
+}
+
+// Gutter marker for lines with comments - defined outside component to avoid re-creation
+class CommentGutterMarker extends GutterMarker {
+ constructor(
+ readonly lineComments: DiffComment[],
+ readonly isFirstLine: boolean
+ ) {
+ super();
+ }
+
+ toDOM() {
+ const marker = document.createElement('div');
+ marker.className = 'cm-comment-gutter-marker';
+ marker.title = `${this.lineComments.length} comment${this.lineComments.length > 1 ? 's' : ''} - click to view`;
+
+ // Add icon only on the first line of a comment
+ if (this.isFirstLine) {
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
+ svg.setAttribute('width', '12');
+ svg.setAttribute('height', '12');
+ svg.setAttribute('viewBox', '0 0 24 24');
+ svg.setAttribute('fill', 'none');
+ svg.setAttribute('stroke', 'rgba(99, 102, 241, 0.9)');
+ svg.setAttribute('stroke-width', '2');
+ svg.setAttribute('stroke-linecap', 'round');
+ svg.setAttribute('stroke-linejoin', 'round');
+
+ const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
+ path.setAttribute('d', 'M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z');
+ svg.appendChild(path);
+ marker.appendChild(svg);
+ }
+
+ return marker;
+ }
+
+ eq(other: CommentGutterMarker) {
+ return this.lineComments.length === other.lineComments.length && this.isFirstLine === other.isFirstLine;
+ }
+}
+
+export function CodeMirrorCodeEditor({
+ path,
+ content,
+ originalContent,
+ isDirty,
+ isSaving,
+ sessionId,
+ worktreePath,
+ enableComments = false,
+ onChange,
+ onSave,
+ onDelete,
+}: FileEditorContentProps) {
+ const [wrapEnabled, setWrapEnabled] = useState(true);
+ const [textSelection, setTextSelection] = useState(null);
+ const [floatingButtonPos, setFloatingButtonPos] = useState(null);
+ const [currentSelection, setCurrentSelection] = useState<{ text: string; startLine: number; endLine: number } | null>(null);
+ const [commentView, setCommentView] = useState(null);
+ const editorRef = useRef(null);
+ const wrapperRef = useRef(null);
+ const mousePositionRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
+ const { toast } = useToast();
+
+ // Comment store
+ const addComment = useDiffCommentsStore((state) => state.addComment);
+ const removeComment = useDiffCommentsStore((state) => state.removeComment);
+ const comments = useFileComments(sessionId ?? '', path);
+
+ const langExt = getCodeMirrorExtensionFromPath(path);
+
+ // Create the comment decoration plugin
+ const commentPlugin = useMemo(() => {
+ return ViewPlugin.fromClass(
+ class {
+ decorations: DecorationSet;
+
+ constructor(view: EditorView) {
+ this.decorations = createCommentDecorations(view, comments);
+ }
+
+ update(update: ViewUpdate) {
+ if (update.docChanged || update.viewportChanged) {
+ this.decorations = createCommentDecorations(update.view, comments);
+ }
+ }
+ },
+ { decorations: (v) => v.decorations }
+ );
+ }, [comments]);
+
+ // Create comment gutter
+ const commentGutter = useMemo(() => {
+ // Build a map of line number to comments that include that line
+ const commentsByLine = new Map();
+ // Track which lines are the first line of at least one comment
+ const firstLines = new Set();
+
+ for (const comment of comments) {
+ firstLines.add(comment.startLine);
+ // Add to all lines in the range
+ for (let lineNum = comment.startLine; lineNum <= comment.endLine; lineNum++) {
+ const existing = commentsByLine.get(lineNum) || [];
+ existing.push(comment);
+ commentsByLine.set(lineNum, existing);
+ }
+ }
+
+ return gutter({
+ class: 'cm-comment-gutter',
+ lineMarker: (view, line) => {
+ const lineNum = view.state.doc.lineAt(line.from).number;
+ const lineComments = commentsByLine.get(lineNum);
+ if (lineComments && lineComments.length > 0) {
+ return new CommentGutterMarker(lineComments, firstLines.has(lineNum));
+ }
+ return null;
+ },
+ domEventHandlers: {
+ click: (view, line, event) => {
+ const lineNum = view.state.doc.lineAt(line.from).number;
+ const lineComments = commentsByLine.get(lineNum);
+ if (lineComments && lineComments.length > 0) {
+ event.preventDefault();
+ event.stopPropagation();
+ setCommentView({
+ comments: lineComments,
+ position: { x: (event as MouseEvent).clientX, y: (event as MouseEvent).clientY },
+ });
+ return true;
+ }
+ return false;
+ },
+ },
+ });
+ }, [comments]);
+
+ // Track mouse position
+ useEffect(() => {
+ const handleMouseMove = (e: MouseEvent) => {
+ mousePositionRef.current = { x: e.clientX, y: e.clientY };
+ };
+ document.addEventListener('mousemove', handleMouseMove);
+ return () => document.removeEventListener('mousemove', handleMouseMove);
+ }, []);
+
+ // Show floating button after selection ends (on mouseup)
+ const handleSelectionEnd = useCallback(() => {
+ if (!enableComments || !sessionId) return;
+
+ const view = editorRef.current?.view;
+ if (!view) return;
+
+ const selection = view.state.selection.main;
+ if (selection.empty) {
+ setFloatingButtonPos(null);
+ setCurrentSelection(null);
+ return;
+ }
+
+ const selectedText = view.state.sliceDoc(selection.from, selection.to);
+ if (!selectedText.trim()) {
+ setFloatingButtonPos(null);
+ setCurrentSelection(null);
+ return;
+ }
+
+ // Get line numbers
+ const startLine = view.state.doc.lineAt(selection.from).number;
+ const endLine = view.state.doc.lineAt(selection.to).number;
+
+ // Use mouse position for the floating button (where cursor is)
+ setCurrentSelection({ text: selectedText, startLine, endLine });
+ setFloatingButtonPos({ x: mousePositionRef.current.x, y: mousePositionRef.current.y });
+ }, [enableComments, sessionId]);
+
+ // Listen for mouseup to show floating button after selection ends
+ useEffect(() => {
+ const wrapper = wrapperRef.current;
+ if (!wrapper || !enableComments || !sessionId) return;
+
+ const handleMouseUp = (e: MouseEvent) => {
+ // Don't show if clicking on the floating button itself
+ if ((e.target as HTMLElement).closest('.floating-comment-btn')) {
+ return;
+ }
+ // Small delay to let selection finalize
+ setTimeout(handleSelectionEnd, 10);
+ };
+
+ // Clear selection state when clicking (new selection starts)
+ const handleMouseDown = (e: MouseEvent) => {
+ // Don't clear if clicking on the floating button
+ if ((e.target as HTMLElement).closest('.floating-comment-btn')) {
+ return;
+ }
+ setFloatingButtonPos(null);
+ };
+
+ wrapper.addEventListener('mouseup', handleMouseUp);
+ wrapper.addEventListener('mousedown', handleMouseDown);
+ return () => {
+ wrapper.removeEventListener('mouseup', handleMouseUp);
+ wrapper.removeEventListener('mousedown', handleMouseDown);
+ };
+ }, [enableComments, sessionId, handleSelectionEnd]);
+
+ // Clear floating button when selection is cleared via keyboard
+ const selectionUpdateExtension = useMemo(() => {
+ return EditorView.updateListener.of((update) => {
+ if (update.selectionSet) {
+ const selection = update.state.selection.main;
+ if (selection.empty) {
+ setFloatingButtonPos(null);
+ setCurrentSelection(null);
+ }
+ }
+ });
+ }, []);
+
+ const extensions: Extension[] = useMemo(() => {
+ const exts: Extension[] = [
+ EditorView.editable.of(true),
+ EditorView.theme({
+ '&': { backgroundColor: 'hsl(var(--background)) !important' },
+ '.cm-gutters': { backgroundColor: 'hsl(var(--background)) !important', borderRight: 'none' },
+ // Comment gutter
+ '.cm-comment-gutter': {
+ width: '22px',
+ cursor: 'pointer',
+ },
+ '.cm-comment-gutter .cm-gutterElement': {
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ padding: '0',
+ },
+ // Marker styles - background color on the marker itself (indigo)
+ '.cm-comment-gutter-marker': {
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ width: '100%',
+ height: '100%',
+ cursor: 'pointer',
+ backgroundColor: 'rgba(99, 102, 241, 0.2)',
+ },
+ '.cm-comment-gutter-marker:hover': {
+ backgroundColor: 'rgba(99, 102, 241, 0.35)',
+ },
+ // Comment line highlight with left border (indigo)
+ '.cm-comment-line': {
+ backgroundColor: 'rgba(99, 102, 241, 0.15) !important',
+ borderLeft: '3px solid rgba(99, 102, 241, 0.6)',
+ marginLeft: '-3px',
+ paddingLeft: '3px',
+ },
+ }),
+ commentGutter,
+ commentPlugin,
+ selectionUpdateExtension,
+ ];
+ if (wrapEnabled) {
+ exts.push(EditorView.lineWrapping);
+ }
+ if (langExt) {
+ exts.push(langExt);
+ }
+ return exts;
+ }, [langExt, wrapEnabled, commentGutter, commentPlugin, selectionUpdateExtension]);
+
+ // Calculate diff stats when dirty
+ const diffStats = useMemo(() => {
+ if (!isDirty) return null;
+
+ const originalLines = originalContent.split('\n');
+ const currentLines = content.split('\n');
+
+ let additions = 0;
+ let deletions = 0;
+
+ const maxLen = Math.max(originalLines.length, currentLines.length);
+ for (let i = 0; i < maxLen; i++) {
+ const origLine = originalLines[i];
+ const currLine = currentLines[i];
+
+ if (origLine === undefined && currLine !== undefined) {
+ additions++;
+ } else if (origLine !== undefined && currLine === undefined) {
+ deletions++;
+ } else if (origLine !== currLine) {
+ additions++;
+ deletions++;
+ }
+ }
+
+ return { additions, deletions };
+ }, [isDirty, content, originalContent]);
+
+ // Handle Cmd+I to open comment popover
+ useEffect(() => {
+ const wrapper = wrapperRef.current;
+ if (!wrapper || !enableComments || !sessionId) return;
+
+ const handleKeyDown = (e: KeyboardEvent) => {
+ // Check for Cmd+I (Mac) or Ctrl+I (Windows/Linux)
+ if ((e.metaKey || e.ctrlKey) && e.key === 'i') {
+ if (!currentSelection || !floatingButtonPos) return;
+
+ e.preventDefault();
+ e.stopPropagation();
+
+ setTextSelection({
+ ...currentSelection,
+ position: floatingButtonPos,
+ });
+ setFloatingButtonPos(null);
+ }
+ };
+
+ wrapper.addEventListener('keydown', handleKeyDown, true);
+ return () => wrapper.removeEventListener('keydown', handleKeyDown, true);
+ }, [enableComments, sessionId, currentSelection, floatingButtonPos]);
+
+ // Handle Cmd/Ctrl+S keyboard shortcut and Escape
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if ((e.metaKey || e.ctrlKey) && e.key === 's') {
+ e.preventDefault();
+ if (isDirty && !isSaving) {
+ onSave();
+ }
+ }
+ // Escape to close popovers
+ if (e.key === 'Escape') {
+ if (textSelection) setTextSelection(null);
+ if (commentView) setCommentView(null);
+ }
+ };
+
+ window.addEventListener('keydown', handleKeyDown);
+ return () => window.removeEventListener('keydown', handleKeyDown);
+ }, [isDirty, isSaving, onSave, textSelection, commentView]);
+
+ const handleChange = useCallback(
+ (value: string) => {
+ onChange(value);
+ },
+ [onChange]
+ );
+
+ // Open comment popover from floating button
+ const handleFloatingButtonClick = useCallback((e: React.MouseEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ if (!currentSelection || !floatingButtonPos) return;
+
+ setTextSelection({
+ ...currentSelection,
+ position: floatingButtonPos,
+ });
+ setFloatingButtonPos(null);
+ }, [currentSelection, floatingButtonPos]);
+
+ // Submit comment
+ const handleCommentSubmit = useCallback((annotation: string) => {
+ if (!textSelection || !sessionId) return;
+
+ const { text, startLine, endLine } = textSelection;
+
+ const comment: DiffComment = {
+ id: `${path}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
+ sessionId,
+ filePath: path,
+ startLine,
+ endLine,
+ side: 'additions',
+ codeContent: text,
+ annotation,
+ createdAt: new Date().toISOString(),
+ status: 'pending',
+ };
+
+ addComment(comment);
+ setTextSelection(null);
+
+ // Clear selection in editor
+ const view = editorRef.current?.view;
+ if (view) {
+ view.dispatch({
+ selection: { anchor: view.state.selection.main.head },
+ });
+ }
+
+ toast({
+ title: 'Comment added',
+ description: 'Your comment will be sent with your next message.',
+ });
+ }, [textSelection, sessionId, path, addComment, toast]);
+
+ // Close popover
+ const handlePopoverClose = useCallback(() => {
+ setTextSelection(null);
+ }, []);
+
+ // Delete comment
+ const handleDeleteComment = useCallback((commentId: string) => {
+ if (!sessionId) return;
+ removeComment(sessionId, path, commentId);
+
+ // Close the view if no more comments
+ if (commentView && commentView.comments.length <= 1) {
+ setCommentView(null);
+ } else if (commentView) {
+ setCommentView({
+ ...commentView,
+ comments: commentView.comments.filter(c => c.id !== commentId),
+ });
+ }
+
+ toast({
+ title: 'Comment deleted',
+ });
+ }, [sessionId, path, removeComment, commentView, toast]);
+
+ // Close comment view
+ const handleCommentViewClose = useCallback(() => {
+ setCommentView(null);
+ }, []);
+
+ return (
+
+ {/* Editor header with save button */}
+
+
+ {toRelativePath(path, worktreePath)}
+ {isDirty && diffStats && (
+
+ {formatDiffStats(diffStats.additions, diffStats.deletions)}
+
+ )}
+
+
+ {enableComments && sessionId && comments.length > 0 && (
+
+
+ {comments.length} comment{comments.length > 1 ? 's' : ''}
+
+ )}
+
+
+
+
+ {wrapEnabled ? 'Disable word wrap' : 'Enable word wrap'}
+
+ {onDelete && (
+
+
+
+
+ Delete file
+
+ )}
+
+
+
+
+ {/* CodeMirror editor */}
+
+
+
+ {/* Floating "Add comment" button when text is selected */}
+ {floatingButtonPos && !textSelection && (
+
+ )}
+
+ {/* Comment popover for adding new comment */}
+ {textSelection && (
+
+ )}
+
+ {/* Comment view popover for viewing/deleting existing comments */}
+ {commentView && (
+
+ )}
+
+
+ );
+}
diff --git a/apps/web/components/editors/file-actions-dropdown.tsx b/apps/web/components/editors/file-actions-dropdown.tsx
new file mode 100644
index 0000000000..2c86007ce7
--- /dev/null
+++ b/apps/web/components/editors/file-actions-dropdown.tsx
@@ -0,0 +1,135 @@
+'use client';
+
+import { useMemo, useCallback } from 'react';
+import {
+ IconExternalLink,
+ IconCopy,
+ IconFolderShare,
+} from '@tabler/icons-react';
+import { Button } from '@kandev/ui/button';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from '@kandev/ui/dropdown-menu';
+import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip';
+import { useEditors } from '@/hooks/domains/settings/use-editors';
+import { useOpenSessionInEditor } from '@/hooks/use-open-session-in-editor';
+import { useOpenSessionFolder } from '@/hooks/use-open-session-folder';
+import { useAppStore } from '@/components/state-provider';
+import type { EditorOption } from '@/lib/types/http';
+
+type FileActionsDropdownProps = {
+ /** File path to open / copy */
+ filePath: string;
+ /** Session ID override — defaults to activeSessionId from store */
+ sessionId?: string;
+ /** Button size variant */
+ size?: 'sm' | 'xs';
+ /** Optional toast callback after copy */
+ onCopied?: () => void;
+};
+
+export function FileActionsDropdown({
+ filePath,
+ sessionId: sessionIdProp,
+ size = 'xs',
+ onCopied,
+}: FileActionsDropdownProps) {
+ const storeSessionId = useAppStore((state) => state.tasks.activeSessionId);
+ const sessionId = sessionIdProp ?? storeSessionId ?? null;
+ const worktreePath = useAppStore((state) => {
+ if (!sessionId) return undefined;
+ return state.taskSessions.items[sessionId]?.worktree_path;
+ });
+
+ const openEditor = useOpenSessionInEditor(sessionId);
+ const openFolder = useOpenSessionFolder(sessionId);
+ const { editors } = useEditors();
+ const defaultEditorId = useAppStore((state) => state.userSettings.defaultEditorId);
+
+ const enabledEditors = useMemo(
+ () =>
+ editors.filter((editor: EditorOption) => {
+ if (!editor.enabled) return false;
+ if (editor.kind === 'built_in') return editor.installed;
+ return true;
+ }),
+ [editors]
+ );
+
+ const absolutePath = useMemo(() => {
+ if (!worktreePath) return filePath;
+ // Avoid double slashes
+ const base = worktreePath.endsWith('/') ? worktreePath : `${worktreePath}/`;
+ const rel = filePath.startsWith('/') ? filePath.slice(1) : filePath;
+ return `${base}${rel}`;
+ }, [worktreePath, filePath]);
+
+ const handleCopyPath = useCallback(() => {
+ navigator.clipboard.writeText(absolutePath);
+ onCopied?.();
+ }, [absolutePath, onCopied]);
+
+ const handleOpenInEditor = useCallback(
+ (editorId: string) => {
+ void openEditor.open({ editorId, filePath });
+ },
+ [openEditor, filePath]
+ );
+
+ const handleOpenFolder = useCallback(() => {
+ void openFolder.open();
+ }, [openFolder]);
+
+ const btnClass = size === 'xs'
+ ? 'h-6 w-6 p-0 cursor-pointer opacity-60 hover:opacity-100'
+ : 'h-8 w-8 p-0 cursor-pointer text-muted-foreground hover:text-foreground';
+
+ const iconClass = size === 'xs' ? 'h-3.5 w-3.5' : 'h-4 w-4';
+
+ return (
+
+
+
+
+
+
+
+ Open with...
+
+
+ {enabledEditors.map((editor: EditorOption) => (
+ handleOpenInEditor(editor.id)}
+ >
+ {editor.name}
+ {editor.id === defaultEditorId && (
+ default
+ )}
+
+ ))}
+ {enabledEditors.length === 0 && (
+
+ No editors configured
+
+ )}
+
+
+
+ Copy path
+
+
+
+ Open folder
+
+
+
+ );
+}
diff --git a/apps/web/components/editors/monaco/builtin-providers.ts b/apps/web/components/editors/monaco/builtin-providers.ts
new file mode 100644
index 0000000000..b513d4cd71
--- /dev/null
+++ b/apps/web/components/editors/monaco/builtin-providers.ts
@@ -0,0 +1,16 @@
+// Shared state for controlling Monaco's built-in TS/JS provider suppression.
+// Separated into its own module (no heavy imports) so both monaco-loader.ts
+// and lsp-client-manager.ts can import it without circular dependencies or
+// pulling in the full monaco-editor bundle.
+
+let _suppressed = false;
+
+/** Returns true when built-in TS/JS providers should be suppressed (LSP active). */
+export function isBuiltinTsSuppressed(): boolean {
+ return _suppressed;
+}
+
+/** Set suppression state. Called synchronously from lsp-client-manager. */
+export function setBuiltinTsSuppressed(suppressed: boolean): void {
+ _suppressed = suppressed;
+}
diff --git a/apps/web/components/editors/monaco/monaco-code-block.tsx b/apps/web/components/editors/monaco/monaco-code-block.tsx
new file mode 100644
index 0000000000..a280d53d96
--- /dev/null
+++ b/apps/web/components/editors/monaco/monaco-code-block.tsx
@@ -0,0 +1,82 @@
+'use client';
+
+import { useTheme } from 'next-themes';
+import Editor from '@monaco-editor/react';
+import { IconCheck, IconCopy } from '@tabler/icons-react';
+import { cn } from '@/lib/utils';
+import { getMonacoLanguageFromName } from '@/lib/editor/language-map';
+import { EDITOR_FONT_FAMILY } from '@/lib/theme/editor-theme';
+import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard';
+import { initMonacoThemes } from './monaco-init';
+
+initMonacoThemes();
+
+type MonacoCodeBlockProps = {
+ children: React.ReactNode;
+ className?: string;
+};
+
+export function MonacoCodeBlock({ children, className }: MonacoCodeBlockProps) {
+ const { copied, copy } = useCopyToClipboard();
+ const { resolvedTheme } = useTheme();
+
+ const code = String(children).replace(/\n$/, '');
+ const lang = className?.replace('language-', '').toLowerCase() ?? '';
+ const language = getMonacoLanguageFromName(lang);
+ const lineCount = code.split('\n').length;
+ // Compact height: ~16px per line + 8px padding
+ const height = Math.min(Math.max(lineCount * 16 + 8, 32), 500);
+
+ return (
+
+
+
+
+
+ );
+}
diff --git a/apps/web/components/editors/monaco/monaco-code-editor.tsx b/apps/web/components/editors/monaco/monaco-code-editor.tsx
new file mode 100644
index 0000000000..1716d86320
--- /dev/null
+++ b/apps/web/components/editors/monaco/monaco-code-editor.tsx
@@ -0,0 +1,719 @@
+'use client';
+
+import { useCallback, useEffect, useMemo, useState, useRef } from 'react';
+import Editor, { type OnMount, type OnChange } from '@monaco-editor/react';
+import type { editor as monacoEditor, IDisposable } from 'monaco-editor';
+import { useTheme } from 'next-themes';
+import { Button } from '@kandev/ui/button';
+import { IconDeviceFloppy, IconLoader2, IconTrash, IconTextWrap, IconTextWrapDisabled, IconMessagePlus, IconArrowsDiff, IconPlugConnected, IconPlugOff, IconAlertTriangle } from '@tabler/icons-react';
+import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip';
+import { formatDiffStats } from '@/lib/utils/file-diff';
+import { toRelativePath } from '@/lib/utils';
+import { getMonacoLanguage } from '@/lib/editor/language-map';
+import { EDITOR_FONT_FAMILY, EDITOR_FONT_SIZE } from '@/lib/theme/editor-theme';
+import { useDiffCommentsStore, useFileComments } from '@/lib/state/slices/diff-comments/diff-comments-slice';
+import type { DiffComment } from '@/lib/diff/types';
+import { useToast } from '@/components/toast-provider';
+import { EditorCommentPopover } from '@/components/task/editor-comment-popover';
+import { CommentViewPopover } from '@/components/task/comment-view-popover';
+import { diffLines } from 'diff';
+import { FileActionsDropdown } from '@/components/editors/file-actions-dropdown';
+import { useAppStore } from '@/components/state-provider';
+import { useLsp } from '@/hooks/use-lsp';
+import type { LspStatus } from '@/lib/lsp/lsp-client-manager';
+import { lspClientManager } from '@/lib/lsp/lsp-client-manager';
+import { consumePendingCursorPosition } from '@/hooks/use-file-editors';
+import { initMonacoThemes } from './monaco-init';
+
+initMonacoThemes();
+
+type MonacoCodeEditorProps = {
+ path: string;
+ content: string;
+ originalContent: string;
+ isDirty: boolean;
+ isSaving: boolean;
+ sessionId?: string;
+ worktreePath?: string;
+ enableComments?: boolean;
+ onChange: (newContent: string) => void;
+ onSave: () => void;
+ onDelete?: () => void;
+};
+
+type TextSelection = {
+ text: string;
+ startLine: number;
+ endLine: number;
+ position: { x: number; y: number };
+} | null;
+
+type FloatingButtonPosition = {
+ x: number;
+ y: number;
+} | null;
+
+type CommentViewState = {
+ comments: DiffComment[];
+ position: { x: number; y: number };
+} | null;
+
+function LspStatusButton({
+ status,
+ lspLanguage,
+ onToggle,
+}: {
+ status: LspStatus;
+ lspLanguage: string | null;
+ onToggle: () => void;
+}) {
+ if (!lspLanguage) return null;
+
+ type StateConfig = { icon: React.ReactNode; tooltip: string; clickable: boolean };
+ const configs: Record = {
+ disabled: {
+ icon: ,
+ tooltip: 'LSP: Off \u2014 click to start',
+ clickable: true,
+ },
+ connecting: {
+ icon: ,
+ tooltip: 'LSP: Connecting...',
+ clickable: true,
+ },
+ installing: {
+ icon: ,
+ tooltip: 'LSP: Installing language server...',
+ clickable: false,
+ },
+ starting: {
+ icon: ,
+ tooltip: 'LSP: Starting language server...',
+ clickable: true,
+ },
+ ready: {
+ icon: ,
+ tooltip: 'LSP: Connected \u2014 click to stop',
+ clickable: true,
+ },
+ stopping: {
+ icon: ,
+ tooltip: 'LSP: Stopping...',
+ clickable: false,
+ },
+ unavailable: {
+ icon: ,
+ tooltip: `LSP: ${'reason' in status ? status.reason : 'Unavailable'}`,
+ clickable: true,
+ },
+ error: {
+ icon: ,
+ tooltip: `LSP: ${'reason' in status ? status.reason : 'Error'}`,
+ clickable: true,
+ },
+ };
+
+ const c = configs[status.state];
+ if (!c) return null;
+
+ return (
+
+
+
+
+ {c.tooltip}
+
+ );
+}
+
+export function MonacoCodeEditor({
+ path,
+ content,
+ originalContent,
+ isDirty,
+ isSaving,
+ sessionId,
+ worktreePath,
+ enableComments = false,
+ onChange,
+ onSave,
+ onDelete,
+}: MonacoCodeEditorProps) {
+ const { resolvedTheme } = useTheme();
+ const [wrapEnabled, setWrapEnabled] = useState(true);
+ const [textSelection, setTextSelection] = useState(null);
+ const [floatingButtonPos, setFloatingButtonPos] = useState(null);
+ const [currentSelection, setCurrentSelection] = useState<{ text: string; startLine: number; endLine: number } | null>(null);
+ const [commentView, setCommentView] = useState(null);
+ const [showDiffIndicators, setShowDiffIndicators] = useState(true);
+ const editorRef = useRef(null);
+ const wrapperRef = useRef(null);
+ const mousePositionRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
+ const decorationsRef = useRef(null);
+ const diffDecorationsRef = useRef(null);
+ const disposablesRef = useRef([]);
+ const { toast } = useToast();
+
+ const addComment = useDiffCommentsStore((state) => state.addComment);
+ const removeComment = useDiffCommentsStore((state) => state.removeComment);
+ const comments = useFileComments(sessionId ?? '', path);
+
+ const language = getMonacoLanguage(path);
+
+ // LSP integration
+ const activeSessionId = useAppStore((state) => state.tasks.activeSessionId);
+ const lspSessionId = sessionId ?? activeSessionId ?? null;
+ const { status: lspStatus, lspLanguage, toggle: toggleLsp } = useLsp(lspSessionId, language);
+
+ const hasLspActive = lspStatus.state === 'ready';
+
+ // Use absolute path for Monaco model URI so the LSP server receives correct file:// URIs
+ const monacoPath = worktreePath ? `${worktreePath}/${path}` : path;
+
+ // LSP document sync: open/close document when LSP becomes ready or component mounts/unmounts
+ const documentUri = `file://${monacoPath}`;
+ useEffect(() => {
+ if (!hasLspActive || !lspSessionId || !lspLanguage) return;
+ lspClientManager.openDocument(lspSessionId, lspLanguage, documentUri, language, content);
+ return () => {
+ lspClientManager.closeDocument(lspSessionId, lspLanguage, documentUri);
+ };
+ // Only run on mount/unmount and when LSP readiness changes — NOT on content changes
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [hasLspActive, lspSessionId, lspLanguage, documentUri, language]);
+
+ // LSP document change: notify server of content changes (debounced)
+ const changeTimerRef = useRef | null>(null);
+ useEffect(() => {
+ if (!hasLspActive || !lspSessionId || !lspLanguage) return;
+ if (changeTimerRef.current) clearTimeout(changeTimerRef.current);
+ changeTimerRef.current = setTimeout(() => {
+ lspClientManager.changeDocument(lspSessionId, lspLanguage, documentUri, content);
+ }, 300);
+ return () => {
+ if (changeTimerRef.current) clearTimeout(changeTimerRef.current);
+ };
+ }, [hasLspActive, lspSessionId, lspLanguage, documentUri, content]);
+
+ // Toast on LSP status changes
+ const lspStateForToast = lspStatus.state;
+ const lspReasonForToast = 'reason' in lspStatus ? lspStatus.reason : null;
+ useEffect(() => {
+ if (lspStateForToast === 'installing') {
+ toast({
+ title: 'Installing language server',
+ description: 'This may take a moment...',
+ });
+ } else if (lspStateForToast === 'unavailable' && lspReasonForToast) {
+ toast({
+ title: 'Language server not found',
+ description: `${lspReasonForToast}. Enable auto-install in Settings → Editors.`,
+ });
+ } else if (lspStateForToast === 'error' && lspReasonForToast) {
+ toast({
+ title: 'LSP error',
+ description: lspReasonForToast,
+ });
+ }
+ }, [lspStateForToast, lspReasonForToast, toast]);
+
+ // Track mouse position
+ useEffect(() => {
+ const handleMouseMove = (e: MouseEvent) => {
+ mousePositionRef.current = { x: e.clientX, y: e.clientY };
+ };
+ document.addEventListener('mousemove', handleMouseMove);
+ return () => document.removeEventListener('mousemove', handleMouseMove);
+ }, []);
+
+ // Handle editor mount
+ const handleEditorDidMount: OnMount = useCallback(
+ (editor) => {
+ editorRef.current = editor;
+ decorationsRef.current = editor.createDecorationsCollection([]);
+ diffDecorationsRef.current = editor.createDecorationsCollection([]);
+
+ // Jump to pending cursor position (e.g. from LSP Go-to-Definition)
+ const pendingPos = consumePendingCursorPosition(path);
+ if (pendingPos) {
+ editor.setPosition({ lineNumber: pendingPos.line, column: pendingPos.column });
+ editor.revealLineInCenter(pendingPos.line);
+ }
+
+ // Cmd/Ctrl+S to save
+ editor.addCommand(
+ // eslint-disable-next-line no-bitwise
+ (window as { monaco?: typeof import('monaco-editor') }).monaco!.KeyMod.CtrlCmd |
+ (window as { monaco?: typeof import('monaco-editor') }).monaco!.KeyCode.KeyS,
+ () => {
+ onSave();
+ }
+ );
+
+ // Selection change listener for comments
+ if (enableComments && sessionId) {
+ const disposable = editor.onDidChangeCursorSelection(() => {
+ const selection = editor.getSelection();
+ if (!selection || selection.isEmpty()) {
+ setCurrentSelection(null);
+ return;
+ }
+ const model = editor.getModel();
+ if (!model) return;
+ const text = model.getValueInRange(selection);
+ if (!text.trim()) {
+ setCurrentSelection(null);
+ return;
+ }
+ setCurrentSelection({
+ text,
+ startLine: selection.startLineNumber,
+ endLine: selection.endLineNumber,
+ });
+ });
+ disposablesRef.current.push(disposable);
+ }
+
+ // Gutter click for viewing comments
+ const glyphDisposable = editor.onMouseDown((e) => {
+ if (e.target.type === 2 /* GUTTER_GLYPH_MARGIN */ || e.target.type === 3 /* GUTTER_LINE_NUMBERS */) {
+ const lineNumber = e.target.position?.lineNumber;
+ if (!lineNumber) return;
+
+ const lineComments = comments.filter(
+ (c) => lineNumber >= c.startLine && lineNumber <= c.endLine
+ );
+ if (lineComments.length > 0 && e.event.browserEvent) {
+ setCommentView({
+ comments: lineComments,
+ position: { x: e.event.browserEvent.clientX, y: e.event.browserEvent.clientY },
+ });
+ }
+ }
+ });
+ disposablesRef.current.push(glyphDisposable);
+ },
+ [path, onSave, enableComments, sessionId, comments]
+ );
+
+ // Cleanup disposables
+ useEffect(() => {
+ return () => {
+ for (const d of disposablesRef.current) d.dispose();
+ disposablesRef.current = [];
+ };
+ }, []);
+
+ // Update decorations when comments change
+ useEffect(() => {
+ if (!decorationsRef.current || !editorRef.current) return;
+
+ const decorations: monacoEditor.IModelDeltaDecoration[] = [];
+ const linesWithComments = new Set();
+ const firstLines = new Set();
+
+ for (const comment of comments) {
+ firstLines.add(comment.startLine);
+ for (let line = comment.startLine; line <= comment.endLine; line++) {
+ linesWithComments.add(line);
+ }
+ }
+
+ for (const lineNum of linesWithComments) {
+ decorations.push({
+ range: {
+ startLineNumber: lineNum,
+ startColumn: 1,
+ endLineNumber: lineNum,
+ endColumn: 1,
+ },
+ options: {
+ isWholeLine: true,
+ className: 'monaco-comment-line',
+ glyphMarginClassName: firstLines.has(lineNum)
+ ? 'monaco-comment-glyph'
+ : 'monaco-comment-glyph-bg',
+ glyphMarginHoverMessage: {
+ value: `${comments.filter((c) => lineNum >= c.startLine && lineNum <= c.endLine).length} comment(s) - click to view`,
+ },
+ },
+ });
+ }
+
+ decorationsRef.current.set(decorations);
+ }, [comments]);
+
+ // Diff gutter indicators
+ useEffect(() => {
+ if (!diffDecorationsRef.current || !editorRef.current) return;
+
+ if (!showDiffIndicators || !isDirty || !originalContent) {
+ diffDecorationsRef.current.set([]);
+ return;
+ }
+
+ const changes = diffLines(originalContent, content);
+ const decorations: monacoEditor.IModelDeltaDecoration[] = [];
+ let currentLine = 1;
+
+ for (let i = 0; i < changes.length; i++) {
+ const change = changes[i];
+ const lineCount = change.count ?? 0;
+
+ if (change.removed) {
+ // Check if next change is an addition (modified lines)
+ const next = changes[i + 1];
+ if (next && next.added) {
+ // Modified: removed + added pair
+ const addedLineCount = next.count ?? 0;
+ for (let j = 0; j < addedLineCount; j++) {
+ decorations.push({
+ range: { startLineNumber: currentLine + j, startColumn: 1, endLineNumber: currentLine + j, endColumn: 1 },
+ options: { isWholeLine: true, linesDecorationsClassName: 'monaco-diff-modified-gutter' },
+ });
+ }
+ currentLine += addedLineCount;
+ i++; // skip the added part
+ } else {
+ // Standalone deletion: red indicator at previous line
+ const indicatorLine = Math.max(1, currentLine - 1);
+ decorations.push({
+ range: { startLineNumber: indicatorLine, startColumn: 1, endLineNumber: indicatorLine, endColumn: 1 },
+ options: { isWholeLine: true, linesDecorationsClassName: 'monaco-diff-deleted-gutter' },
+ });
+ }
+ } else if (change.added) {
+ // Standalone addition
+ for (let j = 0; j < lineCount; j++) {
+ decorations.push({
+ range: { startLineNumber: currentLine + j, startColumn: 1, endLineNumber: currentLine + j, endColumn: 1 },
+ options: { isWholeLine: true, linesDecorationsClassName: 'monaco-diff-added-gutter' },
+ });
+ }
+ currentLine += lineCount;
+ } else {
+ // Unchanged
+ currentLine += lineCount;
+ }
+ }
+
+ diffDecorationsRef.current.set(decorations);
+ }, [content, originalContent, showDiffIndicators, isDirty]);
+
+ // Show floating button after mouse up
+ useEffect(() => {
+ const wrapper = wrapperRef.current;
+ if (!wrapper || !enableComments || !sessionId) return;
+
+ const handleMouseUp = (e: MouseEvent) => {
+ if ((e.target as HTMLElement).closest('.floating-comment-btn')) return;
+ setTimeout(() => {
+ if (currentSelection) {
+ setFloatingButtonPos({ x: mousePositionRef.current.x, y: mousePositionRef.current.y });
+ }
+ }, 10);
+ };
+
+ const handleMouseDown = (e: MouseEvent) => {
+ if ((e.target as HTMLElement).closest('.floating-comment-btn')) return;
+ setFloatingButtonPos(null);
+ };
+
+ wrapper.addEventListener('mouseup', handleMouseUp);
+ wrapper.addEventListener('mousedown', handleMouseDown);
+ return () => {
+ wrapper.removeEventListener('mouseup', handleMouseUp);
+ wrapper.removeEventListener('mousedown', handleMouseDown);
+ };
+ }, [enableComments, sessionId, currentSelection]);
+
+ // Cmd+I for comment popover
+ useEffect(() => {
+ const wrapper = wrapperRef.current;
+ if (!wrapper || !enableComments || !sessionId) return;
+
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if ((e.metaKey || e.ctrlKey) && e.key === 'i') {
+ if (!currentSelection || !floatingButtonPos) return;
+ e.preventDefault();
+ e.stopPropagation();
+ setTextSelection({ ...currentSelection, position: floatingButtonPos });
+ setFloatingButtonPos(null);
+ }
+ };
+
+ wrapper.addEventListener('keydown', handleKeyDown, true);
+ return () => wrapper.removeEventListener('keydown', handleKeyDown, true);
+ }, [enableComments, sessionId, currentSelection, floatingButtonPos]);
+
+ // Escape to close popovers
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') {
+ if (textSelection) setTextSelection(null);
+ if (commentView) setCommentView(null);
+ }
+ };
+ window.addEventListener('keydown', handleKeyDown);
+ return () => window.removeEventListener('keydown', handleKeyDown);
+ }, [textSelection, commentView]);
+
+ const handleChange: OnChange = useCallback(
+ (value) => {
+ if (value !== undefined) onChange(value);
+ },
+ [onChange]
+ );
+
+ const handleFloatingButtonClick = useCallback(
+ (e: React.MouseEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ if (!currentSelection || !floatingButtonPos) return;
+ setTextSelection({ ...currentSelection, position: floatingButtonPos });
+ setFloatingButtonPos(null);
+ },
+ [currentSelection, floatingButtonPos]
+ );
+
+ const handleCommentSubmit = useCallback(
+ (annotation: string) => {
+ if (!textSelection || !sessionId) return;
+ const comment: DiffComment = {
+ id: `${path}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
+ sessionId,
+ filePath: path,
+ startLine: textSelection.startLine,
+ endLine: textSelection.endLine,
+ side: 'additions',
+ codeContent: textSelection.text,
+ annotation,
+ createdAt: new Date().toISOString(),
+ status: 'pending',
+ };
+ addComment(comment);
+ setTextSelection(null);
+
+ // Clear editor selection
+ const editor = editorRef.current;
+ if (editor) {
+ const pos = editor.getPosition();
+ if (pos) editor.setSelection({ startLineNumber: pos.lineNumber, startColumn: pos.column, endLineNumber: pos.lineNumber, endColumn: pos.column });
+ }
+
+ toast({ title: 'Comment added', description: 'Your comment will be sent with your next message.' });
+ },
+ [textSelection, sessionId, path, addComment, toast]
+ );
+
+ const handlePopoverClose = useCallback(() => setTextSelection(null), []);
+
+ const handleDeleteComment = useCallback(
+ (commentId: string) => {
+ if (!sessionId) return;
+ removeComment(sessionId, path, commentId);
+ if (commentView && commentView.comments.length <= 1) {
+ setCommentView(null);
+ } else if (commentView) {
+ setCommentView({
+ ...commentView,
+ comments: commentView.comments.filter((c) => c.id !== commentId),
+ });
+ }
+ toast({ title: 'Comment deleted' });
+ },
+ [sessionId, path, removeComment, commentView, toast]
+ );
+
+ const handleCommentViewClose = useCallback(() => setCommentView(null), []);
+
+ // Diff stats
+ const diffStats = useMemo(() => {
+ if (!isDirty) return null;
+ const originalLines = originalContent.split('\n');
+ const currentLines = content.split('\n');
+ let additions = 0;
+ let deletions = 0;
+ const maxLen = Math.max(originalLines.length, currentLines.length);
+ for (let i = 0; i < maxLen; i++) {
+ const origLine = originalLines[i];
+ const currLine = currentLines[i];
+ if (origLine === undefined && currLine !== undefined) additions++;
+ else if (origLine !== undefined && currLine === undefined) deletions++;
+ else if (origLine !== currLine) { additions++; deletions++; }
+ }
+ return { additions, deletions };
+ }, [isDirty, content, originalContent]);
+
+ return (
+
+ {/* Editor header */}
+
+
+ {toRelativePath(path, worktreePath)}
+ {isDirty && diffStats && (
+
+ {formatDiffStats(diffStats.additions, diffStats.deletions)}
+
+ )}
+
+
+ {enableComments && sessionId && comments.length > 0 && (
+
+
+ {comments.length} comment{comments.length > 1 ? 's' : ''}
+
+ )}
+
+ {isDirty && (
+
+
+
+
+ {showDiffIndicators ? 'Hide diff indicators' : 'Show diff indicators'}
+
+ )}
+
+
+
+
+ {wrapEnabled ? 'Disable word wrap' : 'Enable word wrap'}
+
+
+ {onDelete && (
+
+
+
+
+ Delete file
+
+ )}
+
+
+
+
+ {/* Monaco editor */}
+
+
+ Loading editor...
+
+ }
+ />
+
+ {/* Floating comment button */}
+ {floatingButtonPos && !textSelection && (
+
+ )}
+
+ {textSelection && (
+
+ )}
+
+ {commentView && (
+
+ )}
+
+
+ );
+}
diff --git a/apps/web/components/editors/monaco/monaco-diff-viewer.tsx b/apps/web/components/editors/monaco/monaco-diff-viewer.tsx
new file mode 100644
index 0000000000..cfbeb175f7
--- /dev/null
+++ b/apps/web/components/editors/monaco/monaco-diff-viewer.tsx
@@ -0,0 +1,555 @@
+'use client';
+
+import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
+import { DiffEditor, type DiffOnMount } from '@monaco-editor/react';
+import type { editor as monacoEditor } from 'monaco-editor';
+import { useTheme } from 'next-themes';
+import { cn } from '@kandev/ui/lib/utils';
+import { Button } from '@kandev/ui/button';
+import { Tooltip, TooltipTrigger, TooltipContent } from '@kandev/ui/tooltip';
+import { IconCopy, IconTextWrap, IconLayoutRows, IconLayoutColumns, IconPencil, IconArrowBackUp, IconFoldDown, IconFold } from '@tabler/icons-react';
+import type { FileDiffData, DiffComment } from '@/lib/diff/types';
+import { getMonacoLanguage } from '@/lib/editor/language-map';
+import { EDITOR_FONT_FAMILY, EDITOR_FONT_SIZE } from '@/lib/theme/editor-theme';
+import { useDiffComments } from '@/components/diff/use-diff-comments';
+import { CommentForm } from '@/components/diff/comment-form';
+import { CommentDisplay } from '@/components/diff/comment-display';
+import { initMonacoThemes } from './monaco-init';
+
+initMonacoThemes();
+
+// Global view mode sync (same as pierre diff-viewer)
+const DIFF_VIEW_MODE_KEY = 'diff-view-mode';
+const DEFAULT_VIEW_MODE = 'unified' as const;
+const VIEW_MODE_CHANGE_EVENT = 'diff-view-mode-change';
+type ViewMode = 'split' | 'unified';
+
+function getStoredViewMode(): ViewMode {
+ if (typeof window === 'undefined') return DEFAULT_VIEW_MODE;
+ const stored = localStorage.getItem(DIFF_VIEW_MODE_KEY);
+ return stored === 'split' || stored === 'unified' ? stored : DEFAULT_VIEW_MODE;
+}
+
+function setStoredViewMode(mode: ViewMode): void {
+ localStorage.setItem(DIFF_VIEW_MODE_KEY, mode);
+ window.dispatchEvent(new CustomEvent(VIEW_MODE_CHANGE_EVENT, { detail: mode }));
+}
+
+function useGlobalViewMode(): [ViewMode, (mode: ViewMode) => void] {
+ const subscribe = useCallback((callback: () => void) => {
+ window.addEventListener(VIEW_MODE_CHANGE_EVENT, callback);
+ window.addEventListener('storage', callback);
+ return () => {
+ window.removeEventListener(VIEW_MODE_CHANGE_EVENT, callback);
+ window.removeEventListener('storage', callback);
+ };
+ }, []);
+ const getSnapshot = useCallback(() => getStoredViewMode(), []);
+ const getServerSnapshot = useCallback(() => DEFAULT_VIEW_MODE, []);
+ const viewMode = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
+ return [viewMode, setStoredViewMode];
+}
+
+// Global folding toggle sync (same pattern as view mode)
+const DIFF_FOLD_KEY = 'diff-fold-unchanged';
+const DEFAULT_FOLD = true;
+const FOLD_CHANGE_EVENT = 'diff-fold-change';
+
+function getStoredFolding(): boolean {
+ if (typeof window === 'undefined') return DEFAULT_FOLD;
+ const stored = localStorage.getItem(DIFF_FOLD_KEY);
+ if (stored === null) return DEFAULT_FOLD;
+ return stored === 'true';
+}
+
+function setStoredFolding(fold: boolean): void {
+ localStorage.setItem(DIFF_FOLD_KEY, String(fold));
+ window.dispatchEvent(new CustomEvent(FOLD_CHANGE_EVENT, { detail: fold }));
+}
+
+function useGlobalFolding(): [boolean, (fold: boolean) => void] {
+ const subscribe = useCallback((callback: () => void) => {
+ window.addEventListener(FOLD_CHANGE_EVENT, callback);
+ window.addEventListener('storage', callback);
+ return () => {
+ window.removeEventListener(FOLD_CHANGE_EVENT, callback);
+ window.removeEventListener('storage', callback);
+ };
+ }, []);
+ const getSnapshot = useCallback(() => getStoredFolding(), []);
+ const getServerSnapshot = useCallback(() => DEFAULT_FOLD, []);
+ const foldUnchanged = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
+ return [foldUnchanged, setStoredFolding];
+}
+
+interface MonacoDiffViewerProps {
+ data: FileDiffData;
+ sessionId?: string;
+ onCommentAdd?: (comment: DiffComment) => void;
+ onCommentDelete?: (commentId: string) => void;
+ comments?: DiffComment[];
+ className?: string;
+ compact?: boolean;
+ hideHeader?: boolean;
+ onOpenFile?: (filePath: string) => void;
+ onRevert?: (filePath: string) => void;
+ wordWrap?: boolean;
+}
+
+export function MonacoDiffViewer({
+ data,
+ sessionId,
+ onCommentAdd,
+ onCommentDelete,
+ comments: externalComments,
+ className,
+ compact = false,
+ hideHeader = false,
+ onOpenFile,
+ onRevert,
+ wordWrap: wordWrapProp,
+}: MonacoDiffViewerProps) {
+ const { resolvedTheme } = useTheme();
+ const [globalViewMode, setGlobalViewMode] = useGlobalViewMode();
+ const [foldUnchanged, setFoldUnchanged] = useGlobalFolding();
+ const [wordWrapLocal, setWordWrap] = useState(false);
+ const wordWrap = wordWrapProp ?? wordWrapLocal;
+ const diffEditorRef = useRef(null);
+ const wrapperRef = useRef(null);
+
+ // Context menu state
+ type ContextMenuState = {
+ x: number;
+ y: number;
+ lineNumber: number;
+ side: 'original' | 'modified';
+ isChangedLine: boolean;
+ lineContent: string;
+ } | null;
+ const [contextMenu, setContextMenu] = useState(null);
+
+ // Comment form state
+ const [showCommentForm, setShowCommentForm] = useState(false);
+ const [selectedLineRange, setSelectedLineRange] = useState<{ start: number; end: number; side: string } | null>(null);
+
+ const {
+ comments: internalComments,
+ addComment,
+ removeComment,
+ updateComment,
+ editingCommentId,
+ setEditingComment,
+ } = useDiffComments({
+ sessionId: sessionId || '',
+ filePath: data.filePath,
+ diff: data.diff,
+ newContent: data.newContent,
+ oldContent: data.oldContent,
+ });
+
+ const comments = externalComments || internalComments;
+
+ const language = getMonacoLanguage(data.filePath);
+ const showHeader = !hideHeader && !compact;
+
+ // Parse original/modified content from diff data
+ // When only a unified diff string is provided (e.g. git diffs), reconstruct
+ // the original and modified content by parsing the patch hunks.
+ const { original, modified } = useMemo(() => {
+ if (data.oldContent || data.newContent) {
+ return {
+ original: data.oldContent ?? '',
+ modified: data.newContent ?? '',
+ };
+ }
+
+ // Reconstruct from unified diff patch
+ if (data.diff) {
+ const originalLines: string[] = [];
+ const modifiedLines: string[] = [];
+ const lines = data.diff.split('\n');
+
+ for (const line of lines) {
+ // Skip diff headers
+ if (line.startsWith('diff ') || line.startsWith('index ') ||
+ line.startsWith('--- ') || line.startsWith('+++ ') ||
+ line.startsWith('@@')) {
+ continue;
+ }
+
+ if (line.startsWith('-')) {
+ originalLines.push(line.slice(1));
+ } else if (line.startsWith('+')) {
+ modifiedLines.push(line.slice(1));
+ } else if (line.startsWith(' ') || line === '') {
+ // Context line (or empty line between hunks)
+ const content = line.startsWith(' ') ? line.slice(1) : line;
+ originalLines.push(content);
+ modifiedLines.push(content);
+ } else if (line.startsWith('\\')) {
+ // "\ No newline at end of file" — skip
+ continue;
+ }
+ }
+
+ return {
+ original: originalLines.join('\n'),
+ modified: modifiedLines.join('\n'),
+ };
+ }
+
+ return { original: '', modified: '' };
+ }, [data.oldContent, data.newContent, data.diff]);
+
+ const handleDiffEditorMount: DiffOnMount = useCallback((editor) => {
+ diffEditorRef.current = editor;
+
+ // Register context menu on both sides
+ const modifiedEditor = editor.getModifiedEditor();
+ const originalEditor = editor.getOriginalEditor();
+
+ const handleContextMenu = (side: 'original' | 'modified') => (e: monacoEditor.IEditorMouseEvent) => {
+ if (e.target.position) {
+ e.event.preventDefault();
+ e.event.stopPropagation();
+ const lineNumber = e.target.position.lineNumber;
+ const targetEditor = side === 'modified' ? modifiedEditor : originalEditor;
+ const model = targetEditor.getModel();
+ const lineContent = model ? model.getLineContent(lineNumber) : '';
+ const lineChanges = editor.getLineChanges();
+ let isChangedLine = false;
+ if (lineChanges) {
+ for (const change of lineChanges) {
+ if (side === 'modified') {
+ if (lineNumber >= change.modifiedStartLineNumber && lineNumber <= change.modifiedEndLineNumber) {
+ isChangedLine = true;
+ break;
+ }
+ } else {
+ if (lineNumber >= change.originalStartLineNumber && lineNumber <= change.originalEndLineNumber) {
+ isChangedLine = true;
+ break;
+ }
+ }
+ }
+ }
+ setContextMenu({
+ x: e.event.posx,
+ y: e.event.posy,
+ lineNumber,
+ side,
+ isChangedLine,
+ lineContent,
+ });
+ }
+ };
+
+ modifiedEditor.onContextMenu(handleContextMenu('modified'));
+ originalEditor.onContextMenu(handleContextMenu('original'));
+ }, []);
+
+ // Handle comment submission
+ const handleCommentSubmit = useCallback(
+ (content: string) => {
+ if (!selectedLineRange) return;
+ if (onCommentAdd && externalComments !== undefined) {
+ const comment: DiffComment = {
+ id: `${data.filePath}-${Date.now()}`,
+ sessionId: sessionId || '',
+ filePath: data.filePath,
+ startLine: Math.min(selectedLineRange.start, selectedLineRange.end),
+ endLine: Math.max(selectedLineRange.start, selectedLineRange.end),
+ side: (selectedLineRange.side || 'additions') as DiffComment['side'],
+ codeContent: '',
+ annotation: content,
+ createdAt: new Date().toISOString(),
+ status: 'pending',
+ };
+ onCommentAdd(comment);
+ } else if (sessionId) {
+ addComment(
+ {
+ start: selectedLineRange.start,
+ end: selectedLineRange.end,
+ side: selectedLineRange.side as 'additions' | 'deletions',
+ },
+ content
+ );
+ }
+ setShowCommentForm(false);
+ setSelectedLineRange(null);
+ },
+ [selectedLineRange, sessionId, data.filePath, addComment, onCommentAdd, externalComments]
+ );
+
+ const handleCommentDelete = useCallback(
+ (commentId: string) => {
+ if (onCommentDelete && externalComments !== undefined) {
+ onCommentDelete(commentId);
+ } else {
+ removeComment(commentId);
+ }
+ },
+ [removeComment, onCommentDelete, externalComments]
+ );
+
+ const handleCommentUpdate = useCallback(
+ (commentId: string, content: string) => {
+ updateComment(commentId, { annotation: content });
+ setEditingComment(null);
+ },
+ [updateComment, setEditingComment]
+ );
+
+ // Close context menu on click or Escape
+ useEffect(() => {
+ if (!contextMenu) return;
+ const handleClose = () => setContextMenu(null);
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') setContextMenu(null);
+ };
+ window.addEventListener('mousedown', handleClose);
+ window.addEventListener('keydown', handleKeyDown);
+ return () => {
+ window.removeEventListener('mousedown', handleClose);
+ window.removeEventListener('keydown', handleKeyDown);
+ };
+ }, [contextMenu]);
+
+ // Copy all changed lines from the diff
+ const copyAllChangedLines = useCallback(() => {
+ const editor = diffEditorRef.current;
+ if (!editor) return;
+ const lineChanges = editor.getLineChanges();
+ if (!lineChanges) return;
+ const modifiedModel = editor.getModifiedEditor().getModel();
+ if (!modifiedModel) return;
+ const changedLines: string[] = [];
+ for (const change of lineChanges) {
+ if (change.modifiedStartLineNumber <= change.modifiedEndLineNumber) {
+ for (let i = change.modifiedStartLineNumber; i <= change.modifiedEndLineNumber; i++) {
+ changedLines.push(modifiedModel.getLineContent(i));
+ }
+ }
+ }
+ navigator.clipboard.writeText(changedLines.join('\n'));
+ setContextMenu(null);
+ }, []);
+
+ if (!data.oldContent && !data.newContent && !data.diff) {
+ return (
+
+ No diff available
+
+ );
+ }
+
+ return (
+
+ {/* Toolbar */}
+ {showHeader && (
+
+
{data.filePath}
+
+
+
+
+
+ Copy diff
+
+
+
+
+
+ {foldUnchanged ? 'Show all lines' : 'Fold unchanged lines'}
+
+ {onRevert && (
+
+
+
+
+ Revert changes
+
+ )}
+
+
+
+
+ Toggle word wrap
+
+
+
+
+
+ {globalViewMode === 'split' ? 'Switch to unified view' : 'Switch to split view'}
+
+ {onOpenFile && (
+
+
+
+
+ Edit
+
+ )}
+
+
+ )}
+
+ {/* Diff editor */}
+
+
+ Loading diff...
+
+ }
+ />
+
+
+ {/* Comment annotations below the diff */}
+ {comments.length > 0 && (
+
+ {comments.map((comment) => (
+
+ {editingCommentId === comment.id ? (
+ handleCommentUpdate(comment.id, content)}
+ onCancel={() => setEditingComment(null)}
+ isEditing
+ />
+ ) : (
+ handleCommentDelete(comment.id)}
+ onEdit={() => setEditingComment(comment.id)}
+ showCode={false}
+ />
+ )}
+
+ ))}
+
+ )}
+
+ {/* New comment form */}
+ {showCommentForm && selectedLineRange && (
+
+ {
+ setShowCommentForm(false);
+ setSelectedLineRange(null);
+ }}
+ />
+
+ )}
+
+ {/* Context menu */}
+ {contextMenu && (
+ e.stopPropagation()}
+ >
+
+ {contextMenu.isChangedLine && (
+
+ )}
+ {onRevert && (
+ <>
+
+
+ >
+ )}
+
+ )}
+
+ );
+}
diff --git a/apps/web/components/editors/monaco/monaco-init.ts b/apps/web/components/editors/monaco/monaco-init.ts
new file mode 100644
index 0000000000..8abfc3e1f3
--- /dev/null
+++ b/apps/web/components/editors/monaco/monaco-init.ts
@@ -0,0 +1,108 @@
+import type { Monaco } from '@monaco-editor/react';
+import type { editor as monacoEditor, Uri, IRange, IPosition } from 'monaco-editor';
+import { KANDEV_MONACO_DARK, KANDEV_MONACO_LIGHT } from '@/lib/theme/editor-theme';
+
+let initialized = false;
+let monacoInstance: Monaco | null = null;
+
+export function initMonacoThemes() {
+ if (initialized || typeof window === 'undefined') return;
+ initialized = true;
+
+ // Dynamic import ensures monaco-loader runs (which sets MonacoEnvironment +
+ // loader.config) before any component mounts.
+ import('./monaco-loader').then(({ monaco }) => {
+ monacoInstance = monaco;
+
+ monaco.editor.defineTheme('kandev-dark', KANDEV_MONACO_DARK);
+ monaco.editor.defineTheme('kandev-light', KANDEV_MONACO_LIGHT);
+
+ // Disable Monaco's built-in TS/JS diagnostics — they can't resolve project-level
+ // path aliases (@/, tsconfig paths). LSP diagnostics are applied separately via
+ // setModelMarkers() and work independently of this setting.
+ setMonacoDiagnostics(false);
+
+ // Enable JSX so .tsx/.jsx files parse correctly
+ const tsDefaults = monaco.languages.typescript.typescriptDefaults;
+ const jsDefaults = monaco.languages.typescript.javascriptDefaults;
+ tsDefaults.setCompilerOptions({
+ jsx: monaco.languages.typescript.JsxEmit.ReactJSX,
+ allowJs: true,
+ allowNonTsExtensions: true,
+ target: monaco.languages.typescript.ScriptTarget.ESNext,
+ module: monaco.languages.typescript.ModuleKind.ESNext,
+ moduleResolution: monaco.languages.typescript.ModuleResolutionKind.NodeJs,
+ });
+ jsDefaults.setCompilerOptions({
+ jsx: monaco.languages.typescript.JsxEmit.ReactJSX,
+ allowJs: true,
+ allowNonTsExtensions: true,
+ target: monaco.languages.typescript.ScriptTarget.ESNext,
+ module: monaco.languages.typescript.ModuleKind.ESNext,
+ });
+
+ // Register editor opener to intercept Go-to-Definition navigation.
+ // When the LSP returns a file:// URI, this opens the file in a dockview tab
+ // instead of Monaco trying to navigate to a non-existent model.
+ // Dynamic import to avoid circular dependency (lsp-client-manager → monaco-init).
+ import('@/lib/lsp/lsp-client-manager').then(({ lspClientManager }) => {
+ monaco.editor.registerEditorOpener({
+ openCodeEditor(_source: monacoEditor.ICodeEditor, resource: Uri, selectionOrPosition?: IRange | IPosition) {
+ const uri = resource.toString();
+ const opener = lspClientManager.getFileOpener();
+ if (opener && uri.startsWith('file:')) {
+ // Extract line/column from the selection or position
+ let line: number | undefined;
+ let column: number | undefined;
+ if (selectionOrPosition) {
+ if ('lineNumber' in selectionOrPosition) {
+ // IPosition
+ line = selectionOrPosition.lineNumber;
+ column = selectionOrPosition.column;
+ } else if ('startLineNumber' in selectionOrPosition) {
+ // IRange
+ line = selectionOrPosition.startLineNumber;
+ column = selectionOrPosition.startColumn;
+ }
+ }
+ opener(uri, line, column);
+ return true;
+ }
+ return false;
+ },
+ });
+ });
+ });
+}
+
+export function getMonacoInstance(): Monaco | null {
+ return monacoInstance;
+}
+
+export function setMonacoDiagnostics(enabled: boolean) {
+ if (!monacoInstance) return;
+ const tsDefaults = monacoInstance.languages.typescript.typescriptDefaults;
+ const jsDefaults = monacoInstance.languages.typescript.javascriptDefaults;
+ const diagOptions = {
+ noSemanticValidation: !enabled,
+ noSuggestionDiagnostics: !enabled,
+ noSyntaxValidation: !enabled,
+ };
+ tsDefaults.setDiagnosticsOptions(diagOptions);
+ jsDefaults.setDiagnosticsOptions(diagOptions);
+}
+
+/**
+ * Suppress or restore Monaco's built-in TS/JS language providers (hover,
+ * completions, definitions, references, etc.). When LSP is active these
+ * should be suppressed to avoid duplicate results.
+ *
+ * Uses the shared flag in builtin-providers.ts which is checked both at
+ * registration time (to decide whether to wrap a provider) and at call time
+ * (to decide whether wrapped providers return empty results).
+ */
+export function setMonacoBuiltinProviders(enabled: boolean) {
+ import('./builtin-providers').then(({ setBuiltinTsSuppressed }) => {
+ setBuiltinTsSuppressed(!enabled);
+ });
+}
diff --git a/apps/web/components/editors/monaco/monaco-inline-diff.tsx b/apps/web/components/editors/monaco/monaco-inline-diff.tsx
new file mode 100644
index 0000000000..d9debea30c
--- /dev/null
+++ b/apps/web/components/editors/monaco/monaco-inline-diff.tsx
@@ -0,0 +1,92 @@
+'use client';
+
+import { useMemo } from 'react';
+import { DiffEditor } from '@monaco-editor/react';
+import { useTheme } from 'next-themes';
+import { cn } from '@/lib/utils';
+import type { FileDiffData } from '@/lib/diff/types';
+import { getMonacoLanguage } from '@/lib/editor/language-map';
+import { EDITOR_FONT_FAMILY } from '@/lib/theme/editor-theme';
+import { initMonacoThemes } from './monaco-init';
+
+initMonacoThemes();
+
+type MonacoInlineDiffProps = {
+ data: FileDiffData;
+ className?: string;
+};
+
+export function MonacoInlineDiff({ data, className }: MonacoInlineDiffProps) {
+ const { resolvedTheme } = useTheme();
+ const language = getMonacoLanguage(data.filePath);
+
+ const { original, modified } = useMemo(() => {
+ if (data.oldContent || data.newContent) {
+ return {
+ original: data.oldContent ?? '',
+ modified: data.newContent ?? '',
+ };
+ }
+
+ // Reconstruct from unified diff patch
+ if (data.diff) {
+ const originalLines: string[] = [];
+ const modifiedLines: string[] = [];
+ for (const line of data.diff.split('\n')) {
+ if (line.startsWith('diff ') || line.startsWith('index ') ||
+ line.startsWith('--- ') || line.startsWith('+++ ') ||
+ line.startsWith('@@') || line.startsWith('\\')) continue;
+ if (line.startsWith('-')) {
+ originalLines.push(line.slice(1));
+ } else if (line.startsWith('+')) {
+ modifiedLines.push(line.slice(1));
+ } else {
+ const content = line.startsWith(' ') ? line.slice(1) : line;
+ originalLines.push(content);
+ modifiedLines.push(content);
+ }
+ }
+ return { original: originalLines.join('\n'), modified: modifiedLines.join('\n') };
+ }
+
+ return { original: '', modified: '' };
+ }, [data.oldContent, data.newContent, data.diff]);
+
+ const lineCount = Math.max(
+ original.split('\n').length,
+ modified.split('\n').length
+ );
+ const height = Math.min(Math.max(lineCount * 16 + 8, 48), 300);
+
+ return (
+
+
+
+ );
+}
diff --git a/apps/web/components/editors/monaco/monaco-loader.ts b/apps/web/components/editors/monaco/monaco-loader.ts
new file mode 100644
index 0000000000..d0974ff296
--- /dev/null
+++ b/apps/web/components/editors/monaco/monaco-loader.ts
@@ -0,0 +1,116 @@
+import * as monacoImport from 'monaco-editor';
+import type { Monaco } from '@monaco-editor/react';
+import type { IDisposable } from 'monaco-editor';
+import { loader } from '@monaco-editor/react';
+import { isBuiltinTsSuppressed } from './builtin-providers';
+
+// Cast to Monaco type (from @monaco-editor/react) which has the full
+// languages.typescript typings. The main 'monaco-editor' export marks
+// languages.typescript as deprecated in favor of top-level typescript
+// namespace, but @monaco-editor/react's Monaco type still uses it.
+const monaco = monacoImport as unknown as Monaco;
+
+// ---------------------------------------------------------------------------
+// Built-in TS/JS provider suppression
+// ---------------------------------------------------------------------------
+// Monaco v0.55.1's setModeConfiguration fires onDidChange, but the
+// tsMode.js setupMode() function never subscribes to it — providers are
+// registered once and never torn down. So we intercept provider registration
+// to wrap built-in TS/JS providers with a suppression flag check. When LSP
+// is active, the wrappers return null/empty instead of calling the original.
+//
+// Key: we check `isBuiltinTsSuppressed()` at REGISTRATION time. If the flag
+// is already true when a provider is registered, it means the LSP client is
+// registering its own providers — those should NOT be wrapped. Only providers
+// registered while the flag is false (Monaco's built-in ones) get wrapped.
+// ---------------------------------------------------------------------------
+
+const TS_LANGUAGES = new Set([
+ 'typescript',
+ 'javascript',
+ 'typescriptreact',
+ 'javascriptreact',
+]);
+
+// Configure workers before Monaco initializes.
+// The `new URL('...', import.meta.url)` pattern is recognized by Turbopack/webpack
+// to bundle each worker as a separate chunk.
+if (typeof window !== 'undefined') {
+ self.MonacoEnvironment = {
+ getWorker(_workerId: string, label: string) {
+ switch (label) {
+ case 'json':
+ return new Worker(new URL('monaco-editor/esm/vs/language/json/json.worker.js', import.meta.url), { type: 'module' });
+ case 'css':
+ case 'scss':
+ case 'less':
+ return new Worker(new URL('monaco-editor/esm/vs/language/css/css.worker.js', import.meta.url), { type: 'module' });
+ case 'html':
+ case 'handlebars':
+ case 'razor':
+ return new Worker(new URL('monaco-editor/esm/vs/language/html/html.worker.js', import.meta.url), { type: 'module' });
+ case 'typescript':
+ case 'javascript':
+ return new Worker(new URL('monaco-editor/esm/vs/language/typescript/ts.worker.js', import.meta.url), { type: 'module' });
+ default:
+ return new Worker(new URL('monaco-editor/esm/vs/editor/editor.worker.js', import.meta.url), { type: 'module' });
+ }
+ },
+ };
+
+ // Tell @monaco-editor/react to use local monaco instance (no CDN fetch)
+ loader.config({ monaco: monacoImport });
+
+ // Expose on window for code that accesses window.monaco (e.g. keybindings in editor)
+ (window as { monaco?: Monaco }).monaco = monaco;
+
+ // Wrap built-in TS/JS provider registration so we can suppress them at call
+ // time when an external LSP is active. This MUST happen before the first TS
+ // model is created (which triggers the lazy tsMode setup), and since this
+ // module runs at import time, it's guaranteed to be in place early enough.
+ const langs = monacoImport.languages;
+
+ type ProviderRegistrationFn = (selector: string, provider: Record, ...rest: unknown[]) => IDisposable;
+
+ function wrapRegistration(
+ original: ProviderRegistrationFn,
+ methodName: string,
+ emptyResult: unknown,
+ ): ProviderRegistrationFn {
+ return function (selector: string, provider: Record, ...rest: unknown[]) {
+ // Only wrap providers for TS/JS languages that are registered while
+ // suppression is OFF (= Monaco's built-in providers). When suppression
+ // is already ON at registration time, it means the LSP client is
+ // registering its own providers — pass those through unwrapped.
+ if (
+ typeof selector === 'string' &&
+ TS_LANGUAGES.has(selector) &&
+ typeof provider[methodName] === 'function' &&
+ !isBuiltinTsSuppressed()
+ ) {
+ const origMethod = (provider[methodName] as (...a: unknown[]) => unknown).bind(provider);
+ const wrapped = Object.create(provider);
+ wrapped[methodName] = function (...args: unknown[]) {
+ if (isBuiltinTsSuppressed()) return emptyResult;
+ return origMethod(...args);
+ };
+ return original.call(langs, selector, wrapped, ...rest);
+ }
+ return original.call(langs, selector, provider, ...rest);
+ };
+ }
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- monkey-patching Monaco internals
+ const l = langs as Record;
+ l.registerHoverProvider = wrapRegistration(l.registerHoverProvider.bind(langs), 'provideHover', null);
+ l.registerCompletionItemProvider = wrapRegistration(l.registerCompletionItemProvider.bind(langs), 'provideCompletionItems', { suggestions: [] });
+ l.registerSignatureHelpProvider = wrapRegistration(l.registerSignatureHelpProvider.bind(langs), 'provideSignatureHelp', null);
+ l.registerDefinitionProvider = wrapRegistration(l.registerDefinitionProvider.bind(langs), 'provideDefinition', null);
+ l.registerReferenceProvider = wrapRegistration(l.registerReferenceProvider.bind(langs), 'provideReferences', null);
+ l.registerDocumentHighlightProvider = wrapRegistration(l.registerDocumentHighlightProvider.bind(langs), 'provideDocumentHighlights', null);
+ l.registerCodeActionProvider = wrapRegistration(l.registerCodeActionProvider.bind(langs), 'provideCodeActions', null);
+ l.registerRenameProvider = wrapRegistration(l.registerRenameProvider.bind(langs), 'provideRenameEdits', null);
+ l.registerInlayHintsProvider = wrapRegistration(l.registerInlayHintsProvider.bind(langs), 'provideInlayHints', null);
+}
+
+export { monaco };
diff --git a/apps/web/components/editors/pierre/pierre-diff-viewer.tsx b/apps/web/components/editors/pierre/pierre-diff-viewer.tsx
new file mode 100644
index 0000000000..c6d06da4dd
--- /dev/null
+++ b/apps/web/components/editors/pierre/pierre-diff-viewer.tsx
@@ -0,0 +1,5 @@
+'use client';
+
+// Re-export the existing Pierre/diffs-based DiffViewer as PierreDiffViewer
+// The original implementation stays in components/diff/diff-viewer.tsx
+export { DiffViewer as PierreDiffViewer, DiffViewInline as PierreInlineDiff } from '@/components/diff/diff-viewer';
diff --git a/apps/web/components/editors/tiptap/tiptap-comment-highlight.ts b/apps/web/components/editors/tiptap/tiptap-comment-highlight.ts
new file mode 100644
index 0000000000..d1a07d2f14
--- /dev/null
+++ b/apps/web/components/editors/tiptap/tiptap-comment-highlight.ts
@@ -0,0 +1,221 @@
+/**
+ * TipTap extension for comment highlight decorations.
+ * Ported from the Milkdown ProseMirror plugin in markdown-editor.tsx.
+ */
+
+import { Extension } from '@tiptap/core';
+import { Plugin, PluginKey } from '@tiptap/pm/state';
+import { Decoration, DecorationSet } from '@tiptap/pm/view';
+
+export type CommentHighlight = {
+ id: string;
+ selectedText: string;
+ comment: string;
+};
+
+const MIN_COMMENT_TEXT_LENGTH = 3;
+
+const commentHighlightPluginKey = new PluginKey<{
+ deco: DecorationSet;
+ comments: CommentHighlight[];
+}>('tiptap-comment-highlight');
+
+// Helper to get plain text content and position mapping from a document
+function getTextWithPositions(doc: { descendants: (fn: (node: { isText: boolean; text?: string; type: { name: string }; isBlock: boolean }, pos: number) => void) => void }): { text: string; positions: number[] } {
+ let text = '';
+ const positions: number[] = [];
+
+ doc.descendants((node, pos) => {
+ if (node.isText && node.text) {
+ for (let i = 0; i < node.text.length; i++) {
+ positions.push(pos + i);
+ text += node.text[i];
+ }
+ } else if (node.type.name === 'hardBreak') {
+ positions.push(-1);
+ text += ' ';
+ } else if (node.isBlock && text.length > 0 && !text.endsWith('\n')) {
+ positions.push(-1);
+ text += '\n';
+ }
+ });
+
+ return { text, positions };
+}
+
+function normalizeForSearch(text: string): string {
+ return text
+ .replace(/\r\n/g, '\n')
+ .replace(/[\t ]+/g, ' ')
+ .replace(/\n+/g, '\n')
+ .trim()
+ .toLowerCase();
+}
+
+function createCommentDecorations(
+ doc: Parameters[0],
+ comments: CommentHighlight[]
+): DecorationSet {
+ const decorations: Decoration[] = [];
+
+ if (comments.length === 0) {
+ return DecorationSet.empty;
+ }
+
+ const { text: fullText, positions } = getTextWithPositions(doc);
+ const normalizedFullText = normalizeForSearch(fullText);
+
+ for (const comment of comments) {
+ const searchText = normalizeForSearch(comment.selectedText);
+ if (!searchText || searchText.length < MIN_COMMENT_TEXT_LENGTH) continue;
+
+ let normalizedIndex = normalizedFullText.indexOf(searchText);
+
+ if (normalizedIndex === -1) {
+ const firstLine = normalizeForSearch(comment.selectedText.split('\n')[0]);
+ if (firstLine.length >= MIN_COMMENT_TEXT_LENGTH) {
+ normalizedIndex = normalizedFullText.indexOf(firstLine);
+ if (normalizedIndex !== -1) {
+ addDecorationsForRange(normalizedIndex, firstLine.length, comment, fullText, positions, decorations);
+ }
+ }
+ continue;
+ }
+
+ addDecorationsForRange(normalizedIndex, searchText.length, comment, fullText, positions, decorations);
+ }
+
+ return DecorationSet.create(doc, decorations);
+}
+
+function addDecorationsForRange(
+ normalizedIndex: number,
+ length: number,
+ comment: CommentHighlight,
+ fullText: string,
+ positions: number[],
+ decorations: Decoration[]
+) {
+ const normalizedFullText = normalizeForSearch(fullText);
+ const normalizedChars = normalizedFullText.split('');
+ const origChars = fullText.split('');
+
+ const normToOrig: number[] = [];
+ let oi = 0;
+ for (let ni = 0; ni < normalizedChars.length; ni++) {
+ while (
+ oi < origChars.length &&
+ origChars[oi].toLowerCase() !== normalizedChars[ni] &&
+ /\s/.test(origChars[oi])
+ ) {
+ oi++;
+ }
+ normToOrig[ni] = oi;
+ oi++;
+ }
+
+ const startOrig = normToOrig[normalizedIndex] ?? 0;
+ const endOrig = normToOrig[normalizedIndex + length - 1] ?? startOrig;
+
+ let rangeStart = -1;
+
+ for (let i = startOrig; i <= endOrig && i < positions.length; i++) {
+ const pos = positions[i];
+
+ if (pos === -1) {
+ if (rangeStart !== -1) {
+ const fromPos = positions[rangeStart];
+ const toPos = positions[i - 1] + 1;
+ if (fromPos >= 0 && toPos > fromPos) {
+ decorations.push(
+ Decoration.inline(fromPos, toPos, {
+ class: 'comment-highlight',
+ 'data-comment-id': comment.id,
+ title: comment.comment,
+ })
+ );
+ }
+ rangeStart = -1;
+ }
+ } else {
+ if (rangeStart === -1) {
+ rangeStart = i;
+ }
+ }
+ }
+
+ if (rangeStart !== -1 && rangeStart < positions.length) {
+ const fromPos = positions[rangeStart];
+ const lastValidIndex = Math.min(endOrig, positions.length - 1);
+ const toPos = positions[lastValidIndex] + 1;
+ if (fromPos >= 0 && toPos > fromPos) {
+ decorations.push(
+ Decoration.inline(fromPos, toPos, {
+ class: 'comment-highlight',
+ 'data-comment-id': comment.id,
+ title: comment.comment,
+ })
+ );
+ }
+ }
+}
+
+export const CommentHighlightExtension = Extension.create({
+ name: 'commentHighlight',
+
+ addOptions() {
+ return {
+ comments: [] as CommentHighlight[],
+ };
+ },
+
+ addProseMirrorPlugins() {
+ const initialComments = this.options.comments as CommentHighlight[];
+
+ return [
+ new Plugin({
+ key: commentHighlightPluginKey,
+ state: {
+ init(_, state) {
+ return {
+ deco: createCommentDecorations(state.doc, initialComments),
+ comments: initialComments,
+ };
+ },
+ apply(tr, value, _, newState) {
+ const newComments = tr.getMeta(commentHighlightPluginKey);
+ if (newComments !== undefined) {
+ return {
+ deco: createCommentDecorations(newState.doc, newComments),
+ comments: newComments,
+ };
+ }
+ if (tr.docChanged) {
+ return {
+ deco: createCommentDecorations(newState.doc, value.comments),
+ comments: value.comments,
+ };
+ }
+ return value;
+ },
+ },
+ props: {
+ decorations(state) {
+ const pluginState = commentHighlightPluginKey.getState(state);
+ return pluginState?.deco ?? DecorationSet.empty;
+ },
+ },
+ }),
+ ];
+ },
+});
+
+// Helper to update comments from outside
+export function updateCommentHighlights(
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ editor: { view: { dispatch: (tr: any) => void; state: { tr: { setMeta: (key: PluginKey, value: unknown) => any } } } },
+ comments: CommentHighlight[]
+) {
+ const tr = editor.view.state.tr.setMeta(commentHighlightPluginKey, comments);
+ editor.view.dispatch(tr);
+}
diff --git a/apps/web/components/editors/tiptap/tiptap-mermaid-extension.ts b/apps/web/components/editors/tiptap/tiptap-mermaid-extension.ts
new file mode 100644
index 0000000000..363aa79db3
--- /dev/null
+++ b/apps/web/components/editors/tiptap/tiptap-mermaid-extension.ts
@@ -0,0 +1,104 @@
+/**
+ * TipTap node view extension for rendering mermaid diagrams.
+ * Ported from the Milkdown mermaidCodeBlockView in markdown-editor.tsx.
+ */
+
+import { Node, mergeAttributes } from '@tiptap/core';
+import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react';
+import { createElement, useEffect, useRef, useState } from 'react';
+import mermaid from 'mermaid';
+
+let mermaidInitialized = false;
+let mermaidIdCounter = 0;
+
+function initMermaid(theme: 'dark' | 'light' = 'dark') {
+ mermaid.initialize({
+ startOnLoad: false,
+ theme: theme === 'dark' ? 'dark' : 'default',
+ securityLevel: 'loose',
+ });
+ mermaidInitialized = true;
+}
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+function MermaidNodeView({ node }: { node: any }) {
+ const containerRef = useRef(null);
+ const [error, setError] = useState(null);
+ const code = node.textContent;
+
+ useEffect(() => {
+ if (!mermaidInitialized) initMermaid();
+ if (!containerRef.current || !code.trim()) return;
+
+ const id = `mermaid-${++mermaidIdCounter}`;
+ mermaid
+ .render(id, code)
+ .then(({ svg }) => {
+ if (containerRef.current) {
+ containerRef.current.innerHTML = svg;
+ setError(null);
+ }
+ })
+ .catch((err: Error) => {
+ setError(err.message);
+ });
+ }, [code]);
+
+ return createElement(
+ NodeViewWrapper,
+ { className: 'mermaid-container' },
+ error
+ ? createElement('pre', { className: 'mermaid-error' }, `Error rendering diagram: ${error}`)
+ : createElement('div', { ref: containerRef })
+ );
+}
+
+/**
+ * Custom CodeBlock extension that renders mermaid blocks as diagrams.
+ * For non-mermaid blocks, uses default rendering.
+ */
+export const MermaidCodeBlock = Node.create({
+ name: 'mermaidCodeBlock',
+ group: 'block',
+ content: 'text*',
+ marks: '',
+ code: true,
+ defining: true,
+
+ addAttributes() {
+ return {
+ language: {
+ default: null,
+ parseHTML: (element) => element.getAttribute('data-language'),
+ renderHTML: (attributes) => {
+ if (!attributes.language) return {};
+ return { 'data-language': attributes.language };
+ },
+ },
+ };
+ },
+
+ parseHTML() {
+ return [
+ {
+ tag: 'pre',
+ preserveWhitespace: 'full',
+ getAttrs: (node) => {
+ const el = node as HTMLElement;
+ const code = el.querySelector('code');
+ const lang = code?.className?.match(/language-(\w+)/)?.[1];
+ if (lang === 'mermaid') return { language: 'mermaid' };
+ return false; // Let default code block handle non-mermaid
+ },
+ },
+ ];
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return ['pre', mergeAttributes(HTMLAttributes), ['code', {}, 0]];
+ },
+
+ addNodeView() {
+ return ReactNodeViewRenderer(MermaidNodeView);
+ },
+});
diff --git a/apps/web/components/editors/tiptap/tiptap-plan-editor.tsx b/apps/web/components/editors/tiptap/tiptap-plan-editor.tsx
new file mode 100644
index 0000000000..2a8e988f27
--- /dev/null
+++ b/apps/web/components/editors/tiptap/tiptap-plan-editor.tsx
@@ -0,0 +1,236 @@
+'use client';
+
+import { useEffect, useRef, useState } from 'react';
+import { useTheme } from 'next-themes';
+import { useEditor, EditorContent } from '@tiptap/react';
+import StarterKit from '@tiptap/starter-kit';
+import Placeholder from '@tiptap/extension-placeholder';
+import Link from '@tiptap/extension-link';
+import TaskList from '@tiptap/extension-task-list';
+import TaskItem from '@tiptap/extension-task-item';
+import { Table } from '@tiptap/extension-table';
+import { TableRow } from '@tiptap/extension-table-row';
+import { TableCell } from '@tiptap/extension-table-cell';
+import { TableHeader } from '@tiptap/extension-table-header';
+import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight';
+import { Markdown } from 'tiptap-markdown';
+import { common, createLowlight } from 'lowlight';
+import { CommentHighlightExtension, updateCommentHighlights, type CommentHighlight } from './tiptap-comment-highlight';
+
+export type { CommentHighlight };
+
+export type TextSelection = {
+ text: string;
+ position: { x: number; y: number };
+};
+
+type TipTapPlanEditorProps = {
+ value: string;
+ onChange: (value: string) => void;
+ placeholder?: string;
+ onSelectionChange?: (selection: TextSelection | null) => void;
+ onSelectionEnd?: (selection: TextSelection | null) => void;
+ comments?: CommentHighlight[];
+ onCommentClick?: (id: string, position: { x: number; y: number }) => void;
+};
+
+const MIN_COMMENT_TEXT_LENGTH = 3;
+
+const lowlight = createLowlight(common);
+
+export function TipTapPlanEditor({
+ value,
+ onChange,
+ placeholder = 'Start typing...',
+ onSelectionChange,
+ onSelectionEnd,
+ comments = [],
+ onCommentClick,
+}: TipTapPlanEditorProps) {
+ const { resolvedTheme } = useTheme();
+ const wrapperRef = useRef(null);
+ const onChangeRef = useRef(onChange);
+ const onSelectionChangeRef = useRef(onSelectionChange);
+ const onSelectionEndRef = useRef(onSelectionEnd);
+ const onCommentClickRef = useRef(onCommentClick);
+ const [isReady, setIsReady] = useState(false);
+
+ // Keep refs updated
+ useEffect(() => { onChangeRef.current = onChange; }, [onChange]);
+ useEffect(() => { onSelectionChangeRef.current = onSelectionChange; }, [onSelectionChange]);
+ useEffect(() => { onSelectionEndRef.current = onSelectionEnd; }, [onSelectionEnd]);
+ useEffect(() => { onCommentClickRef.current = onCommentClick; }, [onCommentClick]);
+
+ const editor = useEditor({
+ extensions: [
+ StarterKit.configure({
+ codeBlock: false, // We use CodeBlockLowlight instead
+ }),
+ CodeBlockLowlight.configure({ lowlight }),
+ Markdown.configure({
+ html: true,
+ transformPastedText: true,
+ transformCopiedText: true,
+ }),
+ Placeholder.configure({ placeholder }),
+ Link.configure({ openOnClick: false }),
+ TaskList,
+ TaskItem.configure({ nested: true }),
+ Table.configure({ resizable: false }),
+ TableRow,
+ TableCell,
+ TableHeader,
+ CommentHighlightExtension.configure({ comments }),
+ ],
+ content: value,
+ editorProps: {
+ attributes: {
+ class: 'tiptap-plan-editor',
+ },
+ },
+ onUpdate: ({ editor: ed }) => {
+ // tiptap-markdown adds getMarkdown() to the editor storage
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const md = (ed.storage as any).markdown?.getMarkdown?.() as string | undefined;
+ onChangeRef.current(md ?? ed.getText());
+ },
+ onCreate: () => {
+ setIsReady(true);
+ },
+ });
+
+ // Update comment decorations when comments change
+ useEffect(() => {
+ if (!editor || !isReady) return;
+ try {
+ updateCommentHighlights(editor as Parameters[0], comments);
+ } catch {
+ // Editor might not be ready yet
+ }
+ }, [comments, editor, isReady]);
+
+ // Set up click handler for comment highlights via event delegation
+ useEffect(() => {
+ const wrapper = wrapperRef.current;
+ if (!wrapper) return;
+
+ const handleClick = (e: MouseEvent) => {
+ const target = e.target as HTMLElement;
+ const commentHighlight = target.closest('.comment-highlight');
+ if (commentHighlight && onCommentClickRef.current) {
+ const commentId = commentHighlight.getAttribute('data-comment-id');
+ if (commentId) {
+ onCommentClickRef.current(commentId, { x: e.clientX, y: e.clientY });
+ }
+ }
+ };
+
+ wrapper.addEventListener('click', handleClick);
+ return () => wrapper.removeEventListener('click', handleClick);
+ }, []);
+
+ // Handle mouseup to show floating button when selection ends
+ useEffect(() => {
+ const wrapper = wrapperRef.current;
+ if (!wrapper) return;
+
+ const handleMouseUp = (e: MouseEvent) => {
+ if (!onSelectionEndRef.current) return;
+ const mouseX = e.clientX;
+ const mouseY = e.clientY;
+
+ setTimeout(() => {
+ const selection = window.getSelection();
+ if (!selection || selection.isCollapsed || !selection.toString().trim()) {
+ onSelectionEndRef.current?.(null);
+ return;
+ }
+ const selectedText = selection.toString().trim();
+ if (selectedText.length < MIN_COMMENT_TEXT_LENGTH) {
+ onSelectionEndRef.current?.(null);
+ return;
+ }
+ onSelectionEndRef.current?.({
+ text: selectedText,
+ position: { x: mouseX, y: mouseY },
+ });
+ }, 10);
+ };
+
+ const handleMouseDown = (e: MouseEvent) => {
+ if ((e.target as HTMLElement).closest('.floating-comment-btn')) return;
+ onSelectionEndRef.current?.(null);
+ };
+
+ wrapper.addEventListener('mouseup', handleMouseUp);
+ wrapper.addEventListener('mousedown', handleMouseDown);
+ return () => {
+ wrapper.removeEventListener('mouseup', handleMouseUp);
+ wrapper.removeEventListener('mousedown', handleMouseDown);
+ };
+ }, []);
+
+ // Handle Cmd+I to trigger selection popover
+ useEffect(() => {
+ const wrapper = wrapperRef.current;
+ if (!wrapper) return;
+
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if ((e.metaKey || e.ctrlKey) && e.key === 'i') {
+ if (!onSelectionChangeRef.current) return;
+ const selection = window.getSelection();
+ if (!selection) return;
+
+ e.preventDefault();
+ e.stopImmediatePropagation();
+
+ if (selection.isCollapsed || !selection.toString().trim()) return;
+ const selectedText = selection.toString().trim();
+ if (selectedText.length < MIN_COMMENT_TEXT_LENGTH) return;
+
+ const focusNode = selection.focusNode;
+ const focusOffset = selection.focusOffset;
+
+ if (focusNode) {
+ const focusRange = document.createRange();
+ focusRange.setStart(focusNode, focusOffset);
+ focusRange.setEnd(focusNode, focusOffset);
+ const focusRect = focusRange.getBoundingClientRect();
+
+ let x = focusRect.right || focusRect.left;
+ let y = focusRect.bottom || focusRect.top;
+
+ if (x === 0 && y === 0) {
+ const range = selection.getRangeAt(0);
+ const rects = range.getClientRects();
+ const lastRect = rects.length > 0 ? rects[rects.length - 1] : range.getBoundingClientRect();
+ x = lastRect.right;
+ y = lastRect.bottom;
+ }
+
+ onSelectionChangeRef.current({
+ text: selectedText,
+ position: { x, y },
+ });
+ }
+ }
+ };
+
+ wrapper.addEventListener('keydown', handleKeyDown, true);
+ return () => wrapper.removeEventListener('keydown', handleKeyDown, true);
+ }, []);
+
+ return (
+
+
+ {!isReady && (
+
+ Loading editor...
+
+ )}
+
+ );
+}
diff --git a/apps/web/components/review/review-dialog.tsx b/apps/web/components/review/review-dialog.tsx
index 99ad30705c..f771113f98 100644
--- a/apps/web/components/review/review-dialog.tsx
+++ b/apps/web/components/review/review-dialog.tsx
@@ -22,6 +22,7 @@ type ReviewDialogProps = {
baseBranch?: string;
taskTitle?: string;
onSendComments: (comments: DiffComment[]) => void;
+ onOpenFile?: (filePath: string) => void;
gitStatusFiles: Record | null;
cumulativeDiff: CumulativeDiff | null;
};
@@ -34,6 +35,7 @@ export const ReviewDialog = memo(function ReviewDialog({
baseBranch,
taskTitle,
onSendComments,
+ onOpenFile,
gitStatusFiles,
cumulativeDiff,
}: ReviewDialogProps) {
@@ -257,6 +259,7 @@ export const ReviewDialog = memo(function ReviewDialog({
wordWrap={wordWrap}
onToggleReviewed={handleToggleReviewed}
onDiscard={handleDiscard}
+ onOpenFile={onOpenFile}
fileRefs={fileRefs}
/>
) : (
diff --git a/apps/web/components/review/review-diff-list.tsx b/apps/web/components/review/review-diff-list.tsx
index 48fe1fee15..e1b9b96a82 100644
--- a/apps/web/components/review/review-diff-list.tsx
+++ b/apps/web/components/review/review-diff-list.tsx
@@ -3,14 +3,14 @@
import { memo, useEffect, useRef, useState, useCallback } from 'react';
import {
IconAlertTriangle,
- IconCopy,
IconArrowBackUp,
+ IconPencil,
} from '@tabler/icons-react';
import { Checkbox } from '@kandev/ui/checkbox';
import { Button } from '@kandev/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip';
import { FileDiffViewer } from '@/components/diff';
-import { useToast } from '@/components/toast-provider';
+import { FileActionsDropdown } from '@/components/editors/file-actions-dropdown';
import type { ReviewFile } from './types';
type ReviewDiffListProps = {
@@ -22,6 +22,7 @@ type ReviewDiffListProps = {
wordWrap: boolean;
onToggleReviewed: (path: string, reviewed: boolean) => void;
onDiscard: (path: string) => void;
+ onOpenFile?: (filePath: string) => void;
fileRefs: Map>;
};
@@ -34,6 +35,7 @@ export const ReviewDiffList = memo(function ReviewDiffList({
wordWrap,
onToggleReviewed,
onDiscard,
+ onOpenFile,
fileRefs,
}: ReviewDiffListProps) {
const scrollContainerRef = useRef(null);
@@ -51,6 +53,7 @@ export const ReviewDiffList = memo(function ReviewDiffList({
wordWrap={wordWrap}
onToggleReviewed={onToggleReviewed}
onDiscard={onDiscard}
+ onOpenFile={onOpenFile}
sectionRef={fileRefs.get(file.path)}
scrollContainer={scrollContainerRef}
/>
@@ -68,6 +71,7 @@ type FileDiffSectionProps = {
wordWrap: boolean;
onToggleReviewed: (path: string, reviewed: boolean) => void;
onDiscard: (path: string) => void;
+ onOpenFile?: (filePath: string) => void;
sectionRef?: React.RefObject;
scrollContainer: React.RefObject;
};
@@ -81,13 +85,13 @@ function FileDiffSection({
wordWrap,
onToggleReviewed,
onDiscard,
+ onOpenFile,
sectionRef,
scrollContainer,
}: FileDiffSectionProps) {
const [isVisible, setIsVisible] = useState(false);
const sentinelRef = useRef(null);
const autoMarkedRef = useRef(false);
- const { toast } = useToast();
// Lazy rendering: mount the diff viewer only when section becomes visible
useEffect(() => {
@@ -144,11 +148,6 @@ function FileDiffSection({
[file.path, onToggleReviewed]
);
- const handleCopyPath = useCallback(() => {
- navigator.clipboard.writeText(file.path);
- toast({ title: 'Copied', description: file.path, variant: 'success' });
- }, [file.path, toast]);
-
const handleDiscard = useCallback(() => {
onDiscard(file.path);
}, [file.path, onDiscard]);
@@ -184,20 +183,25 @@ function FileDiffSection({
{/* File actions */}
- {/* Copy path */}
-
-
-
-
- Copy file path
-
+ {/* Edit in editor tab */}
+ {onOpenFile && (
+
+
+
+
+ Edit
+
+ )}
+
+ {/* Open with external editor / copy path / open folder */}
+
{/* Discard changes */}
{file.source === 'uncommitted' && (
diff --git a/apps/web/components/settings/editors-settings.tsx b/apps/web/components/settings/editors-settings.tsx
index 2303c39117..f086382260 100644
--- a/apps/web/components/settings/editors-settings.tsx
+++ b/apps/web/components/settings/editors-settings.tsx
@@ -1,11 +1,16 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
-import { IconEdit, IconTrash } from '@tabler/icons-react';
+import { IconEdit, IconTrash, IconChevronDown, IconExternalLink, IconInfoCircle } from '@tabler/icons-react';
import { Badge } from '@kandev/ui/badge';
import { Button } from '@kandev/ui/button';
+import { Checkbox } from '@kandev/ui/checkbox';
import { Input } from '@kandev/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@kandev/ui/select';
+import { Separator } from '@kandev/ui/separator';
+import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip';
+import { Switch } from '@kandev/ui/switch';
+import { Textarea } from '@kandev/ui/textarea';
import { SettingsPageTemplate } from '@/components/settings/settings-page-template';
import { Combobox, type ComboboxOption } from '@/components/combobox';
import { useAppStore } from '@/components/state-provider';
@@ -13,6 +18,7 @@ import { EditableCard } from '@/components/settings/editable-card';
import { useEditors } from '@/hooks/domains/settings/use-editors';
import { createEditor, deleteEditor, updateEditor, updateUserSettings } from '@/lib/api';
import { useRequest } from '@/lib/http/use-request';
+import { LSP_DEFAULT_CONFIGS } from '@/lib/lsp/lsp-client-manager';
import type { EditorOption } from '@/lib/types/http';
type CustomKind = 'custom_command' | 'custom_remote_ssh' | 'custom_hosted_url';
@@ -36,6 +42,21 @@ const CUSTOM_KIND_OPTIONS: Array<{ value: CustomKind; label: string }> = [
const PLACEHOLDER_HINT = '{cwd} {file} {rel} {line} {column}';
+const LSP_LANGUAGE_OPTIONS = [
+ { id: 'typescript', label: 'TypeScript / JavaScript', binary: 'typescript-language-server',
+ docsUrl: 'https://github.com/typescript-language-server/typescript-language-server#workspace-configuration',
+ installHint: 'Installs typescript-language-server and typescript via npm into ~/.kandev/lsp-servers/' },
+ { id: 'go', label: 'Go', binary: 'gopls',
+ docsUrl: 'https://github.com/golang/tools/blob/master/gopls/doc/settings.md',
+ installHint: 'Runs "go install golang.org/x/tools/gopls@latest". Requires Go to be installed.' },
+ { id: 'rust', label: 'Rust', binary: 'rust-analyzer',
+ docsUrl: 'https://rust-analyzer.github.io/book/configuration.html',
+ installHint: 'Downloads the rust-analyzer binary from GitHub releases into ~/.kandev/lsp-servers/' },
+ { id: 'python', label: 'Python', binary: 'pyright-langserver',
+ docsUrl: 'https://microsoft.github.io/pyright/#/settings',
+ installHint: 'Installs pyright via npm into ~/.kandev/lsp-servers/' },
+];
+
function getCustomKindLabel(kind: string) {
return CUSTOM_KIND_OPTIONS.find((option) => option.value === kind)?.label ?? 'Custom';
}
@@ -288,6 +309,98 @@ export function EditorsSettings() {
const [isAdding, setIsAdding] = useState(false);
const [editingId, setEditingId] = useState
(null);
+ // LSP settings
+ const [lspAutoStartLanguages, setLspAutoStartLanguages] = useState(
+ () => currentUserSettings.lspAutoStartLanguages ?? []
+ );
+ const [lspAutoInstallLanguages, setLspAutoInstallLanguages] = useState(
+ () => currentUserSettings.lspAutoInstallLanguages ?? []
+ );
+ const [baselineLspAutoStart, setBaselineLspAutoStart] = useState(
+ () => currentUserSettings.lspAutoStartLanguages ?? []
+ );
+ const [baselineLspAutoInstall, setBaselineLspAutoInstall] = useState(
+ () => currentUserSettings.lspAutoInstallLanguages ?? []
+ );
+
+ // LSP server configs (per-language JSON overrides)
+ const initConfigStrings = useCallback((): Record => {
+ const configs = currentUserSettings.lspServerConfigs ?? {};
+ const result: Record = {};
+ for (const [lang, config] of Object.entries(configs)) {
+ if (config && Object.keys(config).length > 0) {
+ result[lang] = JSON.stringify(config, null, 2);
+ }
+ }
+ return result;
+ }, [currentUserSettings.lspServerConfigs]);
+ const [lspConfigStrings, setLspConfigStrings] = useState>(initConfigStrings);
+ const [baselineLspConfigStrings, setBaselineLspConfigStrings] = useState>(initConfigStrings);
+ const [expandedConfigLang, setExpandedConfigLang] = useState(null);
+ const [lspConfigErrors, setLspConfigErrors] = useState>({});
+
+ const toggleAutoStart = useCallback((langId: string, checked: boolean) => {
+ setLspAutoStartLanguages((prev) =>
+ checked ? [...prev, langId] : prev.filter((id) => id !== langId)
+ );
+ }, []);
+
+ const toggleAutoInstall = useCallback((langId: string, checked: boolean) => {
+ setLspAutoInstallLanguages((prev) =>
+ checked ? [...prev, langId] : prev.filter((id) => id !== langId)
+ );
+ }, []);
+
+ const updateLspConfigString = useCallback((langId: string, value: string) => {
+ setLspConfigStrings((prev) => {
+ if (!value.trim()) {
+ const next = { ...prev };
+ delete next[langId];
+ return next;
+ }
+ return { ...prev, [langId]: value };
+ });
+ // Validate JSON
+ if (!value.trim()) {
+ setLspConfigErrors((prev) => {
+ const next = { ...prev };
+ delete next[langId];
+ return next;
+ });
+ return;
+ }
+ try {
+ const parsed = JSON.parse(value);
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
+ setLspConfigErrors((prev) => ({ ...prev, [langId]: 'Must be a JSON object' }));
+ } else {
+ setLspConfigErrors((prev) => {
+ const next = { ...prev };
+ delete next[langId];
+ return next;
+ });
+ }
+ } catch {
+ setLspConfigErrors((prev) => ({ ...prev, [langId]: 'Invalid JSON' }));
+ }
+ }, []);
+
+ const parseLspConfigs = useCallback((): Record> | null => {
+ const result: Record> = {};
+ for (const [lang, str] of Object.entries(lspConfigStrings)) {
+ if (!str.trim()) continue;
+ try {
+ const parsed = JSON.parse(str);
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return null;
+ result[lang] = parsed as Record;
+ } catch {
+ return null;
+ }
+ }
+ return result;
+ }, [lspConfigStrings]);
+
+
const customEditors = useMemo(() => {
const items = editors.filter(isCustomEditor);
return items.slice().sort((a, b) => {
@@ -350,17 +463,32 @@ export function EditorsSettings() {
}));
}, [availableEditors, defaultEditorId]);
- const isDirty = defaultEditorId !== baselineDefaultId;
+ const lspAutoStartDirty =
+ lspAutoStartLanguages.length !== baselineLspAutoStart.length ||
+ lspAutoStartLanguages.some((id) => !baselineLspAutoStart.includes(id));
+ const lspAutoInstallDirty =
+ lspAutoInstallLanguages.length !== baselineLspAutoInstall.length ||
+ lspAutoInstallLanguages.some((id) => !baselineLspAutoInstall.includes(id));
+ const lspConfigsDirty = JSON.stringify(lspConfigStrings) !== JSON.stringify(baselineLspConfigStrings);
+ const isDirty = defaultEditorId !== baselineDefaultId || lspAutoStartDirty || lspAutoInstallDirty || lspConfigsDirty;
const saveDefaultRequest = useRequest(async () => {
- const payload = {
+ const parsedConfigs = parseLspConfigs();
+ if (parsedConfigs === null) return; // Invalid JSON, don't save
+ const payload: Parameters[0] = {
workspace_id: currentUserSettings.workspaceId ?? '',
board_id: currentUserSettings.boardId ?? '',
repository_ids: currentUserSettings.repositoryIds ?? [],
default_editor_id: defaultEditorId || undefined,
+ lsp_auto_start_languages: lspAutoStartLanguages,
+ lsp_auto_install_languages: lspAutoInstallLanguages,
+ lsp_server_configs: parsedConfigs,
};
const response = await updateUserSettings(payload, { cache: 'no-store' });
setBaselineDefaultId(defaultEditorId);
+ setBaselineLspAutoStart([...lspAutoStartLanguages]);
+ setBaselineLspAutoInstall([...lspAutoInstallLanguages]);
+ setBaselineLspConfigStrings({ ...lspConfigStrings });
if (response?.settings) {
setUserSettings({
workspaceId: response.settings.workspace_id || null,
@@ -372,6 +500,9 @@ export function EditorsSettings() {
enablePreviewOnClick: response.settings.enable_preview_on_click ?? false,
chatSubmitKey: response.settings.chat_submit_key ?? 'cmd_enter',
reviewAutoMarkOnScroll: response.settings.review_auto_mark_on_scroll ?? true,
+ lspAutoStartLanguages: response.settings.lsp_auto_start_languages ?? [],
+ lspAutoInstallLanguages: response.settings.lsp_auto_install_languages ?? [],
+ lspServerConfigs: response.settings.lsp_server_configs ?? {},
loaded: true,
});
}
@@ -426,17 +557,141 @@ export function EditorsSettings() {
return (
{
void saveDefaultRequest.run();
}}
>
-
-
-
Default editor
-
+
+ {/* ── File Editor ────────────────────────────────────── */}
+
+
+ File Editor
+
+
+
+
+
Language Servers
+
+ Auto-start language servers when opening files to get diagnostics, hover info, and go-to-definition. You can also toggle each server on/off per file.
+
+ When enabled, install your project's dependencies (e.g. npm install via repository setup scripts) to avoid missing type errors.
+
+
+
+ {LSP_LANGUAGE_OPTIONS.map((lang) => (
+
+
+
{lang.label}
+
{lang.binary}
+
+
+ Auto-start
+ toggleAutoStart(lang.id, checked === true)}
+ />
+
+
+ toggleAutoInstall(lang.id, checked === true)}
+ className="h-3.5 w-3.5"
+ />
+
+
+
+
+
+
+ {lang.installHint}
+
+
+
+
+ ))}
+
+
+
+
+
+
Server Configuration
+
+ Override settings sent to each language server via workspace/configuration. JSON format.
+
+
+ {LSP_LANGUAGE_OPTIONS.map((lang) => {
+ const isExpanded = expandedConfigLang === lang.id;
+ const configStr = lspConfigStrings[lang.id] ?? '';
+ const defaultConfig = LSP_DEFAULT_CONFIGS[lang.id];
+ const hasDefaults = defaultConfig && Object.keys(defaultConfig).length > 0;
+ const error = lspConfigErrors[lang.id];
+ return (
+
+
+ {isExpanded && (
+
+ )}
+
+ );
+ })}
+
+
+
+
+
+ {/* ── External Editors ───────────────────────────────── */}
+
+
+ External Editors
+
+
+
-
-
-
-
-
Custom editors
-
-
-
- {isAdding && (
-
setIsAdding(false)}
- onSave={(state) => {
- void createRequest.run(state);
- }}
- submitLabel="Add editor"
- isSaving={createRequest.isLoading}
- />
- )}
- {customEditors.length === 0 && !isAdding && (
-
- No custom editors yet.
-
- )}
- {customEditors.map((editor) => {
- return (
-
setEditingId(editor.id)}
- onClose={() => setEditingId(null)}
- renderEdit={({ close }) => (
- {
- void updateRequest.run(editor.id, state).then(() => {
- close();
- });
- }}
- submitLabel="Save changes"
- isSaving={updateRequest.isLoading}
- />
- )}
- renderPreview={({ open }) => (
-
-
-
- {editor.name}
-
-
- {getCustomEditorSummary(editor)}
-
-
-
-
-
-
-
- )}
- />
- );
- })}
-
-
+
+
Custom Editors
+
+
- {builtInEditors.length === 0 ? null : (
-
-
- Supported Editors
-
-
- {builtInEditors.map((editor) => (
-
-
{editor.name}
-
- {editor.installed ? 'Installed' : 'Not installed'}
-
+ {isAdding && (
+
setIsAdding(false)}
+ onSave={(state) => {
+ void createRequest.run(state);
+ }}
+ submitLabel="Add editor"
+ isSaving={createRequest.isLoading}
+ />
+ )}
+
+
+ {customEditors.length === 0 && !isAdding && (
+
+ No custom editors yet.
- ))}
+ )}
+ {customEditors.map((editor) => {
+ return (
+
setEditingId(editor.id)}
+ onClose={() => setEditingId(null)}
+ renderEdit={({ close }) => (
+ {
+ void updateRequest.run(editor.id, state).then(() => {
+ close();
+ });
+ }}
+ submitLabel="Save changes"
+ isSaving={updateRequest.isLoading}
+ />
+ )}
+ renderPreview={({ open }) => (
+
+
+
+ {editor.name}
+
+
+ {getCustomEditorSummary(editor)}
+
+
+
+
+
+
+
+ )}
+ />
+ );
+ })}
- )}
+
+ {builtInEditors.length > 0 && (
+
+
Supported Editors
+
+ {builtInEditors.map((editor) => (
+
+ {editor.name}
+
+ {editor.installed ? 'Installed' : 'Not installed'}
+
+
+ ))}
+
+
+ )}
+
);
diff --git a/apps/web/components/task/chat/messages/code-block.tsx b/apps/web/components/task/chat/messages/code-block.tsx
index 0e97022304..8d08d12650 100644
--- a/apps/web/components/task/chat/messages/code-block.tsx
+++ b/apps/web/components/task/chat/messages/code-block.tsx
@@ -1,80 +1,17 @@
'use client';
-import { useTheme } from 'next-themes';
-import CodeMirror from '@uiw/react-codemirror';
-import { vscodeDark, vscodeLight } from '@uiw/codemirror-theme-vscode';
-import { EditorView } from '@codemirror/view';
-import { IconCheck, IconCopy } from '@tabler/icons-react';
-import { cn } from '@/lib/utils';
-import { getCodeMirrorExtension } from '@/lib/languages';
-import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard';
+import { useEditorProvider } from '@/hooks/use-editor-resolver';
+import { MonacoCodeBlock } from '@/components/editors/monaco/monaco-code-block';
+import { CodeMirrorCodeBlock } from '@/components/editors/codemirror/codemirror-code-block';
type CodeBlockProps = {
children: React.ReactNode;
className?: string;
};
-const getLanguageExtension = (language?: string) => {
- if (!language) return undefined;
- const lang = language.replace('language-', '').toLowerCase();
- return getCodeMirrorExtension(lang);
-};
-
-export function CodeBlock({ children, className }: CodeBlockProps) {
- const { copied, copy } = useCopyToClipboard();
- const { theme, systemTheme } = useTheme();
- const effectiveTheme = theme === 'system' ? systemTheme : theme;
-
- const code = String(children).replace(/\n$/, '');
- const languageExtension = getLanguageExtension(className);
-
- // Custom padding theme
- const paddingTheme = EditorView.theme({
- '&': {
- padding: '2px 2px',
- },
- });
-
- const handleCopy = async () => {
- await copy(code);
- };
-
- return (
-
- {/* Copy button */}
-
-
- {/* Code editor */}
-
-
- );
+export function CodeBlock(props: CodeBlockProps) {
+ const provider = useEditorProvider('chat-code-block');
+ return provider === 'monaco'
+ ?
+ :
;
}
diff --git a/apps/web/components/task/chat/messages/diff-view-block.tsx b/apps/web/components/task/chat/messages/diff-view-block.tsx
index ef2485f25f..5652caeee4 100644
--- a/apps/web/components/task/chat/messages/diff-view-block.tsx
+++ b/apps/web/components/task/chat/messages/diff-view-block.tsx
@@ -1,17 +1,23 @@
'use client';
import { cn } from '@/lib/utils';
+import { useEditorProvider } from '@/hooks/use-editor-resolver';
+import { MonacoInlineDiff } from '@/components/editors/monaco/monaco-inline-diff';
import { DiffViewInline } from '@/components/diff';
import type { FileDiffData } from '@/lib/diff/types';
type DiffViewBlockProps = {
- /** Diff data in the new format */
data: FileDiffData;
- /** Additional class name */
className?: string;
};
export function DiffViewBlock({ data, className }: DiffViewBlockProps) {
+ const provider = useEditorProvider('chat-diff');
+
+ if (provider === 'monaco') {
+ return
;
+ }
+
return (
diff --git a/apps/web/components/task/dockview-desktop-layout.tsx b/apps/web/components/task/dockview-desktop-layout.tsx
index e439860930..456a01804b 100644
--- a/apps/web/components/task/dockview-desktop-layout.tsx
+++ b/apps/web/components/task/dockview-desktop-layout.tsx
@@ -42,6 +42,7 @@ import { useFileEditors } from '@/hooks/use-file-editors';
import { startProcess } from '@/lib/api';
import { createUserShell } from '@/lib/api/domains/user-shell-api';
import { useRepositoryScripts } from '@/hooks/domains/workspace/use-repository-scripts';
+import { useLspFileOpener } from '@/hooks/use-lsp-file-opener';
// Panel components
import { TaskSessionSidebar, NewTaskButton } from './task-session-sidebar';
@@ -500,6 +501,9 @@ export const DockviewDesktopLayout = memo(function DockviewDesktopLayout({
const effectiveSessionId = useAppStore((state) => state.tasks.activeSessionId) ?? sessionId ?? null;
const hasDevScript = Boolean(repository?.dev_script?.trim());
+ // Connect LSP Go-to-Definition navigation to dockview file tabs
+ useLspFileOpener();
+
// Keep sessionIdRef in sync for use inside event handlers
useEffect(() => {
sessionIdRef.current = effectiveSessionId;
diff --git a/apps/web/components/task/file-editor-content.tsx b/apps/web/components/task/file-editor-content.tsx
index 715171764d..054c3b40ac 100644
--- a/apps/web/components/task/file-editor-content.tsx
+++ b/apps/web/components/task/file-editor-content.tsx
@@ -1,22 +1,8 @@
'use client';
-import { useCallback, useEffect, useMemo, useState, useRef } from 'react';
-import CodeMirror, { type ReactCodeMirrorRef } from '@uiw/react-codemirror';
-import { EditorView, gutter, GutterMarker, ViewPlugin, type ViewUpdate } from '@codemirror/view';
-import type { Extension } from '@codemirror/state';
-import { Decoration, type DecorationSet } from '@codemirror/view';
-import { getCodeMirrorExtensionFromPath } from '@/lib/languages';
-import { Button } from '@kandev/ui/button';
-import { IconDeviceFloppy, IconLoader2, IconTrash, IconTextWrap, IconTextWrapDisabled, IconMessagePlus } from '@tabler/icons-react';
-import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip';
-import { formatDiffStats } from '@/lib/utils/file-diff';
-import { toRelativePath } from '@/lib/utils';
-import { vscodeDark } from '@uiw/codemirror-theme-vscode';
-import { useDiffCommentsStore, useFileComments } from '@/lib/state/slices/diff-comments/diff-comments-slice';
-import type { DiffComment } from '@/lib/diff/types';
-import { useToast } from '@/components/toast-provider';
-import { EditorCommentPopover } from './editor-comment-popover';
-import { CommentViewPopover } from './comment-view-popover';
+import { useEditorProvider } from '@/hooks/use-editor-resolver';
+import { MonacoCodeEditor } from '@/components/editors/monaco/monaco-code-editor';
+import { CodeMirrorCodeEditor } from '@/components/editors/codemirror/codemirror-code-editor';
type FileEditorContentProps = {
path: string;
@@ -32,618 +18,9 @@ type FileEditorContentProps = {
onDelete?: () => void;
};
-type TextSelection = {
- text: string;
- startLine: number;
- endLine: number;
- position: { x: number; y: number };
-} | null;
-
-type FloatingButtonPosition = {
- x: number;
- y: number;
-} | null;
-
-type CommentViewState = {
- comments: DiffComment[];
- position: { x: number; y: number };
-} | null;
-
-// Create comment line decorations
-function createCommentDecorations(
- view: EditorView,
- comments: DiffComment[]
-): DecorationSet {
- if (comments.length === 0) {
- return Decoration.none;
- }
-
- const decorations: Array<{ from: number; decoration: Decoration }> = [];
-
- // Get all lines that have comments
- const linesWithComments = new Set
();
- for (const comment of comments) {
- for (let line = comment.startLine; line <= comment.endLine; line++) {
- linesWithComments.add(line);
- }
- }
-
- // Create line decorations for commented lines
- for (const lineNum of linesWithComments) {
- if (lineNum > view.state.doc.lines) continue;
- const line = view.state.doc.line(lineNum);
- decorations.push({
- from: line.from,
- decoration: Decoration.line({
- class: 'cm-comment-line',
- }),
- });
- }
-
- decorations.sort((a, b) => a.from - b.from);
- return Decoration.set(decorations.map(d => d.decoration.range(d.from)));
-}
-
-// Gutter marker for lines with comments - defined outside component to avoid re-creation
-class CommentGutterMarker extends GutterMarker {
- constructor(
- readonly lineComments: DiffComment[],
- readonly isFirstLine: boolean
- ) {
- super();
- }
-
- toDOM() {
- const marker = document.createElement('div');
- marker.className = 'cm-comment-gutter-marker';
- marker.title = `${this.lineComments.length} comment${this.lineComments.length > 1 ? 's' : ''} - click to view`;
-
- // Add icon only on the first line of a comment
- if (this.isFirstLine) {
- const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
- svg.setAttribute('width', '12');
- svg.setAttribute('height', '12');
- svg.setAttribute('viewBox', '0 0 24 24');
- svg.setAttribute('fill', 'none');
- svg.setAttribute('stroke', 'rgba(99, 102, 241, 0.9)');
- svg.setAttribute('stroke-width', '2');
- svg.setAttribute('stroke-linecap', 'round');
- svg.setAttribute('stroke-linejoin', 'round');
-
- const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
- path.setAttribute('d', 'M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z');
- svg.appendChild(path);
- marker.appendChild(svg);
- }
-
- return marker;
- }
-
- eq(other: CommentGutterMarker) {
- return this.lineComments.length === other.lineComments.length && this.isFirstLine === other.isFirstLine;
- }
-}
-
-export function FileEditorContent({
- path,
- content,
- originalContent,
- isDirty,
- isSaving,
- sessionId,
- worktreePath,
- enableComments = false,
- onChange,
- onSave,
- onDelete,
-}: FileEditorContentProps) {
- const [wrapEnabled, setWrapEnabled] = useState(true);
- const [textSelection, setTextSelection] = useState(null);
- const [floatingButtonPos, setFloatingButtonPos] = useState(null);
- const [currentSelection, setCurrentSelection] = useState<{ text: string; startLine: number; endLine: number } | null>(null);
- const [commentView, setCommentView] = useState(null);
- const editorRef = useRef(null);
- const wrapperRef = useRef(null);
- const mousePositionRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
- const { toast } = useToast();
-
- // Comment store
- const addComment = useDiffCommentsStore((state) => state.addComment);
- const removeComment = useDiffCommentsStore((state) => state.removeComment);
- const comments = useFileComments(sessionId ?? '', path);
-
- const langExt = getCodeMirrorExtensionFromPath(path);
-
- // Create the comment decoration plugin
- const commentPlugin = useMemo(() => {
- return ViewPlugin.fromClass(
- class {
- decorations: DecorationSet;
-
- constructor(view: EditorView) {
- this.decorations = createCommentDecorations(view, comments);
- }
-
- update(update: ViewUpdate) {
- if (update.docChanged || update.viewportChanged) {
- this.decorations = createCommentDecorations(update.view, comments);
- }
- }
- },
- { decorations: (v) => v.decorations }
- );
- }, [comments]);
-
- // Create comment gutter
- const commentGutter = useMemo(() => {
- // Build a map of line number to comments that include that line
- const commentsByLine = new Map();
- // Track which lines are the first line of at least one comment
- const firstLines = new Set();
-
- for (const comment of comments) {
- firstLines.add(comment.startLine);
- // Add to all lines in the range
- for (let lineNum = comment.startLine; lineNum <= comment.endLine; lineNum++) {
- const existing = commentsByLine.get(lineNum) || [];
- existing.push(comment);
- commentsByLine.set(lineNum, existing);
- }
- }
-
- return gutter({
- class: 'cm-comment-gutter',
- lineMarker: (view, line) => {
- const lineNum = view.state.doc.lineAt(line.from).number;
- const lineComments = commentsByLine.get(lineNum);
- if (lineComments && lineComments.length > 0) {
- return new CommentGutterMarker(lineComments, firstLines.has(lineNum));
- }
- return null;
- },
- domEventHandlers: {
- click: (view, line, event) => {
- const lineNum = view.state.doc.lineAt(line.from).number;
- const lineComments = commentsByLine.get(lineNum);
- if (lineComments && lineComments.length > 0) {
- event.preventDefault();
- event.stopPropagation();
- setCommentView({
- comments: lineComments,
- position: { x: (event as MouseEvent).clientX, y: (event as MouseEvent).clientY },
- });
- return true;
- }
- return false;
- },
- },
- });
- }, [comments]);
-
- // Track mouse position
- useEffect(() => {
- const handleMouseMove = (e: MouseEvent) => {
- mousePositionRef.current = { x: e.clientX, y: e.clientY };
- };
- document.addEventListener('mousemove', handleMouseMove);
- return () => document.removeEventListener('mousemove', handleMouseMove);
- }, []);
-
- // Show floating button after selection ends (on mouseup)
- const handleSelectionEnd = useCallback(() => {
- if (!enableComments || !sessionId) return;
-
- const view = editorRef.current?.view;
- if (!view) return;
-
- const selection = view.state.selection.main;
- if (selection.empty) {
- setFloatingButtonPos(null);
- setCurrentSelection(null);
- return;
- }
-
- const selectedText = view.state.sliceDoc(selection.from, selection.to);
- if (!selectedText.trim()) {
- setFloatingButtonPos(null);
- setCurrentSelection(null);
- return;
- }
-
- // Get line numbers
- const startLine = view.state.doc.lineAt(selection.from).number;
- const endLine = view.state.doc.lineAt(selection.to).number;
-
- // Use mouse position for the floating button (where cursor is)
- setCurrentSelection({ text: selectedText, startLine, endLine });
- setFloatingButtonPos({ x: mousePositionRef.current.x, y: mousePositionRef.current.y });
- }, [enableComments, sessionId]);
-
- // Listen for mouseup to show floating button after selection ends
- useEffect(() => {
- const wrapper = wrapperRef.current;
- if (!wrapper || !enableComments || !sessionId) return;
-
- const handleMouseUp = (e: MouseEvent) => {
- // Don't show if clicking on the floating button itself
- if ((e.target as HTMLElement).closest('.floating-comment-btn')) {
- return;
- }
- // Small delay to let selection finalize
- setTimeout(handleSelectionEnd, 10);
- };
-
- // Clear selection state when clicking (new selection starts)
- const handleMouseDown = (e: MouseEvent) => {
- // Don't clear if clicking on the floating button
- if ((e.target as HTMLElement).closest('.floating-comment-btn')) {
- return;
- }
- setFloatingButtonPos(null);
- };
-
- wrapper.addEventListener('mouseup', handleMouseUp);
- wrapper.addEventListener('mousedown', handleMouseDown);
- return () => {
- wrapper.removeEventListener('mouseup', handleMouseUp);
- wrapper.removeEventListener('mousedown', handleMouseDown);
- };
- }, [enableComments, sessionId, handleSelectionEnd]);
-
- // Clear floating button when selection is cleared via keyboard
- const selectionUpdateExtension = useMemo(() => {
- return EditorView.updateListener.of((update) => {
- if (update.selectionSet) {
- const selection = update.state.selection.main;
- if (selection.empty) {
- setFloatingButtonPos(null);
- setCurrentSelection(null);
- }
- }
- });
- }, []);
-
- const extensions: Extension[] = useMemo(() => {
- const exts: Extension[] = [
- EditorView.editable.of(true),
- EditorView.theme({
- '&': { backgroundColor: 'hsl(var(--background)) !important' },
- '.cm-gutters': { backgroundColor: 'hsl(var(--background)) !important', borderRight: 'none' },
- // Comment gutter
- '.cm-comment-gutter': {
- width: '22px',
- cursor: 'pointer',
- },
- '.cm-comment-gutter .cm-gutterElement': {
- display: 'flex',
- alignItems: 'center',
- justifyContent: 'center',
- padding: '0',
- },
- // Marker styles - background color on the marker itself (indigo)
- '.cm-comment-gutter-marker': {
- display: 'flex',
- alignItems: 'center',
- justifyContent: 'center',
- width: '100%',
- height: '100%',
- cursor: 'pointer',
- backgroundColor: 'rgba(99, 102, 241, 0.2)',
- },
- '.cm-comment-gutter-marker:hover': {
- backgroundColor: 'rgba(99, 102, 241, 0.35)',
- },
- // Comment line highlight with left border (indigo)
- '.cm-comment-line': {
- backgroundColor: 'rgba(99, 102, 241, 0.15) !important',
- borderLeft: '3px solid rgba(99, 102, 241, 0.6)',
- marginLeft: '-3px',
- paddingLeft: '3px',
- },
- }),
- commentGutter,
- commentPlugin,
- selectionUpdateExtension,
- ];
- if (wrapEnabled) {
- exts.push(EditorView.lineWrapping);
- }
- if (langExt) {
- exts.push(langExt);
- }
- return exts;
- }, [langExt, wrapEnabled, commentGutter, commentPlugin, selectionUpdateExtension]);
-
- // Calculate diff stats when dirty
- const diffStats = useMemo(() => {
- if (!isDirty) return null;
-
- const originalLines = originalContent.split('\n');
- const currentLines = content.split('\n');
-
- let additions = 0;
- let deletions = 0;
-
- const maxLen = Math.max(originalLines.length, currentLines.length);
- for (let i = 0; i < maxLen; i++) {
- const origLine = originalLines[i];
- const currLine = currentLines[i];
-
- if (origLine === undefined && currLine !== undefined) {
- additions++;
- } else if (origLine !== undefined && currLine === undefined) {
- deletions++;
- } else if (origLine !== currLine) {
- additions++;
- deletions++;
- }
- }
-
- return { additions, deletions };
- }, [isDirty, content, originalContent]);
-
- // Handle Cmd+I to open comment popover
- useEffect(() => {
- const wrapper = wrapperRef.current;
- if (!wrapper || !enableComments || !sessionId) return;
-
- const handleKeyDown = (e: KeyboardEvent) => {
- // Check for Cmd+I (Mac) or Ctrl+I (Windows/Linux)
- if ((e.metaKey || e.ctrlKey) && e.key === 'i') {
- if (!currentSelection || !floatingButtonPos) return;
-
- e.preventDefault();
- e.stopPropagation();
-
- setTextSelection({
- ...currentSelection,
- position: floatingButtonPos,
- });
- setFloatingButtonPos(null);
- }
- };
-
- wrapper.addEventListener('keydown', handleKeyDown, true);
- return () => wrapper.removeEventListener('keydown', handleKeyDown, true);
- }, [enableComments, sessionId, currentSelection, floatingButtonPos]);
-
- // Handle Cmd/Ctrl+S keyboard shortcut and Escape
- useEffect(() => {
- const handleKeyDown = (e: KeyboardEvent) => {
- if ((e.metaKey || e.ctrlKey) && e.key === 's') {
- e.preventDefault();
- if (isDirty && !isSaving) {
- onSave();
- }
- }
- // Escape to close popovers
- if (e.key === 'Escape') {
- if (textSelection) setTextSelection(null);
- if (commentView) setCommentView(null);
- }
- };
-
- window.addEventListener('keydown', handleKeyDown);
- return () => window.removeEventListener('keydown', handleKeyDown);
- }, [isDirty, isSaving, onSave, textSelection, commentView]);
-
- const handleChange = useCallback(
- (value: string) => {
- onChange(value);
- },
- [onChange]
- );
-
- // Open comment popover from floating button
- const handleFloatingButtonClick = useCallback((e: React.MouseEvent) => {
- e.preventDefault();
- e.stopPropagation();
-
- if (!currentSelection || !floatingButtonPos) return;
-
- setTextSelection({
- ...currentSelection,
- position: floatingButtonPos,
- });
- setFloatingButtonPos(null);
- }, [currentSelection, floatingButtonPos]);
-
- // Submit comment
- const handleCommentSubmit = useCallback((annotation: string) => {
- if (!textSelection || !sessionId) return;
-
- const { text, startLine, endLine } = textSelection;
-
- const comment: DiffComment = {
- id: `${path}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
- sessionId,
- filePath: path,
- startLine,
- endLine,
- side: 'additions',
- codeContent: text,
- annotation,
- createdAt: new Date().toISOString(),
- status: 'pending',
- };
-
- addComment(comment);
- setTextSelection(null);
-
- // Clear selection in editor
- const view = editorRef.current?.view;
- if (view) {
- view.dispatch({
- selection: { anchor: view.state.selection.main.head },
- });
- }
-
- toast({
- title: 'Comment added',
- description: 'Your comment will be sent with your next message.',
- });
- }, [textSelection, sessionId, path, addComment, toast]);
-
- // Close popover
- const handlePopoverClose = useCallback(() => {
- setTextSelection(null);
- }, []);
-
- // Delete comment
- const handleDeleteComment = useCallback((commentId: string) => {
- if (!sessionId) return;
- removeComment(sessionId, path, commentId);
-
- // Close the view if no more comments
- if (commentView && commentView.comments.length <= 1) {
- setCommentView(null);
- } else if (commentView) {
- setCommentView({
- ...commentView,
- comments: commentView.comments.filter(c => c.id !== commentId),
- });
- }
-
- toast({
- title: 'Comment deleted',
- });
- }, [sessionId, path, removeComment, commentView, toast]);
-
- // Close comment view
- const handleCommentViewClose = useCallback(() => {
- setCommentView(null);
- }, []);
-
- return (
-
- {/* Editor header with save button */}
-
-
- {toRelativePath(path, worktreePath)}
- {isDirty && diffStats && (
-
- {formatDiffStats(diffStats.additions, diffStats.deletions)}
-
- )}
-
-
- {enableComments && sessionId && comments.length > 0 && (
-
-
- {comments.length} comment{comments.length > 1 ? 's' : ''}
-
- )}
-
-
-
-
- {wrapEnabled ? 'Disable word wrap' : 'Enable word wrap'}
-
- {onDelete && (
-
-
-
-
- Delete file
-
- )}
-
-
-
-
- {/* CodeMirror editor */}
-
-
-
- {/* Floating "Add comment" button when text is selected */}
- {floatingButtonPos && !textSelection && (
-
- )}
-
- {/* Comment popover for adding new comment */}
- {textSelection && (
-
- )}
-
- {/* Comment view popover for viewing/deleting existing comments */}
- {commentView && (
-
- )}
-
-
- );
+export function FileEditorContent(props: FileEditorContentProps) {
+ const provider = useEditorProvider('code-editor');
+ return provider === 'monaco'
+ ?
+ : ;
}
diff --git a/apps/web/components/task/markdown-editor.tsx b/apps/web/components/task/markdown-editor.tsx
deleted file mode 100644
index c8e386b1e0..0000000000
--- a/apps/web/components/task/markdown-editor.tsx
+++ /dev/null
@@ -1,605 +0,0 @@
-'use client';
-
-import { useEffect, useRef, useState, useCallback } from 'react';
-import { useTheme } from 'next-themes';
-import { Editor, rootCtx, defaultValueCtx, editorViewCtx } from '@milkdown/core';
-import { nord } from '@milkdown/theme-nord';
-import { commonmark, codeBlockSchema } from '@milkdown/preset-commonmark';
-import { gfm } from '@milkdown/preset-gfm';
-import { listener, listenerCtx } from '@milkdown/plugin-listener';
-import { Milkdown, MilkdownProvider, useEditor } from '@milkdown/react';
-import { Plugin, PluginKey } from '@milkdown/prose/state';
-import { Decoration, DecorationSet } from '@milkdown/prose/view';
-import { $prose, $view } from '@milkdown/utils';
-import type { Node as ProseMirrorNode } from '@milkdown/prose/model';
-import mermaid from 'mermaid';
-
-export type TextSelection = {
- text: string;
- position: { x: number; y: number };
-};
-
-export type CommentHighlight = {
- id: string;
- selectedText: string;
- comment: string;
-};
-
-type MilkdownEditorProps = {
- value: string;
- onChange: (value: string) => void;
- placeholder?: string;
- onSelectionChange?: (selection: TextSelection | null) => void;
- /** Called when selection ends (mouseup) with selected text - for showing floating button */
- onSelectionEnd?: (selection: TextSelection | null) => void;
- comments?: CommentHighlight[];
- onCommentClick?: (id: string, position: { x: number; y: number }) => void;
-};
-
-// Minimum text length required for comment highlighting
-const MIN_COMMENT_TEXT_LENGTH = 3;
-
-// Initialize mermaid with default config
-let mermaidInitialized = false;
-function initMermaid(theme: 'dark' | 'light' = 'dark') {
- mermaid.initialize({
- startOnLoad: false,
- theme: theme === 'dark' ? 'dark' : 'default',
- securityLevel: 'loose',
- });
- mermaidInitialized = true;
-}
-
-// Unique ID counter for mermaid diagrams
-let mermaidIdCounter = 0;
-
-// Create mermaid node view for code blocks using $view
-const mermaidCodeBlockView = $view(codeBlockSchema.node, () => {
- return (node: ProseMirrorNode) => {
- const language = node.attrs.language as string;
-
- // If not mermaid, return null to use default rendering
- if (language !== 'mermaid') {
- // Create default code block rendering
- const pre = document.createElement('pre');
- const code = document.createElement('code');
- code.textContent = node.textContent;
- if (language) {
- code.className = `language-${language}`;
- }
- pre.appendChild(code);
- return {
- dom: pre,
- contentDOM: code,
- };
- }
-
- // Track current content for comparison in update
- let currentContent = node.textContent;
-
- // Create container for mermaid diagram
- const dom = document.createElement('div');
- dom.className = 'mermaid-container';
-
- // Initialize mermaid if not already done
- if (!mermaidInitialized) {
- initMermaid();
- }
-
- // Render the mermaid diagram
- const id = `mermaid-${++mermaidIdCounter}`;
- mermaid.render(id, currentContent).then(({ svg }: { svg: string }) => {
- dom.innerHTML = svg;
- }).catch((err: Error) => {
- dom.innerHTML = `Error rendering diagram: ${err.message}`;
- });
-
- return {
- dom,
- contentDOM: null,
- update: (updatedNode: ProseMirrorNode) => {
- if (updatedNode.type.name !== 'code_block') return false;
- const updatedLang = updatedNode.attrs.language as string;
- if (updatedLang !== 'mermaid') return false;
- const newContent = updatedNode.textContent;
- if (newContent !== currentContent) {
- currentContent = newContent; // Update tracked content
- const newId = `mermaid-${++mermaidIdCounter}`;
- mermaid.render(newId, newContent).then(({ svg }: { svg: string }) => {
- dom.innerHTML = svg;
- }).catch((err: Error) => {
- dom.innerHTML = `Error rendering diagram: ${err.message}`;
- });
- }
- return true;
- },
- };
- };
-});
-
-// Plugin key for managing comment highlights
-const commentHighlightPluginKey = new PluginKey<{
- deco: DecorationSet;
- comments: CommentHighlight[];
-}>('comment-highlight');
-
-// Plugin key for placeholder
-const placeholderPluginKey = new PluginKey('placeholder');
-
-// Create placeholder plugin that shows text when editor is empty
-function createPlaceholderPlugin(placeholder: string) {
- return $prose(() => {
- return new Plugin({
- key: placeholderPluginKey,
- props: {
- decorations(state) {
- const doc = state.doc;
- // Check if document is empty (only has one empty paragraph)
- if (doc.childCount === 1 && doc.firstChild?.type.name === 'paragraph' && doc.firstChild.content.size === 0) {
- return DecorationSet.create(doc, [
- Decoration.widget(1, () => {
- const span = document.createElement('span');
- span.className = 'editor-placeholder';
- span.textContent = placeholder;
- return span;
- }, { side: 0 }),
- ]);
- }
- return DecorationSet.empty;
- },
- },
- });
- });
-}
-
-// Helper to get plain text content and position mapping from a document range
-function getTextWithPositions(doc: Parameters[0]): { text: string; positions: number[] } {
- let text = '';
- const positions: number[] = []; // Maps text index to document position
-
- doc.descendants((node, pos) => {
- if (node.isText && node.text) {
- for (let i = 0; i < node.text.length; i++) {
- positions.push(pos + i);
- text += node.text[i];
- }
- } else if (node.type.name === 'hardbreak') {
- // Handle hardbreak as a space/newline
- positions.push(-1);
- text += ' ';
- } else if (node.isBlock && text.length > 0 && !text.endsWith('\n')) {
- // Add newline between blocks
- positions.push(-1); // -1 indicates a synthetic newline
- text += '\n';
- }
- });
-
- return { text, positions };
-}
-
-// Normalize text for comparison (collapse whitespace, normalize newlines)
-function normalizeForSearch(text: string): string {
- return text
- .replace(/\r\n/g, '\n')
- .replace(/[\t ]+/g, ' ')
- .replace(/\n+/g, '\n')
- .trim()
- .toLowerCase();
-}
-
-// Create decorations for comment highlights
-function createCommentDecorations(
- doc: Parameters[0],
- comments: CommentHighlight[]
-): DecorationSet {
- const decorations: Decoration[] = [];
-
- if (comments.length === 0) {
- return DecorationSet.empty;
- }
-
- // Get the full text content with position mapping
- const { text: fullText, positions } = getTextWithPositions(doc);
- const normalizedFullText = normalizeForSearch(fullText);
-
- comments.forEach((comment) => {
- const searchText = normalizeForSearch(comment.selectedText);
-
- if (!searchText || searchText.length < MIN_COMMENT_TEXT_LENGTH) {
- return;
- }
-
- // Find the text in the normalized full document text
- const normalizedIndex = normalizedFullText.indexOf(searchText);
-
- if (normalizedIndex === -1) {
- // Try with just the first line as fallback
- const firstLine = normalizeForSearch(comment.selectedText.split('\n')[0]);
- if (firstLine.length >= MIN_COMMENT_TEXT_LENGTH) {
- const fallbackIndex = normalizedFullText.indexOf(firstLine);
- if (fallbackIndex !== -1) {
- createDecorationForRange(fallbackIndex, firstLine.length, comment, fullText, positions, decorations);
- }
- }
- return;
- }
-
- createDecorationForRange(normalizedIndex, searchText.length, comment, fullText, positions, decorations);
- });
-
- return DecorationSet.create(doc, decorations);
-}
-
-// Helper to create decoration(s) for a text range, handling multi-line selections
-function createDecorationForRange(
- normalizedIndex: number,
- length: number,
- comment: CommentHighlight,
- fullText: string,
- positions: number[],
- decorations: Decoration[]
-) {
- // Map normalized index back to original text index
- const normalizedFullText = normalizeForSearch(fullText);
-
- const normalizedChars = normalizedFullText.split('');
- const origChars = fullText.split('');
-
- // Map from normalized position to original position
- const normToOrig: number[] = [];
- let oi = 0;
- for (let ni = 0; ni < normalizedChars.length; ni++) {
- // Skip whitespace differences in original
- while (oi < origChars.length &&
- origChars[oi].toLowerCase() !== normalizedChars[ni] &&
- /\s/.test(origChars[oi])) {
- oi++;
- }
- normToOrig[ni] = oi;
- oi++;
- }
-
- const startOrig = normToOrig[normalizedIndex] ?? 0;
- const endOrig = normToOrig[normalizedIndex + length - 1] ?? startOrig;
-
- // Now create decorations for each contiguous range (split by newlines/block boundaries)
- let rangeStart = -1;
-
- for (let i = startOrig; i <= endOrig && i < positions.length; i++) {
- const pos = positions[i];
-
- if (pos === -1) {
- // Hit a block boundary - close current range if open
- if (rangeStart !== -1) {
- const fromPos = positions[rangeStart];
- const toPos = positions[i - 1] + 1;
- if (fromPos >= 0 && toPos > fromPos) {
- decorations.push(Decoration.inline(fromPos, toPos, {
- class: 'comment-highlight',
- 'data-comment-id': comment.id,
- title: comment.comment,
- }));
- }
- rangeStart = -1;
- }
- } else {
- // Valid position - start range if not started
- if (rangeStart === -1) {
- rangeStart = i;
- }
- }
- }
-
- // Close final range
- if (rangeStart !== -1 && rangeStart < positions.length) {
- const fromPos = positions[rangeStart];
- const lastValidIndex = Math.min(endOrig, positions.length - 1);
- const toPos = positions[lastValidIndex] + 1;
- if (fromPos >= 0 && toPos > fromPos) {
- decorations.push(Decoration.inline(fromPos, toPos, {
- class: 'comment-highlight',
- 'data-comment-id': comment.id,
- title: comment.comment,
- }));
- }
- }
-}
-
-// Create the comment highlight plugin
-function createCommentHighlightPlugin(initialComments: CommentHighlight[]) {
- return $prose(() => {
- return new Plugin({
- key: commentHighlightPluginKey,
- state: {
- init(_, state) {
- return {
- deco: createCommentDecorations(state.doc, initialComments),
- comments: initialComments,
- };
- },
- apply(tr, value, _, newState) {
- // Check if comments were updated via meta
- const newComments = tr.getMeta(commentHighlightPluginKey);
- if (newComments !== undefined) {
- return {
- deco: createCommentDecorations(newState.doc, newComments),
- comments: newComments,
- };
- }
- // If document changed, remap decorations
- if (tr.docChanged) {
- return {
- deco: createCommentDecorations(newState.doc, value.comments),
- comments: value.comments,
- };
- }
- return value;
- },
- },
- props: {
- decorations(state) {
- const pluginState = commentHighlightPluginKey.getState(state);
- return pluginState?.deco ?? DecorationSet.empty;
- },
- },
- });
- });
-}
-
-function MilkdownEditorInner({
- value,
- onChange,
- placeholder = 'Start typing...',
- onSelectionChange,
- onSelectionEnd,
- comments = [],
- onCommentClick,
-}: MilkdownEditorProps) {
- const { resolvedTheme } = useTheme();
- const wrapperRef = useRef(null);
- // Capture initial value at mount - editor manages its own state after that
- const initialValueRef = useRef(value);
- const placeholderRef = useRef(placeholder);
- const onChangeRef = useRef(onChange);
- const onSelectionChangeRef = useRef(onSelectionChange);
- const onSelectionEndRef = useRef(onSelectionEnd);
- const commentsRef = useRef(comments);
- const onCommentClickRef = useRef(onCommentClick);
-
- // Keep refs updated
- useEffect(() => {
- onChangeRef.current = onChange;
- }, [onChange]);
-
- useEffect(() => {
- onSelectionChangeRef.current = onSelectionChange;
- }, [onSelectionChange]);
-
- useEffect(() => {
- onSelectionEndRef.current = onSelectionEnd;
- }, [onSelectionEnd]);
-
- useEffect(() => {
- commentsRef.current = comments;
- }, [comments]);
-
- useEffect(() => {
- onCommentClickRef.current = onCommentClick;
- }, [onCommentClick]);
-
- // Set up click handler for comment highlights via event delegation
- useEffect(() => {
- const wrapper = wrapperRef.current;
- if (!wrapper) return;
-
- const handleClick = (e: MouseEvent) => {
- const target = e.target as HTMLElement;
- const commentHighlight = target.closest('.comment-highlight');
- if (commentHighlight && onCommentClickRef.current) {
- const commentId = commentHighlight.getAttribute('data-comment-id');
- if (commentId) {
- onCommentClickRef.current(commentId, { x: e.clientX, y: e.clientY });
- }
- }
- };
-
- wrapper.addEventListener('click', handleClick);
- return () => wrapper.removeEventListener('click', handleClick);
- }, []);
-
- // Handle mouseup to show floating button when selection ends
- useEffect(() => {
- const wrapper = wrapperRef.current;
- if (!wrapper) return;
-
- const handleMouseUp = (e: MouseEvent) => {
- if (!onSelectionEndRef.current) return;
-
- // Capture mouse position immediately
- const mouseX = e.clientX;
- const mouseY = e.clientY;
-
- // Small delay to let selection finalize
- setTimeout(() => {
- const selection = window.getSelection();
- if (!selection || selection.isCollapsed || !selection.toString().trim()) {
- onSelectionEndRef.current?.(null);
- return;
- }
-
- const selectedText = selection.toString().trim();
- if (selectedText.length < MIN_COMMENT_TEXT_LENGTH) {
- onSelectionEndRef.current?.(null);
- return;
- }
-
- // Use mouse position (where cursor is) instead of selection end
- onSelectionEndRef.current?.({
- text: selectedText,
- position: { x: mouseX, y: mouseY },
- });
- }, 10);
- };
-
- const handleMouseDown = (e: MouseEvent) => {
- // Don't clear if clicking on the floating button
- if ((e.target as HTMLElement).closest('.floating-comment-btn')) {
- return;
- }
- // Clear selection state when starting new selection
- onSelectionEndRef.current?.(null);
- };
-
- wrapper.addEventListener('mouseup', handleMouseUp);
- wrapper.addEventListener('mousedown', handleMouseDown);
- return () => {
- wrapper.removeEventListener('mouseup', handleMouseUp);
- wrapper.removeEventListener('mousedown', handleMouseDown);
- };
- }, []);
-
- // Handle Cmd+I to trigger selection popover (overrides italic formatting)
- useEffect(() => {
- const wrapper = wrapperRef.current;
- if (!wrapper) return;
-
- const handleKeyDown = (e: KeyboardEvent) => {
- // Check for Cmd+I (Mac) or Ctrl+I (Windows/Linux)
- if ((e.metaKey || e.ctrlKey) && e.key === 'i') {
- // Only handle if we have a selection callback
- if (!onSelectionChangeRef.current) return;
-
- const selection = window.getSelection();
- if (!selection) return;
-
- // Prevent Milkdown's italic formatting immediately
- e.preventDefault();
- e.stopImmediatePropagation();
-
- // Now check if we have valid selected text
- if (selection.isCollapsed || !selection.toString().trim()) {
- return;
- }
-
- const selectedText = selection.toString().trim();
- if (selectedText.length < MIN_COMMENT_TEXT_LENGTH) {
- return;
- }
-
- // Get position from the focus point (end of selection where cursor is)
- const focusNode = selection.focusNode;
- const focusOffset = selection.focusOffset;
-
- if (focusNode) {
- // Create a range at the focus point to get its position
- const focusRange = document.createRange();
- focusRange.setStart(focusNode, focusOffset);
- focusRange.setEnd(focusNode, focusOffset);
- const focusRect = focusRange.getBoundingClientRect();
-
- // If focusRect has no width (collapsed), use its position
- // Otherwise fall back to the selection's last rect
- let x = focusRect.right || focusRect.left;
- let y = focusRect.bottom || focusRect.top;
-
- // If position is 0,0 (can happen), fall back to last rect of selection
- if (x === 0 && y === 0) {
- const range = selection.getRangeAt(0);
- const rects = range.getClientRects();
- const lastRect = rects.length > 0 ? rects[rects.length - 1] : range.getBoundingClientRect();
- x = lastRect.right;
- y = lastRect.bottom;
- }
-
- onSelectionChangeRef.current({
- text: selectedText,
- position: { x, y },
- });
- }
- }
- };
-
- // Attach to wrapper element in capture phase to intercept before Milkdown
- wrapper.addEventListener('keydown', handleKeyDown, true);
- return () => wrapper.removeEventListener('keydown', handleKeyDown, true);
- }, []);
-
- // Create the highlight plugin once with initial comments
- const highlightPlugin = useCallback(
- () => createCommentHighlightPlugin(commentsRef.current),
- []
- );
-
- // Create the placeholder plugin once
- const placeholderPlugin = useCallback(
- () => createPlaceholderPlugin(placeholderRef.current),
- []
- );
-
- const { loading, get } = useEditor((root) =>
- Editor.make()
- .config((ctx) => {
- ctx.set(rootCtx, root);
- ctx.set(defaultValueCtx, initialValueRef.current);
- ctx.get(listenerCtx).markdownUpdated((_, markdown) => {
- onChangeRef.current(markdown);
- });
- })
- .config(nord)
- .use(commonmark)
- .use(gfm)
- .use(mermaidCodeBlockView)
- .use(listener)
- .use(highlightPlugin())
- .use(placeholderPlugin())
- );
-
- // Update decorations when comments change
- useEffect(() => {
- const editor = get();
- if (!editor || loading) return;
-
- try {
- // Get the editor view and dispatch a transaction to update comments
- editor.action((ctx) => {
- try {
- const view = ctx.get(editorViewCtx);
- if (view) {
- const tr = view.state.tr.setMeta(commentHighlightPluginKey, comments);
- view.dispatch(tr);
- }
- } catch {
- // editorViewCtx not available yet
- }
- });
- } catch {
- // Editor might not be ready
- }
- }, [comments, get, loading]);
-
- return (
-
-
- {loading && (
-
- Loading editor...
-
- )}
-
- );
-}
-
-export function MarkdownEditor(props: MilkdownEditorProps) {
- // Generate a unique key on each mount to force fresh Milkdown instance
- const [instanceId] = useState(() => Math.random().toString(36).slice(2));
-
- return (
-
-
-
- );
-}
-
diff --git a/apps/web/components/task/task-changes-panel.tsx b/apps/web/components/task/task-changes-panel.tsx
index 21da1cda1c..8e5615a2ea 100644
--- a/apps/web/components/task/task-changes-panel.tsx
+++ b/apps/web/components/task/task-changes-panel.tsx
@@ -34,6 +34,7 @@ import { ReviewDialog } from '@/components/review/review-dialog';
import type { ReviewFile } from '@/components/review/types';
import { hashDiff, normalizeDiffContent } from '@/components/review/types';
import type { DiffComment } from '@/lib/diff/types';
+import { usePanelActions } from '@/hooks/use-panel-actions';
import type { SelectedDiff } from './task-layout';
type TaskChangesPanelProps = {
@@ -46,7 +47,11 @@ type TaskChangesPanelProps = {
const TaskChangesPanel = memo(function TaskChangesPanel({
selectedDiff,
onClearSelected,
+ onOpenFile: onOpenFileProp,
}: TaskChangesPanelProps) {
+ const { openFile: panelOpenFile } = usePanelActions();
+ const handleOpenFile = onOpenFileProp ?? panelOpenFile;
+
const [splitView, setSplitView] = useState(() => {
if (typeof window === 'undefined') return false;
return localStorage.getItem('diff-view-mode') === 'split';
@@ -418,6 +423,7 @@ const TaskChangesPanel = memo(function TaskChangesPanel({
wordWrap={wordWrap}
onToggleReviewed={handleToggleReviewed}
onDiscard={handleDiscard}
+ onOpenFile={handleOpenFile}
fileRefs={fileRefs}
/>
) : null}
@@ -432,6 +438,7 @@ const TaskChangesPanel = memo(function TaskChangesPanel({
baseBranch={baseBranch}
taskTitle={taskTitle}
onSendComments={handleDialogSendComments}
+ onOpenFile={handleOpenFile}
gitStatusFiles={gitStatus?.files ?? null}
cumulativeDiff={cumulativeDiff}
/>
diff --git a/apps/web/components/task/task-plan-panel.tsx b/apps/web/components/task/task-plan-panel.tsx
index 56e84b8d32..0f3c7f6919 100644
--- a/apps/web/components/task/task-plan-panel.tsx
+++ b/apps/web/components/task/task-plan-panel.tsx
@@ -11,14 +11,14 @@ import { useTaskPlan } from '@/hooks/domains/session/use-task-plan';
import { useAppStore } from '@/components/state-provider';
import { PlanSelectionPopover } from './plan-selection-popover';
import type { PlanComment } from './plan-comments';
-import type { TextSelection, CommentHighlight } from './markdown-editor';
+import type { TextSelection, CommentHighlight } from '@/components/editors/tiptap/tiptap-plan-editor';
import type { DocumentComment } from '@/lib/state/slices/ui/types';
const EMPTY_COMMENTS: DocumentComment[] = [];
-// Dynamic import to avoid SSR issues with Milkdown
-const MarkdownEditor = dynamic(
- () => import('./markdown-editor').then((mod) => mod.MarkdownEditor),
+// Dynamic import to avoid SSR issues with TipTap
+const PlanEditor = dynamic(
+ () => import('@/components/editors/tiptap/tiptap-plan-editor').then((mod) => mod.TipTapPlanEditor),
{ ssr: false, loading: () => Loading editor...
}
);
@@ -309,7 +309,7 @@ export const TaskPlanPanel = memo(function TaskPlanPanel({ taskId, visible = tru
ref={editorWrapperRef}
onClick={handleEmptyStateClick}
>
- s.providers[context]);
+}
diff --git a/apps/web/hooks/use-file-editors.ts b/apps/web/hooks/use-file-editors.ts
index aeb5f2a5ea..0074cd8594 100644
--- a/apps/web/hooks/use-file-editors.ts
+++ b/apps/web/hooks/use-file-editors.ts
@@ -14,6 +14,20 @@ import type { FileContentResponse } from '@/lib/types/backend';
let _restoredSessionId: string | null = null;
let _restorationInProgress = false;
+// Pending cursor positions: set before opening a file, consumed by the editor on mount.
+// Used by LSP Go-to-Definition to jump to the correct line/column.
+const _pendingCursorPositions = new Map();
+
+export function setPendingCursorPosition(path: string, line: number, column: number) {
+ _pendingCursorPositions.set(path, { line, column });
+}
+
+export function consumePendingCursorPosition(path: string): { line: number; column: number } | undefined {
+ const pos = _pendingCursorPositions.get(path);
+ if (pos) _pendingCursorPositions.delete(path);
+ return pos;
+}
+
export function useFileEditors() {
const activeSessionId = useAppStore((state) => state.tasks.activeSessionId);
const { toast } = useToast();
diff --git a/apps/web/hooks/use-lsp-file-opener.ts b/apps/web/hooks/use-lsp-file-opener.ts
new file mode 100644
index 0000000000..84ae210a06
--- /dev/null
+++ b/apps/web/hooks/use-lsp-file-opener.ts
@@ -0,0 +1,94 @@
+'use client';
+
+import { useEffect } from 'react';
+import { useAppStore } from '@/components/state-provider';
+import { useFileEditors, setPendingCursorPosition, consumePendingCursorPosition } from '@/hooks/use-file-editors';
+import { lspClientManager } from '@/lib/lsp/lsp-client-manager';
+import { getMonacoInstance } from '@/components/editors/monaco/monaco-init';
+
+/**
+ * Try to scroll an already-mounted editor to the given position.
+ * Returns true if the editor was found and scrolled, false otherwise.
+ * If successful, consumes the pending cursor position so handleEditorDidMount
+ * doesn't double-apply it.
+ */
+function scrollEditorIfMounted(
+ relativePath: string,
+ worktreePath: string | null,
+ line: number,
+ column: number,
+): boolean {
+ const monaco = getMonacoInstance();
+ if (!monaco) return false;
+
+ const monacoPath = worktreePath ? `${worktreePath}/${relativePath}` : relativePath;
+
+ for (const editor of monaco.editor.getEditors()) {
+ const model = editor.getModel();
+ if (!model) continue;
+ const modelPath = model.uri.path;
+ if (modelPath === `/${monacoPath}` || modelPath === monacoPath) {
+ consumePendingCursorPosition(relativePath);
+ editor.setPosition({ lineNumber: line, column });
+ editor.revealLineInCenter(line);
+ editor.focus();
+ return true;
+ }
+ }
+ return false;
+}
+
+/**
+ * Connects LSP Go-to-Definition / Find References navigation to dockview file tabs.
+ * When Monaco's registerEditorOpener fires with a file:// URI, this hook converts
+ * the absolute path to a workspace-relative path and opens it via useFileEditors.
+ */
+export function useLspFileOpener() {
+ const { openFile } = useFileEditors();
+
+ const worktreePath = useAppStore((state) => {
+ const sessionId = state.tasks.activeSessionId;
+ if (!sessionId) return null;
+ const session = state.taskSessions.items[sessionId];
+ return session?.worktree_path ?? null;
+ });
+
+ useEffect(() => {
+ const opener = async (uri: string, line?: number, column?: number) => {
+ // uri is like "file:///workspace/path/src/foo.ts"
+ const filePath = uri.replace(/^file:\/\//, '');
+
+ // Dispose the placeholder model since a real tab will create its own model
+ lspClientManager.disposePlaceholderModel(uri);
+
+ // Convert absolute path to workspace-relative path
+ let relativePath = filePath;
+ if (worktreePath && filePath.startsWith(worktreePath)) {
+ relativePath = filePath.slice(worktreePath.length + 1); // +1 for the trailing /
+ }
+
+ // Set pending cursor position so the editor jumps to the correct line/column.
+ // For new files: consumed by handleEditorDidMount when the editor mounts.
+ // For already-open files: consumed by scrollEditorIfMounted below.
+ if (line) {
+ setPendingCursorPosition(relativePath, line, column ?? 1);
+ }
+
+ await openFile(relativePath);
+
+ // For already-open files, the editor is already mounted so handleEditorDidMount
+ // won't fire. Scroll the editor directly.
+ if (line) {
+ scrollEditorIfMounted(relativePath, worktreePath, line, column ?? 1);
+ }
+ };
+
+ lspClientManager.setFileOpener(opener);
+ return () => {
+ // Only clear if we're still the registered opener
+ if (lspClientManager.getFileOpener() === opener) {
+ lspClientManager.setFileOpener(null);
+ }
+ };
+ }, [openFile, worktreePath]);
+}
diff --git a/apps/web/hooks/use-lsp.ts b/apps/web/hooks/use-lsp.ts
new file mode 100644
index 0000000000..b1437394c0
--- /dev/null
+++ b/apps/web/hooks/use-lsp.ts
@@ -0,0 +1,73 @@
+import { useCallback, useEffect, useSyncExternalStore } from 'react';
+import { useAppStore } from '@/components/state-provider';
+import { lspClientManager, toLspLanguage, type LspStatus } from '@/lib/lsp/lsp-client-manager';
+
+const DISABLED: LspStatus = { state: 'disabled' };
+
+export function useLsp(
+ sessionId: string | null,
+ monacoLanguage: string
+): {
+ status: LspStatus;
+ lspLanguage: string | null;
+ toggle: () => void;
+} {
+ const lspAutoStartLanguages = useAppStore((s) => s.userSettings.lspAutoStartLanguages);
+ const lspServerConfigs = useAppStore((s) => s.userSettings.lspServerConfigs);
+ const lspLanguage = toLspLanguage(monacoLanguage);
+ const shouldAutoStart = lspLanguage ? lspAutoStartLanguages.includes(lspLanguage) : false;
+
+ const status = useSyncExternalStore(
+ (cb) =>
+ lspClientManager.onStatusChange((key) => {
+ if (key === `${sessionId}:${lspLanguage}`) cb();
+ }),
+ () =>
+ sessionId && lspLanguage
+ ? lspClientManager.getStatus(sessionId, lspLanguage)
+ : DISABLED
+ );
+
+ // Auto-start: connect when file opens if language is in auto-start list
+ useEffect(() => {
+ if (!shouldAutoStart || !sessionId || !lspLanguage) return;
+ const disconnect = lspClientManager.connect(sessionId, lspLanguage, lspServerConfigs);
+ return disconnect;
+ }, [shouldAutoStart, sessionId, lspLanguage, lspServerConfigs]);
+
+ // Restore LSP state from localStorage: if the user previously enabled LSP
+ // manually for this session+language, reconnect automatically on page load.
+ useEffect(() => {
+ if (!sessionId || !lspLanguage) return;
+ if (shouldAutoStart) return;
+ const current = lspClientManager.getStatus(sessionId, lspLanguage);
+ if (current.state !== 'disabled') return;
+
+ if (lspClientManager.isEnabledInStorage(sessionId, lspLanguage)) {
+ lspClientManager.connect(sessionId, lspLanguage, lspServerConfigs);
+ }
+ }, [sessionId, lspLanguage, shouldAutoStart, lspServerConfigs]);
+
+ // Manual toggle
+ const toggle = useCallback(() => {
+ if (!sessionId || !lspLanguage) return;
+ const current = lspClientManager.getStatus(sessionId, lspLanguage);
+ if (
+ current.state === 'disabled' ||
+ current.state === 'error' ||
+ current.state === 'unavailable'
+ ) {
+ lspClientManager.connect(sessionId, lspLanguage, lspServerConfigs);
+ lspClientManager.saveEnabledState(sessionId, lspLanguage);
+ } else if (
+ current.state === 'ready' ||
+ current.state === 'connecting' ||
+ current.state === 'starting'
+ ) {
+ lspClientManager.stop(sessionId, lspLanguage);
+ lspClientManager.clearEnabledState(sessionId, lspLanguage);
+ }
+ }, [sessionId, lspLanguage, lspServerConfigs]);
+
+ return { status, lspLanguage, toggle };
+}
diff --git a/apps/web/hooks/use-user-display-settings.ts b/apps/web/hooks/use-user-display-settings.ts
index 9ae22c9b7a..371e445e8b 100644
--- a/apps/web/hooks/use-user-display-settings.ts
+++ b/apps/web/hooks/use-user-display-settings.ts
@@ -16,6 +16,9 @@ type DisplaySettings = {
enablePreviewOnClick: boolean;
chatSubmitKey: 'enter' | 'cmd_enter';
reviewAutoMarkOnScroll: boolean;
+ lspAutoStartLanguages: string[];
+ lspAutoInstallLanguages: string[];
+ lspServerConfigs: Record>;
loaded: boolean;
};
@@ -73,6 +76,9 @@ export function useUserDisplaySettings({
enablePreviewOnClick,
chatSubmitKey: current.chatSubmitKey ?? 'cmd_enter',
reviewAutoMarkOnScroll: current.reviewAutoMarkOnScroll ?? true,
+ lspAutoStartLanguages: current.lspAutoStartLanguages ?? [],
+ lspAutoInstallLanguages: current.lspAutoInstallLanguages ?? [],
+ lspServerConfigs: current.lspServerConfigs ?? {},
loaded: true,
};
const sameWorkspace = normalized.workspaceId === current.workspaceId;
@@ -124,6 +130,9 @@ export function useUserDisplaySettings({
enablePreviewOnClick: data.settings.enable_preview_on_click ?? false,
chatSubmitKey: data.settings.chat_submit_key ?? 'cmd_enter',
reviewAutoMarkOnScroll: data.settings.review_auto_mark_on_scroll ?? true,
+ lspAutoStartLanguages: data.settings.lsp_auto_start_languages ?? [],
+ lspAutoInstallLanguages: data.settings.lsp_auto_install_languages ?? [],
+ lspServerConfigs: data.settings.lsp_server_configs ?? {},
loaded: true,
});
})
diff --git a/apps/web/lib/api/domains/settings-api.ts b/apps/web/lib/api/domains/settings-api.ts
index 011e050672..9c68d598b0 100644
--- a/apps/web/lib/api/domains/settings-api.ts
+++ b/apps/web/lib/api/domains/settings-api.ts
@@ -31,6 +31,9 @@ export async function updateUserSettings(
enable_preview_on_click?: boolean;
chat_submit_key?: 'enter' | 'cmd_enter';
review_auto_mark_on_scroll?: boolean;
+ lsp_auto_start_languages?: string[];
+ lsp_auto_install_languages?: string[];
+ lsp_server_configs?: Record>;
},
options?: ApiRequestOptions
) {
diff --git a/apps/web/lib/editor/language-map.ts b/apps/web/lib/editor/language-map.ts
new file mode 100644
index 0000000000..ee1c378e04
--- /dev/null
+++ b/apps/web/lib/editor/language-map.ts
@@ -0,0 +1,194 @@
+/**
+ * Unified language mapping for all editor implementations.
+ * Maps file extensions to language IDs for Monaco, CodeMirror, and lowlight.
+ */
+
+type LanguageIds = {
+ monaco: string;
+ codemirror: string;
+ lowlight: string;
+};
+
+const LANGUAGE_MAP: Record = {
+ // JavaScript/TypeScript
+ '.js': { monaco: 'javascript', codemirror: 'javascript', lowlight: 'javascript' },
+ '.mjs': { monaco: 'javascript', codemirror: 'javascript', lowlight: 'javascript' },
+ '.cjs': { monaco: 'javascript', codemirror: 'javascript', lowlight: 'javascript' },
+ '.jsx': { monaco: 'javascript', codemirror: 'jsx', lowlight: 'javascript' },
+ '.ts': { monaco: 'typescript', codemirror: 'typescript', lowlight: 'typescript' },
+ '.mts': { monaco: 'typescript', codemirror: 'typescript', lowlight: 'typescript' },
+ '.cts': { monaco: 'typescript', codemirror: 'typescript', lowlight: 'typescript' },
+ '.tsx': { monaco: 'typescript', codemirror: 'tsx', lowlight: 'typescript' },
+
+ // Python
+ '.py': { monaco: 'python', codemirror: 'python', lowlight: 'python' },
+ '.pyw': { monaco: 'python', codemirror: 'python', lowlight: 'python' },
+ '.pyi': { monaco: 'python', codemirror: 'python', lowlight: 'python' },
+
+ // Go
+ '.go': { monaco: 'go', codemirror: 'go', lowlight: 'go' },
+
+ // Rust
+ '.rs': { monaco: 'rust', codemirror: 'rust', lowlight: 'rust' },
+
+ // Ruby
+ '.rb': { monaco: 'ruby', codemirror: 'ruby', lowlight: 'ruby' },
+ '.erb': { monaco: 'ruby', codemirror: 'ruby', lowlight: 'erb' },
+
+ // Java/JVM
+ '.java': { monaco: 'java', codemirror: 'java', lowlight: 'java' },
+ '.kt': { monaco: 'kotlin', codemirror: 'kotlin', lowlight: 'kotlin' },
+ '.kts': { monaco: 'kotlin', codemirror: 'kotlin', lowlight: 'kotlin' },
+ '.scala': { monaco: 'scala', codemirror: 'scala', lowlight: 'scala' },
+ '.groovy': { monaco: 'groovy', codemirror: 'groovy', lowlight: 'groovy' },
+
+ // Swift
+ '.swift': { monaco: 'swift', codemirror: 'swift', lowlight: 'swift' },
+
+ // C/C++
+ '.c': { monaco: 'c', codemirror: 'cpp', lowlight: 'c' },
+ '.h': { monaco: 'c', codemirror: 'cpp', lowlight: 'c' },
+ '.cpp': { monaco: 'cpp', codemirror: 'cpp', lowlight: 'cpp' },
+ '.cc': { monaco: 'cpp', codemirror: 'cpp', lowlight: 'cpp' },
+ '.cxx': { monaco: 'cpp', codemirror: 'cpp', lowlight: 'cpp' },
+ '.hpp': { monaco: 'cpp', codemirror: 'cpp', lowlight: 'cpp' },
+ '.hxx': { monaco: 'cpp', codemirror: 'cpp', lowlight: 'cpp' },
+
+ // C#
+ '.cs': { monaco: 'csharp', codemirror: 'csharp', lowlight: 'csharp' },
+
+ // PHP
+ '.php': { monaco: 'php', codemirror: 'php', lowlight: 'php' },
+
+ // Web
+ '.html': { monaco: 'html', codemirror: 'html', lowlight: 'xml' },
+ '.htm': { monaco: 'html', codemirror: 'html', lowlight: 'xml' },
+ '.css': { monaco: 'css', codemirror: 'css', lowlight: 'css' },
+ '.scss': { monaco: 'scss', codemirror: 'css', lowlight: 'scss' },
+ '.sass': { monaco: 'scss', codemirror: 'css', lowlight: 'scss' },
+ '.less': { monaco: 'less', codemirror: 'css', lowlight: 'less' },
+
+ // Data formats
+ '.json': { monaco: 'json', codemirror: 'json', lowlight: 'json' },
+ '.jsonc': { monaco: 'json', codemirror: 'json', lowlight: 'json' },
+ '.yaml': { monaco: 'yaml', codemirror: 'yaml', lowlight: 'yaml' },
+ '.yml': { monaco: 'yaml', codemirror: 'yaml', lowlight: 'yaml' },
+ '.xml': { monaco: 'xml', codemirror: 'html', lowlight: 'xml' },
+ '.toml': { monaco: 'ini', codemirror: 'toml', lowlight: 'ini' },
+ '.ini': { monaco: 'ini', codemirror: 'ini', lowlight: 'ini' },
+ '.env': { monaco: 'ini', codemirror: 'properties', lowlight: 'ini' },
+
+ // Markup
+ '.md': { monaco: 'markdown', codemirror: 'markdown', lowlight: 'markdown' },
+ '.mdx': { monaco: 'markdown', codemirror: 'markdown', lowlight: 'markdown' },
+
+ // Shell
+ '.sh': { monaco: 'shell', codemirror: 'bash', lowlight: 'bash' },
+ '.bash': { monaco: 'shell', codemirror: 'bash', lowlight: 'bash' },
+ '.zsh': { monaco: 'shell', codemirror: 'bash', lowlight: 'bash' },
+ '.fish': { monaco: 'shell', codemirror: 'fish', lowlight: 'bash' },
+
+ // SQL
+ '.sql': { monaco: 'sql', codemirror: 'sql', lowlight: 'sql' },
+
+ // Docker
+ '.dockerfile': { monaco: 'dockerfile', codemirror: 'dockerfile', lowlight: 'dockerfile' },
+
+ // GraphQL
+ '.graphql': { monaco: 'graphql', codemirror: 'graphql', lowlight: 'graphql' },
+ '.gql': { monaco: 'graphql', codemirror: 'graphql', lowlight: 'graphql' },
+
+ // Vue/Svelte
+ '.vue': { monaco: 'html', codemirror: 'vue', lowlight: 'xml' },
+ '.svelte': { monaco: 'html', codemirror: 'svelte', lowlight: 'xml' },
+};
+
+/**
+ * Language name aliases (used when a language string is provided instead of file ext).
+ * Maps common language names to Monaco language IDs.
+ */
+const LANGUAGE_ALIASES: Record = {
+ javascript: 'javascript',
+ js: 'javascript',
+ jsx: 'javascript',
+ typescript: 'typescript',
+ ts: 'typescript',
+ tsx: 'typescript',
+ python: 'python',
+ py: 'python',
+ go: 'go',
+ golang: 'go',
+ rust: 'rust',
+ rs: 'rust',
+ java: 'java',
+ kotlin: 'kotlin',
+ scala: 'scala',
+ swift: 'swift',
+ c: 'c',
+ cpp: 'cpp',
+ 'c++': 'cpp',
+ csharp: 'csharp',
+ 'c#': 'csharp',
+ php: 'php',
+ ruby: 'ruby',
+ rb: 'ruby',
+ html: 'html',
+ css: 'css',
+ scss: 'scss',
+ less: 'less',
+ json: 'json',
+ yaml: 'yaml',
+ yml: 'yaml',
+ xml: 'xml',
+ markdown: 'markdown',
+ md: 'markdown',
+ bash: 'shell',
+ shell: 'shell',
+ sh: 'shell',
+ zsh: 'shell',
+ sql: 'sql',
+ dockerfile: 'dockerfile',
+ graphql: 'graphql',
+ plaintext: 'plaintext',
+ text: 'plaintext',
+};
+
+function getExtension(filePath: string): string {
+ const lastSlash = filePath.lastIndexOf('/');
+ const name = lastSlash >= 0 ? filePath.slice(lastSlash + 1) : filePath;
+
+ // Handle Dockerfile (no extension)
+ if (name.toLowerCase() === 'dockerfile') return '.dockerfile';
+
+ const dot = name.lastIndexOf('.');
+ if (dot === -1) return '';
+ return name.slice(dot).toLowerCase();
+}
+
+export function getMonacoLanguage(filePath: string): string {
+ const ext = getExtension(filePath);
+ return LANGUAGE_MAP[ext]?.monaco ?? 'plaintext';
+}
+
+export function getMonacoLanguageFromName(language: string): string {
+ return LANGUAGE_ALIASES[language.toLowerCase()] ?? 'plaintext';
+}
+
+export function getLowlightLanguage(filePath: string): string {
+ const ext = getExtension(filePath);
+ return LANGUAGE_MAP[ext]?.lowlight ?? 'plaintext';
+}
+
+export function getLowlightLanguageFromName(language: string): string {
+ // For lowlight, the language name is typically the same
+ const lower = language.toLowerCase();
+ // Try to find via alias → then look it up in the map values
+ const monacoLang = LANGUAGE_ALIASES[lower];
+ if (!monacoLang) return lower;
+
+ // Find a matching entry in the map
+ for (const ids of Object.values(LANGUAGE_MAP)) {
+ if (ids.monaco === monacoLang) return ids.lowlight;
+ }
+ return lower;
+}
diff --git a/apps/web/lib/lsp/lsp-client-manager.ts b/apps/web/lib/lsp/lsp-client-manager.ts
new file mode 100644
index 0000000000..d082a3f49b
--- /dev/null
+++ b/apps/web/lib/lsp/lsp-client-manager.ts
@@ -0,0 +1,1065 @@
+import type { editor as monacoEditor, IDisposable, languages, MarkerSeverity as MarkerSeverityType } from 'monaco-editor';
+import { getBackendConfig } from '@/lib/config';
+import { getMonacoInstance } from '@/components/editors/monaco/monaco-init';
+import { setBuiltinTsSuppressed } from '@/components/editors/monaco/builtin-providers';
+
+// ---------------------------------------------------------------------------
+// Types
+// ---------------------------------------------------------------------------
+
+export type LspStatus =
+ | { state: 'disabled' }
+ | { state: 'connecting' }
+ | { state: 'installing' }
+ | { state: 'starting' }
+ | { state: 'ready' }
+ | { state: 'stopping' }
+ | { state: 'unavailable'; reason: string }
+ | { state: 'error'; reason: string };
+
+const DISABLED_STATUS: LspStatus = { state: 'disabled' };
+const LSP_IDLE_TIMEOUT = 2 * 60 * 1000; // 2 minutes
+
+// Default per-language LSP server configurations.
+// These are sent to the language server via workspace/configuration requests.
+// Users can override these via lspServerConfigs in user settings.
+export const LSP_DEFAULT_CONFIGS: Record> = {
+ go: { 'ui.semanticTokens': true },
+};
+
+type StatusListener = (key: string, status: LspStatus) => void;
+
+// ---------------------------------------------------------------------------
+// Minimal JSON-RPC 2.0 client over WebSocket
+// ---------------------------------------------------------------------------
+
+type PendingRequest = { resolve: (value: unknown) => void; reject: (reason: unknown) => void };
+
+class JsonRpcConnection {
+ private nextId = 1;
+ private pending = new Map();
+ private notificationHandlers = new Map void>();
+ private requestHandlers = new Map unknown>();
+ private messageHandler: ((event: MessageEvent) => void) | null = null;
+
+ constructor(private ws: WebSocket) {}
+
+ listen() {
+ this.messageHandler = (event: MessageEvent) => {
+ let msg: { jsonrpc?: string; id?: number; method?: string; params?: unknown; result?: unknown; error?: unknown };
+ try {
+ msg = JSON.parse(event.data as string);
+ } catch {
+ return;
+ }
+
+ if (msg.id !== undefined && msg.method !== undefined) {
+ // Server → client request
+ const handler = this.requestHandlers.get(msg.method);
+ if (handler) {
+ try {
+ const result = handler(msg.params);
+ this.ws.send(JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: result ?? null }));
+ } catch (err) {
+ this.ws.send(JSON.stringify({ jsonrpc: '2.0', id: msg.id, error: { code: -32603, message: String(err) } }));
+ }
+ } else {
+ // Respond with empty result for unhandled server requests (e.g. workspace/configuration)
+ this.ws.send(JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: null }));
+ }
+ } else if (msg.id !== undefined) {
+ // Response to our request
+ const p = this.pending.get(msg.id);
+ if (p) {
+ this.pending.delete(msg.id);
+ if (msg.error) p.reject(msg.error);
+ else p.resolve(msg.result);
+ }
+ } else if (msg.method !== undefined) {
+ // Notification from server
+ this.notificationHandlers.get(msg.method)?.(msg.params);
+ }
+ };
+ this.ws.addEventListener('message', this.messageHandler);
+ }
+
+ sendRequest(method: string, params: unknown): Promise {
+ const id = this.nextId++;
+ return new Promise((resolve, reject) => {
+ this.pending.set(id, { resolve, reject });
+ this.ws.send(JSON.stringify({ jsonrpc: '2.0', id, method, params }));
+ });
+ }
+
+ sendNotification(method: string, params: unknown): void {
+ this.ws.send(JSON.stringify({ jsonrpc: '2.0', method, params }));
+ }
+
+ onNotification(method: string, handler: (params: unknown) => void): void {
+ this.notificationHandlers.set(method, handler);
+ }
+
+ onRequest(method: string, handler: (params: unknown) => unknown): void {
+ this.requestHandlers.set(method, handler);
+ }
+
+ dispose() {
+ if (this.messageHandler) {
+ this.ws.removeEventListener('message', this.messageHandler);
+ this.messageHandler = null;
+ }
+ for (const p of this.pending.values()) {
+ p.reject(new Error('Connection disposed'));
+ }
+ this.pending.clear();
+ this.notificationHandlers.clear();
+ this.requestHandlers.clear();
+ }
+}
+
+// ---------------------------------------------------------------------------
+// LSP ↔ Monaco type conversions
+// ---------------------------------------------------------------------------
+
+type LspPosition = { line: number; character: number };
+type LspRange = { start: LspPosition; end: LspPosition };
+
+function toMonacoRange(r: LspRange): { startLineNumber: number; startColumn: number; endLineNumber: number; endColumn: number } {
+ return {
+ startLineNumber: r.start.line + 1,
+ startColumn: r.start.character + 1,
+ endLineNumber: r.end.line + 1,
+ endColumn: r.end.character + 1,
+ };
+}
+
+function toLspPosition(lineNumber: number, column: number): LspPosition {
+ return { line: lineNumber - 1, character: column - 1 };
+}
+
+// LSP CompletionItemKind → Monaco CompletionItemKind
+function toMonacoCompletionKind(lspKind: number | undefined): number {
+ // Monaco CompletionItemKind values (approximate mapping)
+ const map: Record = {
+ 1: 14, // Text → Value
+ 2: 1, // Method → Method
+ 3: 0, // Function → Function
+ 4: 8, // Constructor → Constructor
+ 5: 4, // Field → Field
+ 6: 5, // Variable → Variable
+ 7: 7, // Class → Class
+ 8: 7, // Interface → Interface (use Class icon)
+ 9: 8, // Module → Module
+ 10: 9, // Property → Property
+ 11: 12, // Unit → Unit
+ 12: 14, // Value → Value
+ 13: 15, // Enum → Enum
+ 14: 17, // Keyword → Keyword
+ 15: 27, // Snippet → Snippet
+ 16: 19, // Color → Color
+ 17: 20, // File → File
+ 18: 21, // Reference → Reference
+ 19: 23, // Folder → Folder
+ 20: 16, // EnumMember → EnumMember
+ 21: 14, // Constant → Value
+ 22: 6, // Struct → Struct
+ 23: 24, // Event → Event
+ 24: 25, // Operator → Operator
+ 25: 26, // TypeParameter → TypeParameter
+ };
+ return map[lspKind ?? 1] ?? 14;
+}
+
+// LSP DiagnosticSeverity → Monaco MarkerSeverity
+function toMonacoSeverity(lspSeverity: number | undefined): MarkerSeverityType {
+ const monaco = getMonacoInstance();
+ if (!monaco) return 8 as MarkerSeverityType; // Error fallback
+ switch (lspSeverity) {
+ case 1: return monaco.MarkerSeverity.Error;
+ case 2: return monaco.MarkerSeverity.Warning;
+ case 3: return monaco.MarkerSeverity.Info;
+ case 4: return monaco.MarkerSeverity.Hint;
+ default: return monaco.MarkerSeverity.Info;
+ }
+}
+
+// Extract plaintext from LSP MarkupContent or string
+function extractHoverContents(contents: unknown): { value: string }[] {
+ if (typeof contents === 'string') return [{ value: contents }];
+ if (contents && typeof contents === 'object' && 'value' in contents) {
+ const c = contents as { kind?: string; value: string };
+ return [{ value: c.value }];
+ }
+ if (Array.isArray(contents)) {
+ return contents.map((item) => {
+ if (typeof item === 'string') return { value: item };
+ if (item && typeof item === 'object' && 'value' in item) return { value: (item as { value: string }).value };
+ return { value: String(item) };
+ });
+ }
+ return [];
+}
+
+// ---------------------------------------------------------------------------
+// LSP Connection
+// ---------------------------------------------------------------------------
+
+type OpenDocument = { version: number; languageId: string };
+
+type LSPConnection = {
+ ws: WebSocket;
+ rpc: JsonRpcConnection | null;
+ initialized: boolean;
+ refCount: number;
+ idleTimer: ReturnType | null;
+ openDocuments: Map;
+ providerDisposables: IDisposable[];
+ serverCapabilities: Record | null;
+ workspacePath: string | null;
+};
+
+// ---------------------------------------------------------------------------
+// Manager
+// ---------------------------------------------------------------------------
+
+function getWsBaseUrl(): string {
+ try {
+ const backendUrl = getBackendConfig().apiBaseUrl;
+ const url = new URL(backendUrl);
+ const protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
+ return `${protocol}//${url.host}`;
+ } catch {
+ return 'ws://localhost:8080';
+ }
+}
+
+class LSPClientManager {
+ private connections = new Map();
+ private statuses = new Map();
+ private listeners = new Set();
+ private fileOpener: ((uri: string, line?: number, column?: number) => void) | null = null;
+ /** Tracks placeholder Monaco models created for LSP references/definitions. */
+ private placeholderModels = new Set();
+
+ setFileOpener(opener: ((uri: string, line?: number, column?: number) => void) | null): void {
+ this.fileOpener = opener;
+ }
+
+ getFileOpener(): ((uri: string, line?: number, column?: number) => void) | null {
+ return this.fileOpener;
+ }
+
+ // ---- localStorage persistence for manual LSP toggle ----
+
+ private lspStorageKey(sessionId: string, language: string): string {
+ return `kandev-lsp:${sessionId}:${language}`;
+ }
+
+ /** Save that LSP was manually enabled for this session+language. */
+ saveEnabledState(sessionId: string, language: string): void {
+ try { localStorage.setItem(this.lspStorageKey(sessionId, language), '1'); } catch {}
+ }
+
+ /** Clear the saved LSP state (manual stop). */
+ clearEnabledState(sessionId: string, language: string): void {
+ try { localStorage.removeItem(this.lspStorageKey(sessionId, language)); } catch {}
+ }
+
+ /** Check if LSP was previously enabled for this session+language. */
+ isEnabledInStorage(sessionId: string, language: string): boolean {
+ try { return localStorage.getItem(this.lspStorageKey(sessionId, language)) === '1'; } catch { return false; }
+ }
+
+ getStatus(sessionId: string, lspLanguage: string): LspStatus {
+ const key = `${sessionId}:${lspLanguage}`;
+ return this.statuses.get(key) ?? DISABLED_STATUS;
+ }
+
+ onStatusChange(listener: StatusListener): () => void {
+ this.listeners.add(listener);
+ return () => { this.listeners.delete(listener); };
+ }
+
+ private setStatus(key: string, status: LspStatus) {
+ this.statuses.set(key, status);
+ for (const listener of this.listeners) {
+ listener(key, status);
+ }
+ }
+
+ // ------- Connection lifecycle -------
+
+ connect(sessionId: string, lspLanguage: string, userConfigs?: Record>): () => void {
+ const key = `${sessionId}:${lspLanguage}`;
+
+ const existing = this.connections.get(key);
+ if (existing && existing.ws.readyState <= WebSocket.OPEN) {
+ existing.refCount++;
+ if (existing.idleTimer) {
+ clearTimeout(existing.idleTimer);
+ existing.idleTimer = null;
+ }
+ return () => this.decrementRef(key);
+ }
+
+ const wsUrl = `${getWsBaseUrl()}/lsp/${sessionId}?language=${lspLanguage}`;
+ const ws = new WebSocket(wsUrl);
+
+ const conn: LSPConnection = {
+ ws,
+ rpc: null,
+ initialized: false,
+ refCount: 1,
+ idleTimer: null,
+ openDocuments: new Map(),
+ providerDisposables: [],
+ serverCapabilities: null,
+ workspacePath: null,
+ };
+ this.connections.set(key, conn);
+ this.setStatus(key, { state: 'connecting' });
+
+ let bridgeStarted = false;
+
+ ws.onopen = () => {
+ this.setStatus(key, { state: 'starting' });
+ };
+
+ // Listen for backend status messages before the LSP bridge starts.
+ const statusHandler = (event: MessageEvent) => {
+ if (bridgeStarted) return;
+
+ let data: { status?: string; error?: string; workspacePath?: string };
+ try {
+ data = JSON.parse(event.data as string);
+ } catch {
+ return;
+ }
+
+ if (data.status === 'installing') {
+ this.setStatus(key, { state: 'installing' });
+ } else if (data.status === 'installed') {
+ this.setStatus(key, { state: 'starting' });
+ } else if (data.status === 'ready') {
+ // Language server is running — start the LSP JSON-RPC bridge
+ ws.removeEventListener('message', statusHandler);
+ bridgeStarted = true;
+ this.initializeLsp(key, ws, lspLanguage, data.workspacePath ?? null, userConfigs);
+ } else if (data.status === 'install_failed') {
+ ws.removeEventListener('message', statusHandler);
+ this.setStatus(key, { state: 'error', reason: data.error || 'Install failed' });
+ }
+ };
+ ws.addEventListener('message', statusHandler);
+
+ ws.onclose = (event) => {
+ ws.removeEventListener('message', statusHandler);
+ this.disposeConnection(key);
+
+ const current = this.statuses.get(key);
+ if (current?.state === 'ready' || current?.state === 'stopping') {
+ this.setStatus(key, { state: 'disabled' });
+ this.statuses.delete(key);
+ return;
+ }
+
+ if (!bridgeStarted) {
+ switch (event.code) {
+ case 4001:
+ this.setStatus(key, { state: 'unavailable', reason: event.reason || 'Language server not found' });
+ break;
+ case 4002:
+ this.setStatus(key, { state: 'unavailable', reason: 'No active workspace' });
+ break;
+ case 4003:
+ this.setStatus(key, { state: 'error', reason: event.reason || 'Install failed' });
+ break;
+ default:
+ if (current?.state !== 'error' && current?.state !== 'unavailable') {
+ this.setStatus(key, { state: 'error', reason: event.reason || 'Connection closed' });
+ }
+ }
+ }
+ };
+
+ ws.onerror = () => {
+ const current = this.statuses.get(key);
+ if (current?.state !== 'error' && current?.state !== 'unavailable') {
+ this.setStatus(key, { state: 'error', reason: 'WebSocket error' });
+ }
+ };
+
+ return () => this.decrementRef(key);
+ }
+
+ private async initializeLsp(key: string, ws: WebSocket, lspLanguage: string, workspacePath: string | null, userConfigs?: Record>) {
+ const conn = this.connections.get(key);
+ if (!conn) return;
+
+ conn.workspacePath = workspacePath;
+
+ // Merge default configs with user overrides for this language
+ const mergedConfig: Record = {
+ ...(LSP_DEFAULT_CONFIGS[lspLanguage] ?? {}),
+ ...(userConfigs?.[lspLanguage] ?? {}),
+ };
+
+ try {
+ const rpc = new JsonRpcConnection(ws);
+ rpc.listen();
+ conn.rpc = rpc;
+
+ // Handle server requests
+ rpc.onRequest('workspace/configuration', (params: unknown) => {
+ const items = (params as { items?: { section?: string }[] })?.items;
+ if (!Array.isArray(items)) return [mergedConfig];
+ return items.map(() => mergedConfig);
+ });
+ rpc.onRequest('client/registerCapability', () => null);
+ rpc.onRequest('window/workDoneProgress/create', () => null);
+
+ const initResult = await rpc.sendRequest('initialize', {
+ processId: null,
+ capabilities: {
+ textDocument: {
+ synchronization: {
+ dynamicRegistration: false,
+ willSave: false,
+ didSave: true,
+ willSaveWaitUntil: false,
+ },
+ completion: {
+ dynamicRegistration: false,
+ completionItem: {
+ snippetSupport: true,
+ commitCharactersSupport: true,
+ documentationFormat: ['markdown', 'plaintext'],
+ deprecatedSupport: true,
+ preselectSupport: true,
+ },
+ contextSupport: true,
+ },
+ hover: {
+ dynamicRegistration: false,
+ contentFormat: ['markdown', 'plaintext'],
+ },
+ definition: { dynamicRegistration: false },
+ references: { dynamicRegistration: false },
+ signatureHelp: {
+ dynamicRegistration: false,
+ signatureInformation: {
+ documentationFormat: ['markdown', 'plaintext'],
+ parameterInformation: { labelOffsetSupport: true },
+ },
+ },
+ publishDiagnostics: { relatedInformation: true },
+ semanticTokens: {
+ dynamicRegistration: false,
+ requests: { full: true },
+ tokenTypes: [
+ 'namespace', 'type', 'class', 'enum', 'interface', 'struct',
+ 'typeParameter', 'parameter', 'variable', 'property', 'enumMember',
+ 'event', 'function', 'method', 'macro', 'keyword', 'modifier',
+ 'comment', 'string', 'number', 'regexp', 'operator', 'decorator',
+ ],
+ tokenModifiers: [
+ 'declaration', 'definition', 'readonly', 'static', 'deprecated',
+ 'abstract', 'async', 'modification', 'documentation', 'defaultLibrary',
+ ],
+ formats: ['relative'],
+ overlappingTokenSupport: false,
+ multilineTokenSupport: false,
+ },
+ },
+ workspace: {
+ configuration: true,
+ didChangeConfiguration: { dynamicRegistration: false },
+ semanticTokens: { refreshSupport: true },
+ },
+ },
+ rootUri: workspacePath ? `file://${workspacePath}` : null,
+ workspaceFolders: workspacePath
+ ? [{ uri: `file://${workspacePath}`, name: workspacePath.split('/').pop() ?? 'workspace' }]
+ : null,
+ initializationOptions: {},
+ }) as { capabilities?: Record } | null;
+
+ conn.serverCapabilities = initResult?.capabilities ?? null;
+ rpc.sendNotification('initialized', {});
+
+ // Register diagnostics handler
+ rpc.onNotification('textDocument/publishDiagnostics', (params) => {
+ this.handleDiagnostics(params as { uri: string; diagnostics: Array<{ range: LspRange; message: string; severity?: number; source?: string; code?: unknown }> });
+ });
+
+ // Suppress Monaco's built-in TS/JS providers BEFORE registering our LSP
+ // providers. The suppression flag is checked at registration time in
+ // monaco-loader.ts — when it's true, newly registered providers are NOT
+ // wrapped with the suppression logic, so our LSP providers always work.
+ // The built-in providers (registered earlier while the flag was false)
+ // will return empty results while the flag is true.
+ if (lspLanguage === 'typescript') {
+ setBuiltinTsSuppressed(true);
+ }
+
+ // Collect callbacks for semantic token refresh — the server sends
+ // workspace/semanticTokens/refresh when analysis is complete and
+ // semantic tokens are ready (e.g. gopls after loading a Go workspace).
+ const semanticRefreshCallbacks: (() => void)[] = [];
+ rpc.onRequest('workspace/semanticTokens/refresh', () => {
+ for (const cb of semanticRefreshCallbacks) cb();
+ return null;
+ });
+
+ // Register Monaco providers for this language
+ conn.providerDisposables = this.registerProviders(rpc, lspLanguage, key, conn.serverCapabilities, semanticRefreshCallbacks);
+ conn.initialized = true;
+
+ this.setStatus(key, { state: 'ready' });
+ } catch (err) {
+ console.error(`[LSP] initializeLsp error:`, err);
+ this.setStatus(key, { state: 'error', reason: String(err) });
+ }
+ }
+
+ // ------- Monaco provider registration -------
+
+ private registerProviders(rpc: JsonRpcConnection, lspLanguage: string, connectionKey: string, serverCapabilities: Record | null, semanticRefreshCallbacks: (() => void)[]): IDisposable[] {
+ const monaco = getMonacoInstance();
+ if (!monaco) return [];
+
+ const monacoLanguages = getMonacoLanguagesForLsp(lspLanguage);
+ const disposables: IDisposable[] = [];
+
+ for (const lang of monacoLanguages) {
+ // Completion
+ disposables.push(
+ monaco.languages.registerCompletionItemProvider(lang, {
+ triggerCharacters: ['.', ':', '<', '"', "'", '/', '@', '#', ' '],
+ provideCompletionItems: async (model: monacoEditor.ITextModel, position: { lineNumber: number; column: number }, _context: unknown, token: { isCancellationRequested: boolean }) => {
+ const uri = this.getDocumentUri(model);
+ if (!uri) return { suggestions: [] };
+ try {
+ const result = await rpc.sendRequest('textDocument/completion', {
+ textDocument: { uri },
+ position: toLspPosition(position.lineNumber, position.column),
+ });
+ if (token.isCancellationRequested) return { suggestions: [] };
+ const items = Array.isArray(result) ? result : ((result as { items?: unknown[] })?.items ?? []);
+ return {
+ suggestions: (items as Array<{
+ label: string | { label: string; detail?: string; description?: string };
+ kind?: number;
+ detail?: string;
+ documentation?: unknown;
+ insertText?: string;
+ insertTextFormat?: number;
+ textEdit?: { range: LspRange; newText: string };
+ additionalTextEdits?: Array<{ range: LspRange; newText: string }>;
+ sortText?: string;
+ filterText?: string;
+ }>).map((item) => {
+ const label = typeof item.label === 'string' ? item.label : item.label.label;
+ const insertText = item.textEdit?.newText ?? item.insertText ?? label;
+ const isSnippet = item.insertTextFormat === 2;
+ return {
+ label,
+ kind: toMonacoCompletionKind(item.kind),
+ detail: item.detail,
+ documentation: typeof item.documentation === 'string'
+ ? item.documentation
+ : item.documentation && typeof item.documentation === 'object' && 'value' in item.documentation
+ ? { value: (item.documentation as { value: string }).value }
+ : undefined,
+ insertText,
+ insertTextRules: isSnippet ? 4 /* InsertAsSnippet */ : undefined,
+ range: item.textEdit?.range ? toMonacoRange(item.textEdit.range) : undefined,
+ sortText: item.sortText,
+ filterText: item.filterText,
+ additionalTextEdits: item.additionalTextEdits?.map((e) => ({
+ range: toMonacoRange(e.range),
+ text: e.newText,
+ })),
+ } as languages.CompletionItem;
+ }),
+ };
+ } catch {
+ return { suggestions: [] };
+ }
+ },
+ })
+ );
+
+ // Hover
+ disposables.push(
+ monaco.languages.registerHoverProvider(lang, {
+ provideHover: async (model: monacoEditor.ITextModel, position: { lineNumber: number; column: number }, token: { isCancellationRequested: boolean }) => {
+ const uri = this.getDocumentUri(model);
+ if (!uri) return null;
+ try {
+ const result = await rpc.sendRequest('textDocument/hover', {
+ textDocument: { uri },
+ position: toLspPosition(position.lineNumber, position.column),
+ }) as { contents: unknown; range?: LspRange } | null;
+ if (token.isCancellationRequested || !result) return null;
+ return {
+ range: result.range ? toMonacoRange(result.range) : undefined,
+ contents: extractHoverContents(result.contents),
+ };
+ } catch {
+ return null;
+ }
+ },
+ })
+ );
+
+ // Go to definition
+ disposables.push(
+ monaco.languages.registerDefinitionProvider(lang, {
+ provideDefinition: async (model: monacoEditor.ITextModel, position: { lineNumber: number; column: number }, token: { isCancellationRequested: boolean }) => {
+ const uri = this.getDocumentUri(model);
+ if (!uri) return null;
+ try {
+ const result = await rpc.sendRequest('textDocument/definition', {
+ textDocument: { uri },
+ position: toLspPosition(position.lineNumber, position.column),
+ });
+ if (token.isCancellationRequested || !result) return null;
+ const defs = Array.isArray(result) ? result : [result];
+ // Pre-create placeholder models so Monaco's peek/goto doesn't crash
+ this.ensureModelsExist(
+ defs.map((d: { uri: string }) => d.uri),
+ connectionKey,
+ );
+ return defs.map((d: { uri: string; range: LspRange }) => ({
+ uri: monaco.Uri.parse(d.uri),
+ range: toMonacoRange(d.range),
+ }));
+ } catch {
+ return null;
+ }
+ },
+ })
+ );
+
+ // References
+ disposables.push(
+ monaco.languages.registerReferenceProvider(lang, {
+ provideReferences: async (model: monacoEditor.ITextModel, position: { lineNumber: number; column: number }, context: { includeDeclaration: boolean }, token: { isCancellationRequested: boolean }) => {
+ const uri = this.getDocumentUri(model);
+ if (!uri) return null;
+ try {
+ const result = await rpc.sendRequest('textDocument/references', {
+ textDocument: { uri },
+ position: toLspPosition(position.lineNumber, position.column),
+ context: { includeDeclaration: context.includeDeclaration },
+ });
+ if (token.isCancellationRequested || !result) return null;
+ const refs = Array.isArray(result) ? result : [];
+ // Pre-create placeholder models so Monaco's peek widget can show previews
+ this.ensureModelsExist(
+ refs.map((r: { uri: string }) => r.uri),
+ connectionKey,
+ );
+ return refs.map((r: { uri: string; range: LspRange }) => ({
+ uri: monaco.Uri.parse(r.uri),
+ range: toMonacoRange(r.range),
+ }));
+ } catch {
+ return null;
+ }
+ },
+ })
+ );
+
+ // Signature help
+ disposables.push(
+ monaco.languages.registerSignatureHelpProvider(lang, {
+ signatureHelpTriggerCharacters: ['(', ','],
+ provideSignatureHelp: async (model: monacoEditor.ITextModel, position: { lineNumber: number; column: number }, _: { isCancellationRequested: boolean }) => {
+ const uri = this.getDocumentUri(model);
+ if (!uri) return null;
+ try {
+ const result = await rpc.sendRequest('textDocument/signatureHelp', {
+ textDocument: { uri },
+ position: toLspPosition(position.lineNumber, position.column),
+ }) as { signatures: Array<{ label: string; documentation?: unknown; parameters?: Array<{ label: string | [number, number]; documentation?: unknown }> }>; activeSignature?: number; activeParameter?: number } | null;
+ if (!result || !result.signatures?.length) return null;
+ return {
+ value: {
+ signatures: result.signatures.map((sig) => ({
+ label: sig.label,
+ documentation: typeof sig.documentation === 'string'
+ ? sig.documentation
+ : sig.documentation && typeof sig.documentation === 'object' && 'value' in sig.documentation
+ ? { value: (sig.documentation as { value: string }).value }
+ : undefined,
+ parameters: (sig.parameters ?? []).map((p) => ({
+ label: p.label,
+ documentation: typeof p.documentation === 'string' ? p.documentation : undefined,
+ })),
+ })),
+ activeSignature: result.activeSignature ?? 0,
+ activeParameter: result.activeParameter ?? 0,
+ },
+ dispose: () => {},
+ };
+ } catch {
+ return null;
+ }
+ },
+ })
+ );
+
+ // Semantic tokens — provides rich highlighting (functions, types, variables
+ // in different colors) using data from the LSP server.
+ const semTokensCap = serverCapabilities?.semanticTokensProvider as
+ { legend?: { tokenTypes: string[]; tokenModifiers: string[] }; full?: boolean | object } | undefined;
+ if (semTokensCap?.legend && semTokensCap.full) {
+ const legend = semTokensCap.legend;
+
+ // onDidChange event: fired when the server sends workspace/semanticTokens/refresh
+ // (e.g. after gopls finishes analyzing the workspace). This tells Monaco to
+ // re-request semantic tokens from the provider.
+ const listeners = new Set<() => void>();
+ const onDidChange = (listener: () => void) => {
+ listeners.add(listener);
+ return { dispose: () => listeners.delete(listener) };
+ };
+ semanticRefreshCallbacks.push(() => {
+ for (const l of listeners) l();
+ });
+
+ // Track pending retry timers so they can be disposed
+ const retryTimers = new Set>();
+
+ disposables.push(
+ monaco.languages.registerDocumentSemanticTokensProvider(lang, {
+ onDidChange,
+ getLegend() {
+ return legend;
+ },
+ provideDocumentSemanticTokens: async (model: monacoEditor.ITextModel, lastResultId: string | null, token: { isCancellationRequested: boolean }) => {
+ const uri = this.getDocumentUri(model);
+ if (!uri) return null;
+ try {
+ const result = await rpc.sendRequest('textDocument/semanticTokens/full', {
+ textDocument: { uri },
+ }) as { resultId?: string; data: number[] } | null;
+ if (token.isCancellationRequested) return null;
+ const dataLen = result?.data?.length ?? 0;
+ // If we got 0 tokens (server still loading/analyzing), schedule a retry
+ if (dataLen === 0) {
+ const timer = setTimeout(() => {
+ retryTimers.delete(timer);
+ for (const l of listeners) l();
+ }, 5000);
+ retryTimers.add(timer);
+ return null;
+ }
+ return {
+ resultId: result!.resultId,
+ data: new Uint32Array(result!.data),
+ };
+ } catch {
+ return null;
+ }
+ },
+ releaseDocumentSemanticTokens() {},
+ })
+ );
+ // Clean up retry timers when providers are disposed
+ disposables.push({ dispose: () => { for (const t of retryTimers) clearTimeout(t); retryTimers.clear(); } });
+ }
+ }
+
+ return disposables;
+ }
+
+ // ------- Placeholder models for Go-to-Definition / References -------
+
+ /**
+ * Ensure Monaco models exist for the given file:// URIs so that the peek
+ * widget and Go-to-Definition don't throw "Model not found". Models are
+ * created with empty content initially, then filled asynchronously from
+ * the backend.
+ */
+ private ensureModelsExist(uris: string[], connectionKey: string): void {
+ const monaco = getMonacoInstance();
+ if (!monaco) return;
+
+ const conn = this.connections.get(connectionKey);
+
+ for (const fileUri of uris) {
+ if (!fileUri.startsWith('file://')) continue;
+ const parsed = monaco.Uri.parse(fileUri);
+
+ // Skip if a model already exists for this URI
+ if (monaco.editor.getModel(parsed)) continue;
+
+ // Create a placeholder model with empty content
+ monaco.editor.createModel('', undefined, parsed);
+ this.placeholderModels.add(fileUri);
+
+ // Fetch real content asynchronously and update the model.
+ // Only for files inside the workspace — external files (e.g. Go module
+ // cache, node_modules in global paths) can't be resolved by the backend's
+ // workspace file handler, so we leave placeholder models empty.
+ if (conn?.workspacePath) {
+ const absolutePath = parsed.path; // e.g. /workspace/src/foo.ts
+ if (!absolutePath.startsWith(conn.workspacePath)) continue;
+ const relativePath = absolutePath.slice(conn.workspacePath.length + 1);
+
+ // Extract sessionId from connection key (format: "sessionId:lspLanguage")
+ const sessionId = connectionKey.split(':')[0];
+
+ // Dynamic import to avoid circular dependency
+ Promise.all([
+ import('@/lib/ws/connection'),
+ import('@/lib/ws/workspace-files'),
+ ]).then(([{ getWebSocketClient }, { requestFileContent }]) => {
+ const client = getWebSocketClient();
+ if (!client) return;
+ return requestFileContent(client, sessionId, relativePath);
+ }).then((response) => {
+ if (!response) return;
+ const model = monaco.editor.getModel(parsed);
+ if (model && this.placeholderModels.has(fileUri)) {
+ model.setValue(response.content);
+ }
+ }).catch(() => {
+ // Best effort — placeholder stays empty
+ });
+ }
+ }
+ }
+
+ /** Dispose a placeholder model (e.g. when the file is opened in a real tab). */
+ disposePlaceholderModel(fileUri: string): void {
+ const monaco = getMonacoInstance();
+ if (!monaco || !this.placeholderModels.has(fileUri)) return;
+ const parsed = monaco.Uri.parse(fileUri);
+ const model = monaco.editor.getModel(parsed);
+ if (model) model.dispose();
+ this.placeholderModels.delete(fileUri);
+ }
+
+ // ------- Document synchronization -------
+
+ openDocument(sessionId: string, lspLanguage: string, documentUri: string, languageId: string, text: string): void {
+ const key = `${sessionId}:${lspLanguage}`;
+ const conn = this.connections.get(key);
+ if (!conn?.initialized || !conn.rpc) return;
+ if (conn.openDocuments.has(documentUri)) return;
+
+ conn.openDocuments.set(documentUri, { version: 1, languageId });
+ conn.rpc.sendNotification('textDocument/didOpen', {
+ textDocument: { uri: documentUri, languageId, version: 1, text },
+ });
+ }
+
+ changeDocument(sessionId: string, lspLanguage: string, documentUri: string, text: string): void {
+ const key = `${sessionId}:${lspLanguage}`;
+ const conn = this.connections.get(key);
+ if (!conn?.initialized || !conn.rpc) return;
+ const doc = conn.openDocuments.get(documentUri);
+ if (!doc) return;
+
+ doc.version++;
+ conn.rpc.sendNotification('textDocument/didChange', {
+ textDocument: { uri: documentUri, version: doc.version },
+ contentChanges: [{ text }],
+ });
+ }
+
+ closeDocument(sessionId: string, lspLanguage: string, documentUri: string): void {
+ const key = `${sessionId}:${lspLanguage}`;
+ const conn = this.connections.get(key);
+ if (!conn?.initialized || !conn.rpc) return;
+ if (!conn.openDocuments.has(documentUri)) return;
+
+ conn.openDocuments.delete(documentUri);
+ conn.rpc.sendNotification('textDocument/didClose', {
+ textDocument: { uri: documentUri },
+ });
+ }
+
+ // ------- Helpers -------
+
+ /** Build a file:// URI from a Monaco model, or null if it can't be determined. */
+ private getDocumentUri(model: monacoEditor.ITextModel): string | null {
+ const uri = model.uri.toString();
+ // If it's already a file:// URI, use it directly
+ if (uri.startsWith('file://')) return uri;
+ // Otherwise, try to match against open documents by path
+ // Monaco @monaco-editor/react creates URIs like "inmemory://model/"
+ // or the model's URI path might match the file path we used
+ const path = model.uri.path;
+ if (path && path.startsWith('/')) return `file://${path}`;
+ return null;
+ }
+
+ private handleDiagnostics(params: { uri: string; diagnostics: Array<{ range: LspRange; message: string; severity?: number; source?: string; code?: unknown }> }) {
+ const monaco = getMonacoInstance();
+ if (!monaco) return;
+
+ // Find the matching model
+ const models = monaco.editor.getModels();
+ const targetModel = models.find((m: monacoEditor.ITextModel) => {
+ const modelUri = m.uri.toString();
+ if (modelUri === params.uri) return true;
+ // Match file:// URI to model path
+ if (params.uri.startsWith('file://')) {
+ const filePath = params.uri.replace('file://', '');
+ if (m.uri.path === filePath) return true;
+ }
+ return false;
+ });
+ if (!targetModel) return;
+
+ const markers = params.diagnostics.map((d) => ({
+ message: d.message,
+ severity: toMonacoSeverity(d.severity),
+ ...toMonacoRange(d.range),
+ source: d.source,
+ code: typeof d.code === 'object' && d.code !== null ? String((d.code as { value: unknown }).value) : d.code !== undefined ? String(d.code) : undefined,
+ }));
+
+ monaco.editor.setModelMarkers(targetModel, 'lsp', markers);
+ }
+
+ // ------- Stop / cleanup -------
+
+ stop(sessionId: string, lspLanguage: string): void {
+ const key = `${sessionId}:${lspLanguage}`;
+ const conn = this.connections.get(key);
+ if (!conn) {
+ this.statuses.delete(key);
+ this.setStatus(key, { state: 'disabled' });
+ return;
+ }
+
+ this.setStatus(key, { state: 'stopping' });
+ if (conn.idleTimer) clearTimeout(conn.idleTimer);
+
+ // Send shutdown/exit before closing
+ if (conn.rpc && conn.initialized) {
+ try {
+ conn.rpc.sendRequest('shutdown', null).then(() => {
+ conn.rpc?.sendNotification('exit', null);
+ }).catch(() => {});
+ } catch {
+ // ignore
+ }
+ }
+
+ this.cleanupConnection(key, conn);
+ this.statuses.delete(key);
+ for (const listener of this.listeners) {
+ listener(key, DISABLED_STATUS);
+ }
+ }
+
+ disconnectAll(): void {
+ for (const [key, conn] of this.connections) {
+ if (conn.idleTimer) clearTimeout(conn.idleTimer);
+ this.cleanupConnection(key, conn);
+ }
+ this.statuses.clear();
+ }
+
+ private decrementRef(key: string) {
+ const conn = this.connections.get(key);
+ if (!conn) return;
+ conn.refCount--;
+ if (conn.refCount <= 0) {
+ conn.idleTimer = setTimeout(() => {
+ this.cleanupConnection(key, conn);
+ this.statuses.delete(key);
+ for (const listener of this.listeners) {
+ listener(key, DISABLED_STATUS);
+ }
+ }, LSP_IDLE_TIMEOUT);
+ }
+ }
+
+ private disposeConnection(key: string) {
+ const conn = this.connections.get(key);
+ if (!conn) return;
+ for (const d of conn.providerDisposables) d.dispose();
+ conn.providerDisposables = [];
+ conn.rpc?.dispose();
+ conn.rpc = null;
+ conn.initialized = false;
+ conn.openDocuments.clear();
+ this.connections.delete(key);
+ }
+
+ private cleanupConnection(key: string, conn: LSPConnection) {
+ // Re-enable Monaco's built-in TS/JS providers if this was a TypeScript LSP connection
+ const lspLanguage = key.split(':')[1];
+ if (lspLanguage === 'typescript') {
+ setBuiltinTsSuppressed(false);
+ }
+
+ for (const d of conn.providerDisposables) d.dispose();
+ conn.providerDisposables = [];
+ conn.rpc?.dispose();
+ conn.rpc = null;
+ conn.initialized = false;
+ conn.openDocuments.clear();
+ try {
+ if (conn.ws.readyState <= WebSocket.OPEN) {
+ conn.ws.close();
+ }
+ } catch {
+ // ignore
+ }
+ this.connections.delete(key);
+
+ // Dispose placeholder models created for this connection
+ const monaco = getMonacoInstance();
+ if (monaco) {
+ for (const uri of this.placeholderModels) {
+ const parsed = monaco.Uri.parse(uri);
+ const model = monaco.editor.getModel(parsed);
+ if (model) model.dispose();
+ }
+ this.placeholderModels.clear();
+
+ // Clear any LSP markers from Monaco models
+ for (const model of monaco.editor.getModels()) {
+ monaco.editor.setModelMarkers(model, 'lsp', []);
+ }
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Language mapping helpers
+// ---------------------------------------------------------------------------
+
+function getMonacoLanguagesForLsp(lspLanguage: string): string[] {
+ switch (lspLanguage) {
+ case 'typescript': return ['typescript', 'javascript', 'typescriptreact', 'javascriptreact'];
+ case 'go': return ['go'];
+ case 'rust': return ['rust'];
+ case 'python': return ['python'];
+ default: return [];
+ }
+}
+
+export function toLspLanguage(monacoLanguage: string): string | null {
+ const map: Record = {
+ typescript: 'typescript',
+ javascript: 'typescript',
+ typescriptreact: 'typescript',
+ javascriptreact: 'typescript',
+ go: 'go',
+ rust: 'rust',
+ python: 'python',
+ };
+ return map[monacoLanguage] ?? null;
+}
+
+export const lspClientManager = new LSPClientManager();
diff --git a/apps/web/lib/state/editor-resolver-store.ts b/apps/web/lib/state/editor-resolver-store.ts
new file mode 100644
index 0000000000..0cf27345fb
--- /dev/null
+++ b/apps/web/lib/state/editor-resolver-store.ts
@@ -0,0 +1,51 @@
+import { create } from 'zustand';
+import { persist } from 'zustand/middleware';
+
+export type EditorContext =
+ | 'code-editor'
+ | 'diff-viewer'
+ | 'chat-code-block'
+ | 'chat-diff'
+ | 'plan-editor';
+
+export type EditorProvider = 'monaco' | 'codemirror' | 'pierre-diffs' | 'tiptap';
+
+const VALID_PROVIDERS: Record = {
+ 'code-editor': ['monaco', 'codemirror'],
+ 'diff-viewer': ['monaco', 'pierre-diffs'],
+ 'chat-code-block': ['monaco', 'codemirror'],
+ 'chat-diff': ['monaco', 'pierre-diffs'],
+ 'plan-editor': ['tiptap'],
+};
+
+const DEFAULT_PROVIDERS: Record = {
+ 'code-editor': 'monaco',
+ 'diff-viewer': 'monaco',
+ 'chat-code-block': 'monaco',
+ 'chat-diff': 'monaco',
+ 'plan-editor': 'tiptap',
+};
+
+type EditorResolverStore = {
+ providers: Record;
+ setProvider: (context: EditorContext, provider: EditorProvider) => void;
+ getProvider: (context: EditorContext) => EditorProvider;
+ getValidProviders: (context: EditorContext) => EditorProvider[];
+};
+
+export const useEditorResolverStore = create()(
+ persist(
+ (set, get) => ({
+ providers: { ...DEFAULT_PROVIDERS },
+ setProvider: (context, provider) => {
+ if (!VALID_PROVIDERS[context].includes(provider)) return;
+ set((state) => ({
+ providers: { ...state.providers, [context]: provider },
+ }));
+ },
+ getProvider: (context) => get().providers[context],
+ getValidProviders: (context) => VALID_PROVIDERS[context],
+ }),
+ { name: 'kandev-editor-providers' }
+ )
+);
diff --git a/apps/web/lib/state/slices/settings/settings-slice.ts b/apps/web/lib/state/slices/settings/settings-slice.ts
index 1c5bd5982c..d635cc536f 100644
--- a/apps/web/lib/state/slices/settings/settings-slice.ts
+++ b/apps/web/lib/state/slices/settings/settings-slice.ts
@@ -28,6 +28,9 @@ export const defaultSettingsState: SettingsSliceState = {
enablePreviewOnClick: false,
chatSubmitKey: 'cmd_enter',
reviewAutoMarkOnScroll: true,
+ lspAutoStartLanguages: [],
+ lspAutoInstallLanguages: [],
+ lspServerConfigs: {},
loaded: false,
},
};
diff --git a/apps/web/lib/state/slices/settings/types.ts b/apps/web/lib/state/slices/settings/types.ts
index 8d38dc6590..902c414c77 100644
--- a/apps/web/lib/state/slices/settings/types.ts
+++ b/apps/web/lib/state/slices/settings/types.ts
@@ -79,6 +79,9 @@ export type UserSettingsState = {
enablePreviewOnClick: boolean;
chatSubmitKey: 'enter' | 'cmd_enter';
reviewAutoMarkOnScroll: boolean;
+ lspAutoStartLanguages: string[];
+ lspAutoInstallLanguages: string[];
+ lspServerConfigs: Record>;
loaded: boolean;
};
diff --git a/apps/web/lib/theme/editor-theme.ts b/apps/web/lib/theme/editor-theme.ts
new file mode 100644
index 0000000000..d183ce05cf
--- /dev/null
+++ b/apps/web/lib/theme/editor-theme.ts
@@ -0,0 +1,107 @@
+import type { editor } from 'monaco-editor';
+
+// Semantic token color rules matching VS Code Dark+ / Light+ themes.
+// These color function names, types, variables, etc. when an LSP server
+// provides semantic tokens (textDocument/semanticTokens/full).
+const SEMANTIC_RULES_DARK: editor.ITokenThemeRule[] = [
+ // Types — teal
+ { token: 'type', foreground: '4EC9B0' },
+ { token: 'class', foreground: '4EC9B0' },
+ { token: 'struct', foreground: '4EC9B0' },
+ { token: 'interface', foreground: '4EC9B0' },
+ { token: 'enum', foreground: '4EC9B0' },
+ { token: 'typeParameter', foreground: '4EC9B0' },
+ { token: 'namespace', foreground: '4EC9B0' },
+ // Functions — yellow
+ { token: 'function', foreground: 'DCDCAA' },
+ { token: 'method', foreground: 'DCDCAA' },
+ { token: 'macro', foreground: 'DCDCAA' },
+ // Variables — light blue
+ { token: 'variable', foreground: '9CDCFE' },
+ { token: 'parameter', foreground: '9CDCFE' },
+ { token: 'property', foreground: '9CDCFE' },
+ { token: 'enumMember', foreground: '4FC1FF' },
+ // Decorators / annotations
+ { token: 'decorator', foreground: 'DCDCAA' },
+];
+
+const SEMANTIC_RULES_LIGHT: editor.ITokenThemeRule[] = [
+ // Types — dark teal
+ { token: 'type', foreground: '267f99' },
+ { token: 'class', foreground: '267f99' },
+ { token: 'struct', foreground: '267f99' },
+ { token: 'interface', foreground: '267f99' },
+ { token: 'enum', foreground: '267f99' },
+ { token: 'typeParameter', foreground: '267f99' },
+ { token: 'namespace', foreground: '267f99' },
+ // Functions — brown
+ { token: 'function', foreground: '795E26' },
+ { token: 'method', foreground: '795E26' },
+ { token: 'macro', foreground: '795E26' },
+ // Variables — dark blue
+ { token: 'variable', foreground: '001080' },
+ { token: 'parameter', foreground: '001080' },
+ { token: 'property', foreground: '001080' },
+ { token: 'enumMember', foreground: '0070C1' },
+ // Decorators
+ { token: 'decorator', foreground: '795E26' },
+];
+
+export const KANDEV_MONACO_DARK: editor.IStandaloneThemeData = {
+ base: 'vs-dark',
+ inherit: true,
+ rules: [...SEMANTIC_RULES_DARK],
+ colors: {
+ 'editor.background': '#141414',
+ 'editor.foreground': '#d4d4d4',
+ 'editorGutter.background': '#141414',
+ 'editor.lineHighlightBackground': '#1c1c1c',
+ 'editorLineNumber.foreground': '#555555',
+ 'editorLineNumber.activeForeground': '#888888',
+ 'editor.selectionBackground': '#264f78',
+ 'editor.inactiveSelectionBackground': '#3a3d41',
+ 'editorCursor.foreground': '#d4d4d4',
+ 'editorWidget.background': '#222222',
+ 'editorWidget.border': '#2a2a2a',
+ 'editorSuggestWidget.background': '#222222',
+ 'editorSuggestWidget.border': '#2a2a2a',
+ 'editorSuggestWidget.selectedBackground': '#264f78',
+ 'minimap.background': '#141414',
+ 'scrollbar.shadow': '#00000000',
+ 'scrollbarSlider.background': '#64646480',
+ 'scrollbarSlider.hoverBackground': '#82828299',
+ 'scrollbarSlider.activeBackground': '#828282bb',
+ 'diffEditor.insertedTextBackground': '#10b98126',
+ 'diffEditor.removedTextBackground': '#f43f5e26',
+ 'diffEditor.insertedLineBackground': '#10b98115',
+ 'diffEditor.removedLineBackground': '#f43f5e15',
+ },
+};
+
+export const KANDEV_MONACO_LIGHT: editor.IStandaloneThemeData = {
+ base: 'vs',
+ inherit: true,
+ rules: [...SEMANTIC_RULES_LIGHT],
+ colors: {
+ 'editor.background': '#ffffff',
+ 'editor.foreground': '#1e1e1e',
+ 'editorGutter.background': '#ffffff',
+ 'editor.lineHighlightBackground': '#f5f5f5',
+ 'editorLineNumber.foreground': '#c0c0c0',
+ 'editorLineNumber.activeForeground': '#555555',
+ 'diffEditor.insertedTextBackground': '#10b98126',
+ 'diffEditor.removedTextBackground': '#f43f5e26',
+ 'diffEditor.insertedLineBackground': '#10b98115',
+ 'diffEditor.removedLineBackground': '#f43f5e15',
+ },
+};
+
+export const PIERRE_DIFFS_THEME = {
+ dark: 'github-dark-high-contrast' as const,
+ light: 'github-light' as const,
+};
+
+export const EDITOR_FONT_FAMILY =
+ '"Geist Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace';
+export const EDITOR_FONT_SIZE = 12;
+export const EDITOR_LINE_HEIGHT = 18;
diff --git a/apps/web/lib/types/backend.ts b/apps/web/lib/types/backend.ts
index c971b5dfff..39f799fa80 100644
--- a/apps/web/lib/types/backend.ts
+++ b/apps/web/lib/types/backend.ts
@@ -285,6 +285,8 @@ export type UserSettingsUpdatedPayload = {
enable_preview_on_click?: boolean;
chat_submit_key?: string;
review_auto_mark_on_scroll?: boolean;
+ lsp_auto_start_languages?: string[];
+ lsp_auto_install_languages?: string[];
updated_at?: string;
};
diff --git a/apps/web/lib/types/http.ts b/apps/web/lib/types/http.ts
index 72e43acbb6..3cbf0e6414 100644
--- a/apps/web/lib/types/http.ts
+++ b/apps/web/lib/types/http.ts
@@ -318,6 +318,9 @@ export type UserSettings = {
enable_preview_on_click?: boolean;
chat_submit_key?: 'enter' | 'cmd_enter';
review_auto_mark_on_scroll?: boolean;
+ lsp_auto_start_languages?: string[];
+ lsp_auto_install_languages?: string[];
+ lsp_server_configs?: Record>;
updated_at: string;
};
diff --git a/apps/web/lib/ws/handlers/users.ts b/apps/web/lib/ws/handlers/users.ts
index 2c8cc6d03e..8593d84176 100644
--- a/apps/web/lib/ws/handlers/users.ts
+++ b/apps/web/lib/ws/handlers/users.ts
@@ -20,6 +20,8 @@ export function registerUsersHandlers(store: StoreApi): WsHandlers {
enablePreviewOnClick: message.payload.enable_preview_on_click ?? false,
chatSubmitKey: (message.payload.chat_submit_key as 'enter' | 'cmd_enter') ?? 'cmd_enter',
reviewAutoMarkOnScroll: message.payload.review_auto_mark_on_scroll ?? true,
+ lspAutoStartLanguages: message.payload.lsp_auto_start_languages ?? [],
+ lspAutoInstallLanguages: message.payload.lsp_auto_install_languages ?? [],
loaded: true,
},
}));
diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts
index 2925e7a1c4..f7c332d19b 100644
--- a/apps/web/next.config.ts
+++ b/apps/web/next.config.ts
@@ -11,6 +11,7 @@ const nextConfig: NextConfig = {
"192.168.1.116:3000",
],
transpilePackages: ["@kandev/ui", "@kandev/theme", "@kandev/types"],
+ turbopack: {},
};
export default nextConfig;
diff --git a/apps/web/package.json b/apps/web/package.json
index b35345e22d..09d6753ebc 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -33,31 +33,33 @@
"@kandev/types": "workspace:*",
"@kandev/ui": "workspace:*",
"@lezer/highlight": "^1.2.3",
- "@milkdown/core": "^7.18.0",
- "@milkdown/ctx": "^7.18.0",
- "@milkdown/plugin-listener": "^7.18.0",
- "@milkdown/preset-commonmark": "^7.18.0",
- "@milkdown/preset-gfm": "^7.18.0",
- "@milkdown/prose": "^7.18.0",
- "@milkdown/react": "^7.18.0",
- "@milkdown/theme-nord": "^7.18.0",
- "@milkdown/utils": "^7.18.0",
+ "@monaco-editor/react": "^4.7.0",
"@pierre/diffs": "^1.0.10",
"@tabler/icons-react": "^3.36.1",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.18",
"@tiptap/core": "^3.19.0",
+ "@tiptap/extension-bubble-menu": "^3.19.0",
"@tiptap/extension-code": "^3.19.0",
"@tiptap/extension-code-block-lowlight": "^3.19.0",
"@tiptap/extension-document": "^3.19.0",
"@tiptap/extension-hard-break": "^3.19.0",
"@tiptap/extension-history": "^3.19.0",
+ "@tiptap/extension-image": "^3.19.0",
+ "@tiptap/extension-link": "^3.19.0",
"@tiptap/extension-mention": "^3.19.0",
"@tiptap/extension-paragraph": "^3.19.0",
"@tiptap/extension-placeholder": "^3.19.0",
+ "@tiptap/extension-table": "^3.19.0",
+ "@tiptap/extension-table-cell": "^3.19.0",
+ "@tiptap/extension-table-header": "^3.19.0",
+ "@tiptap/extension-table-row": "^3.19.0",
+ "@tiptap/extension-task-item": "^3.19.0",
+ "@tiptap/extension-task-list": "^3.19.0",
"@tiptap/extension-text": "^3.19.0",
"@tiptap/pm": "^3.19.0",
"@tiptap/react": "^3.19.0",
+ "@tiptap/starter-kit": "^3.19.0",
"@tiptap/suggestion": "^3.19.0",
"@uiw/codemirror-theme-github": "^4.25.4",
"@uiw/codemirror-theme-vscode": "^4.25.4",
@@ -78,6 +80,7 @@
"input-otp": "^1.4.2",
"lowlight": "^3.3.0",
"mermaid": "^11.12.2",
+ "monaco-editor": "^0.55.1",
"next": "16.1.1",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
@@ -93,6 +96,7 @@
"sonner": "^2.0.7",
"styled-jsx": "5.1.6",
"tailwind-merge": "^3.4.0",
+ "tiptap-markdown": "^0.9.0",
"tw-animate-css": "^1.4.0",
"vaul": "^1.1.2",
"zustand": "^5.0.8"