From 27b19d46983aabd73d4d16daa249eb28e62ad5b4 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Tue, 24 Feb 2026 02:44:08 +0100 Subject: [PATCH] feat: Nerd Font support, clipboard paste, and PTY environment fixes - Add ttyd-nerd-font submodule with embedded JetBrains Mono Nerd Font for proper icon rendering in browser via ttyd - Add Ctrl+Shift+V / Shift+Insert paste support in ttyd frontend - Strip Zellij env vars from PTY child processes to prevent workspace terminal from inheriting Zellij session context - Launch PTY commands through login shell for proper PATH resolution - Use /etc/passwd shell lookup instead of $SHELL (Zellij overrides it) - Soften ANSI 256-color palette to match modern dark themes - Handle Nerd Font PUA glyphs as width 1 in pyte terminal emulator - Fix brightmagenta typo in pyte graphics - Update install script to build ttyd from submodule source - Add web deployment TODO items for image paste and context menu Co-Authored-By: Claude Opus 4.6 --- .gitmodules | 3 ++ TODO.md | 5 ++ clide/app.py | 27 +++++++++- clide/vendor/pyte/graphics.py | 36 ++++++------- clide/vendor/pyte/screens.py | 18 ++++++- clide/widgets/components/terminal_display.py | 54 ++++++++++++++------ clide/widgets/components/terminal_pane.py | 15 +++++- clide/widgets/panels/claude.py | 1 + deploy/clide-launcher | 25 ++++++--- deploy/clide-web.service | 3 +- deploy/install-clide-web.sh | 15 +++++- deploy/ttyd-nerd-font | 1 + deploy/update-ttyd.sh | 12 +++++ 13 files changed, 167 insertions(+), 48 deletions(-) create mode 100644 .gitmodules create mode 160000 deploy/ttyd-nerd-font create mode 100755 deploy/update-ttyd.sh diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..761dda21 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "deploy/ttyd-nerd-font"] + path = deploy/ttyd-nerd-font + url = https://github.com/metorm/ttyd-nerd-font.git diff --git a/TODO.md b/TODO.md index 749eaff7..3a62d46d 100644 --- a/TODO.md +++ b/TODO.md @@ -110,6 +110,11 @@ Long-term open items for Clide development. - [ ] Plugin development guide - [ ] Architecture documentation updates +## Web Deployment (ttyd) + +- [ ] Image paste support: intercept browser clipboard image on Ctrl+V, upload blob to server, save as temp file. Claude Code already handles Ctrl+V as image paste — just needs the image on the filesystem +- [ ] Right-click context menu for copy/paste in web terminal + ## Build & Distribution - [ ] PyInstaller builds for macOS diff --git a/clide/app.py b/clide/app.py index 64d9ec3a..c488f1d3 100644 --- a/clide/app.py +++ b/clide/app.py @@ -192,7 +192,7 @@ class ClideApp(App[None]): test_mode: bool = False, ) -> None: super().__init__() - self.workdir = workdir or Path.cwd() + self.workdir = self._resolve_workdir(workdir) self.settings = settings or ClideSettings() self._test_mode = test_mode @@ -221,6 +221,31 @@ class ClideApp(App[None]): # Register additional syntax highlighting languages register_languages() + @staticmethod + def _resolve_workdir(workdir: Path | None) -> Path: + """Resolve the working directory to the git root when possible.""" + import subprocess + + target = workdir.resolve() if workdir and workdir.is_dir() else Path.cwd() + + # Find git root -- anchors to the project root even if cwd is a subdirectory + try: + result = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + cwd=str(target), + capture_output=True, + text=True, + timeout=3, + ) + if result.returncode == 0: + git_root = Path(result.stdout.strip()) + if git_root.is_dir(): + return git_root + except (subprocess.TimeoutExpired, FileNotFoundError, OSError): + pass + + return target + def _register_themes(self) -> None: """Register all themes with Textual.""" for theme_meta in get_all_themes(): diff --git a/clide/vendor/pyte/graphics.py b/clide/vendor/pyte/graphics.py index 61bbc732..1d8b669e 100644 --- a/clide/vendor/pyte/graphics.py +++ b/clide/vendor/pyte/graphics.py @@ -99,7 +99,7 @@ BG_AIXTERM = { 102: "brightgreen", 103: "brightbrown", 104: "brightblue", - 105: "bfightmagenta", + 105: "brightmagenta", 106: "brightcyan", 107: "brightwhite", } @@ -111,24 +111,24 @@ FG_256 = 38 BG_256 = 48 #: A table of 256 foreground or background colors. -# The following code is part of the Pygments project (BSD licensed). +#: First 16 entries softened from xterm defaults to match modern terminal themes. _FG_BG_256 = [ - (0x00, 0x00, 0x00), # 0 - (0xCD, 0x00, 0x00), # 1 - (0x00, 0xCD, 0x00), # 2 - (0xCD, 0xCD, 0x00), # 3 - (0x00, 0x00, 0xEE), # 4 - (0xCD, 0x00, 0xCD), # 5 - (0x00, 0xCD, 0xCD), # 6 - (0xE5, 0xE5, 0xE5), # 7 - (0x7F, 0x7F, 0x7F), # 8 - (0xFF, 0x00, 0x00), # 9 - (0x00, 0xFF, 0x00), # 10 - (0xFF, 0xFF, 0x00), # 11 - (0x5C, 0x5C, 0xFF), # 12 - (0xFF, 0x00, 0xFF), # 13 - (0x00, 0xFF, 0xFF), # 14 - (0xFF, 0xFF, 0xFF), # 15 + (0x28, 0x2C, 0x34), # 0 black + (0xE0, 0x6C, 0x75), # 1 red + (0x98, 0xC3, 0x79), # 2 green + (0xE5, 0xC0, 0x7B), # 3 yellow + (0x61, 0xAF, 0xEF), # 4 blue + (0xC6, 0x78, 0xDD), # 5 magenta + (0x56, 0xB6, 0xC2), # 6 cyan + (0xAB, 0xB2, 0xBF), # 7 white + (0x5C, 0x63, 0x70), # 8 bright black + (0xE0, 0x6C, 0x75), # 9 bright red + (0x98, 0xC3, 0x79), # 10 bright green + (0xE5, 0xC0, 0x7B), # 11 bright yellow + (0x61, 0xAF, 0xEF), # 12 bright blue + (0xC6, 0x78, 0xDD), # 13 bright magenta + (0x56, 0xB6, 0xC2), # 14 bright cyan + (0xFF, 0xFF, 0xFF), # 15 bright white ] # colors 16..231: the 6x6x6 color cube diff --git a/clide/vendor/pyte/screens.py b/clide/vendor/pyte/screens.py index 2db9c1fb..92baca8a 100644 --- a/clide/vendor/pyte/screens.py +++ b/clide/vendor/pyte/screens.py @@ -310,6 +310,20 @@ class Screen: for char in data: char_width = wcwidth(char) + # Treat Private Use Area and other nerd font glyphs as width 1. + # wcwidth returns -1 for PUA chars (U+E000-U+F8FF, U+F0000-U+FFFFF) + # which are used by Nerd Fonts for icons in tmux status bars, etc. + if char_width < 0: + code = ord(char) + if (0xE000 <= code <= 0xF8FF or # BMP Private Use Area + 0xF0000 <= code <= 0xFFFFF or # Supplementary PUA-A + 0x100000 <= code <= 0x10FFFF or # Supplementary PUA-B + 0x2580 <= code <= 0x259F or # Block Elements + 0x1F000 <= code <= 0x1FFFF): # Symbols/Emoji + char_width = 1 + else: + continue # Skip truly unprintable chars instead of breaking + # Clide: Log character drawing for debugging if _debug_logger is not None and char_width > 0: code = ord(char) @@ -347,8 +361,10 @@ class Screen: self.buffer[self.cursor.y - 1][self.columns - 1] = last._replace( data=normalized ) + elif char_width == 0: + continue # Skip zero-width non-combining chars else: - break + continue # Skip any remaining unhandled chars if char_width > 0: self.cursor.x = min(self.cursor.x + char_width, self.columns) diff --git a/clide/widgets/components/terminal_display.py b/clide/widgets/components/terminal_display.py index 6e5b9719..a0022174 100644 --- a/clide/widgets/components/terminal_display.py +++ b/clide/widgets/components/terminal_display.py @@ -181,11 +181,29 @@ class TerminalDisplay(Widget, can_focus=True): if pid == 0: # Child process os.chdir(cwd) + + # Strip Zellij env vars so child processes don't think + # they're inside a Zellij session (Clide may run inside + # Zellij for web deployment session persistence) + for key in list(os.environ): + if key.startswith("ZELLIJ"): + del os.environ[key] + os.environ["TERM"] = "xterm-256color" os.environ["COLORTERM"] = "truecolor" os.environ["COLUMNS"] = str(self._cols) os.environ["LINES"] = str(self._rows) - os.execlp(command, command) + # Launch through a login shell so profile scripts are sourced + # and PATH includes locations like ~/.local/bin where Claude's + # native binary may be installed. + # Use /etc/passwd shell, not $SHELL (which Zellij may override) + import pwd + + try: + shell = pwd.getpwuid(os.getuid()).pw_shell + except KeyError: + shell = "/bin/bash" + os.execvp(shell, [shell, "-l", "-c", command]) else: # Parent process self._pid = pid @@ -321,23 +339,25 @@ class TerminalDisplay(Widget, can_focus=True): except OSError: pass - # ANSI 256-color palette (standard 16 colors) + # ANSI 256-color palette (standard 16 colors, softened for dark themes) ANSI_COLORS = { - "black": "#000000", - "red": "#cd0000", - "green": "#00cd00", - "yellow": "#cdcd00", - "blue": "#0000ee", - "magenta": "#cd00cd", - "cyan": "#00cdcd", - "white": "#e5e5e5", - "brightblack": "#7f7f7f", - "brightred": "#ff0000", - "brightgreen": "#00ff00", - "brightyellow": "#ffff00", - "brightblue": "#5c5cff", - "brightmagenta": "#ff00ff", - "brightcyan": "#00ffff", + "black": "#282c34", + "red": "#e06c75", + "green": "#98c379", + "brown": "#e5c07b", + "yellow": "#e5c07b", + "blue": "#61afef", + "magenta": "#c678dd", + "cyan": "#56b6c2", + "white": "#abb2bf", + "brightblack": "#5c6370", + "brightred": "#e06c75", + "brightgreen": "#98c379", + "brightbrown": "#e5c07b", + "brightyellow": "#e5c07b", + "brightblue": "#61afef", + "brightmagenta": "#c678dd", + "brightcyan": "#56b6c2", "brightwhite": "#ffffff", } diff --git a/clide/widgets/components/terminal_pane.py b/clide/widgets/components/terminal_pane.py index 6a73f5b2..f0355eb4 100644 --- a/clide/widgets/components/terminal_pane.py +++ b/clide/widgets/components/terminal_pane.py @@ -33,7 +33,20 @@ class TerminalPane(Vertical): super().__init__(**kwargs) self._cwd = cwd or Path.cwd() self._terminal: TerminalDisplay | None = None - self._shell = os.environ.get("SHELL", "/bin/bash") + self._shell = self._find_shell() + + @staticmethod + def _find_shell() -> str: + """Find the user's real shell, ignoring Zellij overrides.""" + import pwd + + # Get shell from /etc/passwd (most reliable) + try: + return pwd.getpwuid(os.getuid()).pw_shell + except KeyError: + pass + # Fallback + return "/bin/bash" def compose(self) -> ComposeResult: yield Static(f"Terminal - {self._cwd}", classes="terminal-header") diff --git a/clide/widgets/panels/claude.py b/clide/widgets/panels/claude.py index d7b2f5bb..bbe62dc6 100644 --- a/clide/widgets/panels/claude.py +++ b/clide/widgets/panels/claude.py @@ -32,6 +32,7 @@ class ClaudePanel(Vertical): ClaudePanel.with-workspace { height: 40%; + border-top: solid $surface; } ClaudePanel TerminalDisplay { diff --git a/deploy/clide-launcher b/deploy/clide-launcher index ad253625..2fa061d7 100755 --- a/deploy/clide-launcher +++ b/deploy/clide-launcher @@ -57,12 +57,23 @@ fi SESSION_NAME="clide-$PROJECT" -# Check if session exists (live or dead) -cd "$WORKDIR" -if zellij list-sessions 2>/dev/null | grep -q "$SESSION_NAME"; then - # Attach to existing session (resurrects dead sessions automatically) - exec zellij attach "$SESSION_NAME" +# Clean up dead/exited zellij sessions for this project +if zellij list-sessions 2>/dev/null | grep -q "$SESSION_NAME.*EXITED"; then + zellij delete-session "$SESSION_NAME" 2>/dev/null || true +fi + +# Try to attach to a live session, otherwise create fresh +if zellij list-sessions 2>/dev/null | grep -q "^$SESSION_NAME "; then + # Session exists and is alive -- attach + exec zellij attach "$SESSION_NAME" \ + options --no-pane-frames --default-cwd "$WORKDIR" else - # Create new session - exec zellij --session "$SESSION_NAME" options --default-shell "$CLIDE_BIN" --default-cwd "$WORKDIR" + # No live session -- kill any zombie remnants and start fresh + zellij delete-session "$SESSION_NAME" 2>/dev/null || true + + exec zellij --session "$SESSION_NAME" \ + options \ + --no-pane-frames \ + --default-shell "$CLIDE_BIN" \ + --default-cwd "$WORKDIR" fi diff --git a/deploy/clide-web.service b/deploy/clide-web.service index f20932bc..257a7c17 100644 --- a/deploy/clide-web.service +++ b/deploy/clide-web.service @@ -13,7 +13,8 @@ WorkingDirectory=/mnt/media/Projects # -W : Writable (allows input) # -a : Allow URL arguments (passes ?project=X as command arg) # -t fontSize=14: Terminal font size -ExecStart=/usr/local/bin/ttyd -p 8888 -W -a -t fontSize=14 /usr/local/bin/clide-launcher +# -t fontFamily: Use embedded JetBrains Mono Nerd Font (from ttyd-nerd-font fork) +ExecStart=/usr/local/bin/ttyd -p 8888 -W -a -t fontSize=14 -t 'fontFamily=JetBrains,monospace' /usr/local/bin/clide-launcher # Kill all child processes (ttyd → zellij → clide → claude) KillMode=control-group diff --git a/deploy/install-clide-web.sh b/deploy/install-clide-web.sh index b303ee7f..ccd5602e 100644 --- a/deploy/install-clide-web.sh +++ b/deploy/install-clide-web.sh @@ -16,9 +16,20 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SUDO_USER="${SUDO_USER:-$(logname)}" USER_HOME=$(eval echo ~"$SUDO_USER") -echo "[1/6] Installing ttyd..." -curl -L https://github.com/tsl0922/ttyd/releases/download/1.7.7/ttyd.x86_64 -o /usr/local/bin/ttyd +echo "[1/6] Building ttyd (with Nerd Font support)..." +TTYD_SRC="$SCRIPT_DIR/ttyd-nerd-font" +if [[ ! -d "$TTYD_SRC/src" ]]; then + echo "Error: ttyd-nerd-font submodule not initialized." + echo "Run: git submodule update --init deploy/ttyd-nerd-font" + exit 1 +fi +mkdir -p "$TTYD_SRC/build" +cd "$TTYD_SRC/build" +cmake .. -DCMAKE_BUILD_TYPE=Release >/dev/null 2>&1 +make -j"$(nproc)" >/dev/null 2>&1 +cp "$TTYD_SRC/build/ttyd" /usr/local/bin/ttyd chmod +x /usr/local/bin/ttyd +cd "$SCRIPT_DIR" ttyd --version echo "" diff --git a/deploy/ttyd-nerd-font b/deploy/ttyd-nerd-font new file mode 160000 index 00000000..8a6e9522 --- /dev/null +++ b/deploy/ttyd-nerd-font @@ -0,0 +1 @@ +Subproject commit 8a6e952254bc7a178331494dac1f1c267abc291b diff --git a/deploy/update-ttyd.sh b/deploy/update-ttyd.sh new file mode 100755 index 00000000..af2e4e9f --- /dev/null +++ b/deploy/update-ttyd.sh @@ -0,0 +1,12 @@ +#!/bin/bash +# Temporary script to install ttyd-nerd-font and restart service +set -e + +systemctl stop clide-web +cp /mnt/media/Projects/clide/deploy/ttyd-nerd-font/build/ttyd /usr/local/bin/ttyd +cp /mnt/media/Projects/clide/deploy/clide-web.service /etc/systemd/system/clide-web.service +systemctl daemon-reload +systemctl start clide-web + +echo "Done. ttyd version: $(ttyd --version)" +systemctl status clide-web --no-pager