diff --git a/.github/workflows/aur-release.yml b/.github/workflows/aur-release.yml index 9cdbd25..63b2555 100644 --- a/.github/workflows/aur-release.yml +++ b/.github/workflows/aur-release.yml @@ -120,7 +120,7 @@ jobs: install -dm755 "${pkgdir}/usr/bin" cat > "${pkgdir}/usr/bin/friendly-kobold" << 'WRAPPER' #!/bin/bash - exec /opt/friendly-kobold/friendly-kobold "$@" + exec "/opt/friendly-kobold/Friendly Kobold" "$@" WRAPPER chmod +x "${pkgdir}/usr/bin/friendly-kobold" @@ -140,8 +140,15 @@ jobs: # Install icon install -dm755 "${pkgdir}/usr/share/pixmaps" + # Try different possible icon locations if [ -f "${pkgdir}/opt/friendly-kobold/resources/assets/icon.png" ]; then cp "${pkgdir}/opt/friendly-kobold/resources/assets/icon.png" "${pkgdir}/usr/share/pixmaps/friendly-kobold.png" + elif [ -f "${pkgdir}/opt/friendly-kobold/assets/icon.png" ]; then + cp "${pkgdir}/opt/friendly-kobold/assets/icon.png" "${pkgdir}/usr/share/pixmaps/friendly-kobold.png" + elif [ -f "${pkgdir}/opt/friendly-kobold/icon.png" ]; then + cp "${pkgdir}/opt/friendly-kobold/icon.png" "${pkgdir}/usr/share/pixmaps/friendly-kobold.png" + else + echo "Warning: Could not find icon.png in expected locations" fi } EOF diff --git a/package.json b/package.json index df70023..94abfce 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "friendly-kobold", "productName": "Friendly Kobold", - "version": "0.5.7", + "version": "0.5.8", "description": "A modern desktop app for running Large Language Models locally", "main": "out/main/index.js", "homepage": "./", @@ -88,6 +88,9 @@ "dependencies": { "@mantine/core": "^8.2.7", "@mantine/hooks": "^8.2.7", + "@xterm/addon-fit": "^0.10.0", + "@xterm/addon-web-links": "^0.11.0", + "@xterm/xterm": "^5.5.0", "execa": "^9.6.0", "got": "^14.4.7", "lucide-react": "^0.541.0", diff --git a/src/components/screens/Interface/TerminalTab.tsx b/src/components/screens/Interface/TerminalTab.tsx index c5b1ea8..d9d300d 100644 --- a/src/components/screens/Interface/TerminalTab.tsx +++ b/src/components/screens/Interface/TerminalTab.tsx @@ -1,212 +1,169 @@ -import { useState, useEffect, useRef } from 'react'; -import { - Box, - ScrollArea, - Text, - ActionIcon, - useComputedColorScheme, -} from '@mantine/core'; -import { ChevronDown } from 'lucide-react'; -import styles from '@/styles/layout.module.css'; +import { useEffect, useRef } from 'react'; +import { Box, useComputedColorScheme } from '@mantine/core'; +import { Terminal } from '@xterm/xterm'; +import { FitAddon } from '@xterm/addon-fit'; +import { WebLinksAddon } from '@xterm/addon-web-links'; +import '@xterm/xterm/css/xterm.css'; import { UI } from '@/constants'; interface TerminalTabProps { onServerReady?: (serverUrl: string) => void; } -const handleCarriageReturns = ( - prevContent: string, - newData: string -): string => { - if (!newData.includes('\r')) { - return prevContent + newData; - } - - try { - let result = prevContent; - let i = 0; - - while (i < newData.length) { - const char = newData[i]; - - if (char === '\r') { - const nextChar = newData[i + 1]; - - if (nextChar === '\n') { - result += '\n'; - i += 2; - } else { - const lines = result.split('\n'); - if (lines.length > 0) { - const restOfData = newData.slice(i + 1); - const nextNewlinePos = restOfData.indexOf('\n'); - const replacementText = - nextNewlinePos === -1 - ? restOfData - : restOfData.slice(0, nextNewlinePos); - - lines[lines.length - 1] = replacementText; - result = lines.join('\n'); - - i += 1 + replacementText.length; - if (nextNewlinePos !== -1) { - result += '\n'; - i += 1; - } - } else { - i++; - } - } - } else { - result += char; - i++; - } - } - - return result; - } catch (error) { - window.electronAPI.logs.logError('Terminal CR Error', error as Error); - return prevContent + newData; - } -}; - export const TerminalTab = ({ onServerReady }: TerminalTabProps) => { const computedColorScheme = useComputedColorScheme('light', { getInitialValueInEffect: false, }); - const [terminalContent, setTerminalContent] = useState(''); - const [isUserScrolling, setIsUserScrolling] = useState(false); - const [shouldAutoScroll, setShouldAutoScroll] = useState(true); - const scrollAreaRef = useRef(null); - const viewportRef = useRef(null); - const lastScrollTop = useRef(0); + const terminalRef = useRef(null); + const xtermRef = useRef(null); + const fitAddonRef = useRef(null); const isDark = computedColorScheme === 'dark'; - const handleScroll = ({ y }: { y: number }) => { - if (!viewportRef.current) return; + useEffect(() => { + if (!terminalRef.current) return; - const { scrollHeight, clientHeight } = viewportRef.current; - const isAtBottomNow = y + clientHeight >= scrollHeight - 10; + const terminal = new Terminal({ + theme: { + background: '#1a1b1e', + foreground: '#ffffff', + cursor: '#ffffff', + selectionBackground: '#264f78', + }, + fontFamily: + 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace', + fontSize: 14, + lineHeight: 1.4, + cursorBlink: false, + disableStdin: true, + allowTransparency: false, + scrollback: 10000, + convertEol: true, + }); - if (y < lastScrollTop.current) { - setIsUserScrolling(true); - setShouldAutoScroll(false); - } else if (isAtBottomNow) { - setIsUserScrolling(false); - setShouldAutoScroll(true); - } + const fitAddon = new FitAddon(); + const webLinksAddon = new WebLinksAddon(); - lastScrollTop.current = y; - }; + terminal.loadAddon(fitAddon); + terminal.loadAddon(webLinksAddon); + + terminal.open(terminalRef.current); + + setTimeout(() => { + fitAddon.fit(); + }, 100); + + xtermRef.current = terminal; + fitAddonRef.current = fitAddon; + + terminal.writeln('\x1b[90mStarting KoboldCpp...\x1b[0m'); + + return () => { + terminal.dispose(); + xtermRef.current = null; + fitAddonRef.current = null; + }; + }, []); useEffect(() => { - if (shouldAutoScroll && !isUserScrolling && viewportRef.current) { - const viewport = viewportRef.current; - viewport.scrollTop = viewport.scrollHeight; + if (xtermRef.current) { + const newTheme = { + background: isDark ? '#1a1b1e' : '#ffffff', + foreground: isDark ? '#ffffff' : '#000000', + cursor: isDark ? '#ffffff' : '#000000', + selectionBackground: isDark ? '#264f78' : '#0078d4', + }; + + xtermRef.current.options.theme = newTheme; + + const element = xtermRef.current.element; + if (element) { + element.style.backgroundColor = newTheme.background; + element.style.color = newTheme.foreground; + } + + xtermRef.current.refresh(0, xtermRef.current.rows - 1); } - }, [terminalContent, shouldAutoScroll, isUserScrolling]); + }, [isDark]); + + useEffect(() => { + const handleResize = () => { + if (fitAddonRef.current && xtermRef.current) { + setTimeout(() => { + fitAddonRef.current?.fit(); + }, 50); + } + }; + + const resizeObserver = new ResizeObserver(handleResize); + if (terminalRef.current) { + resizeObserver.observe(terminalRef.current); + } + + window.addEventListener('resize', handleResize); + + return () => { + window.removeEventListener('resize', handleResize); + resizeObserver.disconnect(); + }; + }, []); useEffect(() => { const cleanup = window.electronAPI.kobold.onKoboldOutput((data: string) => { - setTerminalContent((prev) => { - const newData = data.toString(); + if (!xtermRef.current) return; - if ( - onServerReady && - newData.includes('Please connect to custom endpoint at ') - ) { - const match = newData.match( - /Please connect to custom endpoint at (http:\/\/[^\s]+)/ - ); - if (match) { - const serverUrl = match[1]; - setTimeout(() => onServerReady(serverUrl), 1500); - } + const newData = data.toString(); + + if ( + onServerReady && + newData.includes('Please connect to custom endpoint at ') + ) { + const match = newData.match( + /Please connect to custom endpoint at (http:\/\/[^\s]+)/ + ); + if (match) { + const serverUrl = match[1]; + setTimeout(() => onServerReady(serverUrl), 1500); } + } - return handleCarriageReturns(prev, newData); - }); + xtermRef.current.write(newData); + + setTimeout(() => { + if (xtermRef.current) { + xtermRef.current.scrollToBottom(); + } + }, 0); }); return cleanup; }, [onServerReady]); - const scrollToBottom = () => { - if (viewportRef.current) { - const viewport = viewportRef.current; - viewport.scrollTop = viewport.scrollHeight; - setShouldAutoScroll(true); - setIsUserScrolling(false); - } - }; - return ( - - - {terminalContent.length === 0 ? ( - - Starting KoboldCpp... - - ) : ( -
- {terminalContent} -
- )} -
-
- - {isUserScrolling && !shouldAutoScroll && ( - - - - )} +
); }; diff --git a/yarn.lock b/yarn.lock index a22aea9..8f91464 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2106,6 +2106,31 @@ __metadata: languageName: node linkType: hard +"@xterm/addon-fit@npm:^0.10.0": + version: 0.10.0 + resolution: "@xterm/addon-fit@npm:0.10.0" + peerDependencies: + "@xterm/xterm": ^5.0.0 + checksum: 10c0/76926120fc940376afef2cb68b15aec2a99fc628b6e3cc84f2bcb1682ca9b87f982b3c10ff206faf4ebc5b410467b81a7b5e83be37b4ac386586f472e4fa1c61 + languageName: node + linkType: hard + +"@xterm/addon-web-links@npm:^0.11.0": + version: 0.11.0 + resolution: "@xterm/addon-web-links@npm:0.11.0" + peerDependencies: + "@xterm/xterm": ^5.0.0 + checksum: 10c0/9426bed80afa954b0ea97771d041eb44e77a64e560ce8b8ef507a5d3a763979af18ae9f74ed54007bb7e235d0daf035be2a33f90d8edfecb431caf8ba0b0664e + languageName: node + linkType: hard + +"@xterm/xterm@npm:^5.5.0": + version: 5.5.0 + resolution: "@xterm/xterm@npm:5.5.0" + checksum: 10c0/358801feece58617d777b2783bec68dac1f52f736da3b0317f71a34f4e25431fb0b1920244f678b8d673f797145b4858c2a5ccb463a4a6df7c10c9093f1c9267 + languageName: node + linkType: hard + "abbrev@npm:^1.0.0": version: 1.1.1 resolution: "abbrev@npm:1.1.1" @@ -4391,6 +4416,9 @@ __metadata: "@typescript-eslint/eslint-plugin": "npm:^8.40.0" "@typescript-eslint/parser": "npm:^8.40.0" "@vitejs/plugin-react": "npm:^5.0.1" + "@xterm/addon-fit": "npm:^0.10.0" + "@xterm/addon-web-links": "npm:^0.11.0" + "@xterm/xterm": "npm:^5.5.0" cross-env: "npm:^10.0.0" cspell: "npm:^9.2.0" electron: "npm:^37.3.1"