diff --git a/internal/api/router.go b/internal/api/router.go index 2ba87a0..dca89db 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -49,6 +49,7 @@ func NewRouter(workspaces *service.WorkspaceService, files *service.FileService, api.POST("/workspaces/:id/shell/restart", shellHandler.restart) api.GET("/workspaces/:id/shell/status", shellHandler.status) api.GET("/workspaces/:id/shell/ws", shellHandler.ws) + api.POST("/workspaces/:id/shell/resize", shellHandler.resize) api.GET("/workspaces/:id/acp/status", acpHandler.status) api.GET("/workspaces/:id/acp/history", acpHandler.history) api.POST("/workspaces/:id/acp/prompt", acpHandler.prompt) diff --git a/internal/api/shell_handler.go b/internal/api/shell_handler.go index 760181b..3f2df1e 100644 --- a/internal/api/shell_handler.go +++ b/internal/api/shell_handler.go @@ -53,6 +53,24 @@ func (h *shellHandler) restart(c *gin.Context) { c.Status(http.StatusNoContent) } +func (h *shellHandler) resize(c *gin.Context) { + id := c.Param("id") + var req model.ShellResizeRequest + if err := c.ShouldBindJSON(&req); err != nil { + writeBadRequest(c, "invalid json") + return + } + if req.Cols <= 0 || req.Rows <= 0 { + writeBadRequest(c, "cols and rows must be positive") + return + } + if err := h.svc.Resize(id, req.Cols, req.Rows); err != nil { + writeError(c, err) + return + } + c.Status(http.StatusNoContent) +} + func (h *shellHandler) status(c *gin.Context) { id := c.Param("id") status, err := h.svc.Status(id) diff --git a/internal/model/shell.go b/internal/model/shell.go index 7ac0eaf..06d6f9a 100644 --- a/internal/model/shell.go +++ b/internal/model/shell.go @@ -6,3 +6,9 @@ type ShellStatusResponse struct { Running bool `json:"running"` PID int `json:"pid,omitempty"` } + +// ShellResizeRequest is the body for POST /shell/resize. +type ShellResizeRequest struct { + Cols int `json:"cols"` + Rows int `json:"rows"` +} diff --git a/internal/service/shell_service.go b/internal/service/shell_service.go index cd9cc17..9bd24c8 100644 --- a/internal/service/shell_service.go +++ b/internal/service/shell_service.go @@ -65,6 +65,19 @@ func (s *ShellService) Restart(workspaceID string) error { return nil } +// Resize resizes the PTY for the given workspace. +func (s *ShellService) Resize(workspaceID string, cols, rows int) error { + if _, err := s.workspaces.Get(workspaceID); err != nil { + return err + } + if err := s.shells.Resize(workspaceID, cols, rows); err != nil { + s.logger.Error("shell resize failed", "workspace_id", workspaceID, "error", err) + return err + } + s.logger.Info("shell resized", "workspace_id", workspaceID, "cols", cols, "rows", rows) + return nil +} + // Status returns the shell status for the given workspace. func (s *ShellService) Status(workspaceID string) (shell.Status, error) { if _, err := s.workspaces.Get(workspaceID); err != nil { diff --git a/internal/shell/manager.go b/internal/shell/manager.go index 6b48b80..192a040 100644 --- a/internal/shell/manager.go +++ b/internal/shell/manager.go @@ -29,6 +29,7 @@ type Manager interface { Subscribe(workspaceID string) (Subscription, error) Stdin(workspaceID string) (io.WriteCloser, error) ExitStatus(workspaceID string) (ExitInfo, error) + Resize(workspaceID string, cols, rows int) error } // LocalManager implements Manager using local OS processes. @@ -203,6 +204,25 @@ func (m *LocalManager) Restart(workspaceID string, workspaceRoot string) error { return m.Start(workspaceID, workspaceRoot) } +// Resize resizes the PTY for the given workspace. +// Returns CodeNotFound if no session exists, or CodeBadRequest for invalid dimensions. +func (m *LocalManager) Resize(workspaceID string, cols, rows int) error { + m.mu.Lock() + sess, ok := m.sessions[workspaceID] + m.mu.Unlock() + if !ok { + return util.New(util.CodeNotFound, "no running shell for workspace") + } + if cols <= 0 || rows <= 0 || cols > 10000 || rows > 10000 { + return util.New(util.CodeBadRequest, "invalid cols/rows") + } + f, ok := sess.Stdin.(*os.File) + if !ok { + return util.New(util.CodeInternal, "shell stdin is not a pty file") + } + return pty.Setsize(f, &pty.Winsize{Cols: uint16(cols), Rows: uint16(rows), X: 0, Y: 0}) +} + // Status returns the current shell status for the workspace. func (m *LocalManager) Status(workspaceID string) Status { m.mu.Lock() diff --git a/internal/shell/manager_test.go b/internal/shell/manager_test.go index 642f30b..45874b2 100644 --- a/internal/shell/manager_test.go +++ b/internal/shell/manager_test.go @@ -9,6 +9,14 @@ import ( "time" ) +import ( + "os" + + "codespace/internal/util" + + "github.com/creack/pty" +) + func startTestShell(t *testing.T) (*LocalManager, io.WriteCloser, Subscription) { t.Helper() mgr := NewManager("bash", []string{"-i"}) @@ -84,3 +92,45 @@ func TestShellPTYTermEnv(t *testing.T) { t.Errorf("expected TERM=xterm-256color in output, got:\n%s", out) } } + +func TestShellResizeUpdatesPTYSize(t *testing.T) { + mgr, stdin, sub := startTestShell(t) + _ = sub + + f, ok := stdin.(*os.File) + if !ok { + t.Fatal("stdin is not a pty file") + } + + if err := mgr.Resize("test-ws", 120, 40); err != nil { + t.Fatalf("Resize failed: %v", err) + } + + rows, cols, err := pty.Getsize(f) + if err != nil { + t.Fatalf("Getsize failed: %v", err) + } + if cols != 120 || rows != 40 { + t.Errorf("expected cols=120 rows=40, got cols=%d rows=%d", cols, rows) + } +} + +func TestShellResizeRejectsInvalidSize(t *testing.T) { + mgr, _, _ := startTestShell(t) + + if err := mgr.Resize("test-ws", 0, 40); err == nil { + t.Fatal("expected error for invalid size") + } else if util.CodeOf(err) != util.CodeBadRequest { + t.Errorf("expected CodeBadRequest, got %v", util.CodeOf(err)) + } +} + +func TestShellResizeRejectsMissingSession(t *testing.T) { + mgr := NewManager("bash", []string{"-i"}) + + if err := mgr.Resize("missing-ws", 80, 24); err == nil { + t.Fatal("expected error for missing session") + } else if util.CodeOf(err) != util.CodeNotFound { + t.Errorf("expected CodeNotFound, got %v", util.CodeOf(err)) + } +} diff --git a/web/src/components/terminal/TerminalPanel.tsx b/web/src/components/terminal/TerminalPanel.tsx index 0d411d1..9e1fe97 100644 --- a/web/src/components/terminal/TerminalPanel.tsx +++ b/web/src/components/terminal/TerminalPanel.tsx @@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button"; import { useShellStart, useShellStatus, + useShellResize, useShellWebSocket, } from "@/lib/api/process"; @@ -26,6 +27,8 @@ export function TerminalPanel({ workspaceId }: TerminalPanelProps) { const { status, send, onData } = useShellWebSocket(workspaceId, shellRunning); const startShell = useShellStart(); + const resize = useShellResize(); + // Initialize xterm once and keep it alive across WS status changes. useEffect(() => { const container = containerRef.current; @@ -33,6 +36,8 @@ export function TerminalPanel({ workspaceId }: TerminalPanelProps) { let terminal: Terminal | undefined; let fitAddon: FitAddon | undefined; + let debounceRef: number | undefined; + let removeResize: (() => void) | undefined; try { terminal = new Terminal({ @@ -55,6 +60,20 @@ export function TerminalPanel({ workspaceId }: TerminalPanelProps) { fitAddon = new FitAddon(); terminal.loadAddon(fitAddon); terminal.open(container); + + removeResize = terminal.onResize(({ cols, rows }) => { + if (!workspaceId) return; + if (cols <= 0 || rows <= 0) return; + if (debounceRef !== undefined) { + window.clearTimeout(debounceRef); + } + debounceRef = window.setTimeout(() => { + debounceRef = undefined; + if (!workspaceId) return; + resize.mutate({ workspaceId, cols, rows }); + }, 100); + }).dispose; + terminalRef.current = terminal; fitAddonRef.current = fitAddon; } catch { @@ -91,6 +110,11 @@ export function TerminalPanel({ workspaceId }: TerminalPanelProps) { if (rafId !== undefined) { cancelAnimationFrame(rafId); } + if (debounceRef !== undefined) { + window.clearTimeout(debounceRef); + debounceRef = undefined; + } + removeResize?.(); resizeObserver.disconnect(); window.removeEventListener("resize", handleResize); terminal?.dispose(); @@ -98,7 +122,7 @@ export function TerminalPanel({ workspaceId }: TerminalPanelProps) { terminalRef.current = null; fitAddonRef.current = null; }; - }, []); + }, [resize, workspaceId]); // Reset per-workspace state and wire the active socket to xterm. useEffect(() => { diff --git a/web/src/lib/api/process.ts b/web/src/lib/api/process.ts index be9ce19..3c00baf 100644 --- a/web/src/lib/api/process.ts +++ b/web/src/lib/api/process.ts @@ -148,7 +148,31 @@ export function useShellStop(): UseMutationResult { } export function useShellRestart(): UseMutationResult { - return useShellActionMutation("restart"); + return useShellActionMutation("restart"); +} + +export interface ShellResizeVariables { + workspaceId: string; + cols: number; + rows: number; +} + +export function useShellResize(): UseMutationResult { + return useMutation({ + mutationFn: async ({ workspaceId, cols, rows }) => { + const res = await apiClient( + `/api/workspaces/${encodeURIComponent(workspaceId)}/shell/resize`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ cols, rows }), + }, + ); + if (!res.ok) { + throw new Error(`Failed to resize shell: ${res.status}`); + } + }, + }); } export type ProcessWebSocketStatus =