switch back from xterm, minor fixes

This commit is contained in:
lone-cloud 2025-08-24 00:21:36 -07:00
parent 312f530d1d
commit fe095491a7
10 changed files with 212 additions and 189 deletions

View file

@ -1,6 +1,9 @@
alsa alsa
AMDGPU AMDGPU
ansi
ANSI
APPIMAGE APPIMAGE
stripansi
asar asar
Autoencoder Autoencoder
BLAS BLAS

View file

@ -61,6 +61,7 @@
"@types/node": "^24.3.0", "@types/node": "^24.3.0",
"@types/react": "^19.1.11", "@types/react": "^19.1.11",
"@types/react-dom": "^19.1.7", "@types/react-dom": "^19.1.7",
"@types/strip-ansi": "^5.2.1",
"@typescript-eslint/eslint-plugin": "^8.40.0", "@typescript-eslint/eslint-plugin": "^8.40.0",
"@typescript-eslint/parser": "^8.40.0", "@typescript-eslint/parser": "^8.40.0",
"@vitejs/plugin-react": "^5.0.1", "@vitejs/plugin-react": "^5.0.1",
@ -88,14 +89,12 @@
"dependencies": { "dependencies": {
"@mantine/core": "^8.2.7", "@mantine/core": "^8.2.7",
"@mantine/hooks": "^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", "execa": "^9.6.0",
"got": "^14.4.7", "got": "^14.4.7",
"lucide-react": "^0.541.0", "lucide-react": "^0.541.0",
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1", "react-dom": "^19.1.1",
"strip-ansi": "^7.1.0",
"systeminformation": "^5.27.7", "systeminformation": "^5.27.7",
"winston": "^3.17.0", "winston": "^3.17.0",
"winston-daily-rotate-file": "^5.0.0", "winston-daily-rotate-file": "^5.0.0",

View file

@ -109,8 +109,8 @@ export const AppHeader = ({
{currentScreen === 'interface' && ( {currentScreen === 'interface' && (
<Select <Select
value={activeInterfaceTab} value={activeInterfaceTab || 'terminal'}
onChange={setActiveInterfaceTab} onChange={(value) => setActiveInterfaceTab(value || 'terminal')}
data={[ data={[
{ {
value: 'chat', value: 'chat',
@ -118,7 +118,7 @@ export const AppHeader = ({
}, },
{ value: 'terminal', label: 'Terminal' }, { value: 'terminal', label: 'Terminal' },
]} ]}
placeholder="Select view" allowDeselect={false}
styles={{ styles={{
input: { input: {
minWidth: '9.375rem', minWidth: '9.375rem',

View file

@ -27,17 +27,6 @@ export const ModelFileField = ({
}: ModelFileFieldProps) => { }: ModelFileFieldProps) => {
const validationState = getInputValidationState(value); const validationState = getInputValidationState(value);
const getInputColor = () => {
switch (validationState) {
case 'valid':
return 'green';
case 'invalid':
return 'red';
default:
return undefined;
}
};
const getHelperText = () => { const getHelperText = () => {
if (!value.trim()) return undefined; if (!value.trim()) return undefined;
@ -62,7 +51,6 @@ export const ModelFileField = ({
placeholder={placeholder} placeholder={placeholder}
value={value} value={value}
onChange={(event) => onChange(event.currentTarget.value)} onChange={(event) => onChange(event.currentTarget.value)}
color={getInputColor()}
error={validationState === 'invalid' ? getHelperText() : undefined} error={validationState === 'invalid' ? getHelperText() : undefined}
/> />
</div> </div>

View file

@ -1,10 +1,15 @@
import { useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import { Box, useComputedColorScheme } from '@mantine/core'; import {
import { Terminal } from '@xterm/xterm'; Box,
import { FitAddon } from '@xterm/addon-fit'; ScrollArea,
import { WebLinksAddon } from '@xterm/addon-web-links'; Text,
import '@xterm/xterm/css/xterm.css'; ActionIcon,
useComputedColorScheme,
} from '@mantine/core';
import { ChevronDown } from 'lucide-react';
import styles from '@/styles/layout.module.css';
import { UI } from '@/constants'; import { UI } from '@/constants';
import { handleTerminalOutput } from '@/utils';
interface TerminalTabProps { interface TerminalTabProps {
onServerReady?: (serverUrl: string) => void; onServerReady?: (serverUrl: string) => void;
@ -14,152 +19,139 @@ export const TerminalTab = ({ onServerReady }: TerminalTabProps) => {
const computedColorScheme = useComputedColorScheme('light', { const computedColorScheme = useComputedColorScheme('light', {
getInitialValueInEffect: false, getInitialValueInEffect: false,
}); });
const terminalRef = useRef<HTMLDivElement>(null); const [terminalContent, setTerminalContent] = useState<string>('');
const xtermRef = useRef<Terminal | null>(null); const [isUserScrolling, setIsUserScrolling] = useState<boolean>(false);
const fitAddonRef = useRef<FitAddon | null>(null); const [shouldAutoScroll, setShouldAutoScroll] = useState<boolean>(true);
const scrollAreaRef = useRef<HTMLDivElement>(null);
const viewportRef = useRef<HTMLDivElement>(null);
const lastScrollTop = useRef<number>(0);
const isDark = computedColorScheme === 'dark'; const isDark = computedColorScheme === 'dark';
useEffect(() => { const handleScroll = ({ y }: { y: number }) => {
if (!terminalRef.current) return; if (!viewportRef.current) return;
const terminal = new Terminal({ const { scrollHeight, clientHeight } = viewportRef.current;
theme: { const isAtBottomNow = y + clientHeight >= scrollHeight - 10;
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,
smoothScrollDuration: 0,
});
const fitAddon = new FitAddon(); if (y < lastScrollTop.current) {
const webLinksAddon = new WebLinksAddon(); setIsUserScrolling(true);
setShouldAutoScroll(false);
terminal.loadAddon(fitAddon); } else if (isAtBottomNow) {
terminal.loadAddon(webLinksAddon); setIsUserScrolling(false);
setShouldAutoScroll(true);
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 (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);
}
}, [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); lastScrollTop.current = y;
};
return () => { useEffect(() => {
window.removeEventListener('resize', handleResize); if (shouldAutoScroll && !isUserScrolling && viewportRef.current) {
resizeObserver.disconnect(); const viewport = viewportRef.current;
}; viewport.scrollTop = viewport.scrollHeight;
}, []); }
}, [terminalContent, shouldAutoScroll, isUserScrolling]);
useEffect(() => { useEffect(() => {
const cleanup = window.electronAPI.kobold.onKoboldOutput((data: string) => { const cleanup = window.electronAPI.kobold.onKoboldOutput((data: string) => {
if (!xtermRef.current) return; setTerminalContent((prev) => {
const newData = data.toString();
const newData = data.toString(); if (
onServerReady &&
if ( newData.includes('Please connect to custom endpoint at ')
onServerReady && ) {
newData.includes('Please connect to custom endpoint at ') const match = newData.match(
) { /Please connect to custom endpoint at (http:\/\/[^\s]+)/
const match = newData.match( );
/Please connect to custom endpoint at (http:\/\/[^\s]+)/ if (match) {
); const serverUrl = match[1];
if (match) { setTimeout(() => onServerReady(serverUrl), 1500);
const serverUrl = match[1]; }
setTimeout(() => onServerReady(serverUrl), 1500);
} }
}
xtermRef.current.write(newData); return handleTerminalOutput(prev, newData);
xtermRef.current.scrollToBottom(); });
}); });
return cleanup; return cleanup;
}, [onServerReady]); }, [onServerReady]);
const scrollToBottom = () => {
if (viewportRef.current) {
const viewport = viewportRef.current;
viewport.scrollTop = viewport.scrollHeight;
setShouldAutoScroll(true);
setIsUserScrolling(false);
}
};
return ( return (
<Box <Box
style={{ style={{
height: `calc(100vh - ${UI.HEADER_HEIGHT}px)`, height: `calc(100vh - ${UI.HEADER_HEIGHT}px)`,
display: 'flex',
flexDirection: 'column',
backgroundColor: isDark backgroundColor: isDark
? 'var(--mantine-color-dark-filled)' ? 'var(--mantine-color-dark-filled)'
: 'var(--mantine-color-gray-0)', : 'var(--mantine-color-gray-0)',
borderRadius: 'inherit', borderRadius: 'inherit',
padding: '0.5rem', position: 'relative',
display: 'flex',
flexDirection: 'column',
}} }}
> >
<div <ScrollArea
ref={terminalRef} ref={scrollAreaRef}
style={{ viewportRef={viewportRef}
height: '100%', onScrollPositionChange={handleScroll}
width: '100%', className={styles.terminalScrollArea}
flex: 1, scrollbarSize={8}
minHeight: 0, offsetScrollbars={false}
backgroundColor: isDark ? '#1a1b1e' : '#ffffff', >
borderRadius: '0.25rem', <Box p="md">
overflow: 'hidden', {terminalContent.length === 0 ? (
}} <Text c="dimmed" style={{ fontFamily: 'inherit' }}>
/> Starting KoboldCpp...
</Text>
) : (
<div
style={{
margin: 0,
fontFamily:
'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
fontSize: '14px',
lineHeight: 1.4,
color: isDark
? 'var(--mantine-color-gray-0)'
: 'var(--mantine-color-dark-filled)',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
>
{terminalContent}
</div>
)}
</Box>
</ScrollArea>
{isUserScrolling && !shouldAutoScroll && (
<ActionIcon
variant="filled"
color="blue"
size="lg"
radius="xl"
onClick={scrollToBottom}
style={{
position: 'absolute',
bottom: '20px',
right: '20px',
zIndex: 10,
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.3)',
}}
aria-label="Scroll to bottom"
>
<ChevronDown size={20} />
</ActionIcon>
)}
</Box> </Box>
); );
}; };

View file

@ -21,17 +21,6 @@ export const GeneralTab = ({ onBackendsReady }: GeneralTabProps) => {
const validationState = getInputValidationState(modelPath); const validationState = getInputValidationState(modelPath);
const getInputColor = () => {
switch (validationState) {
case 'valid':
return 'green';
case 'invalid':
return 'red';
default:
return undefined;
}
};
const getHelperText = () => { const getHelperText = () => {
if (!modelPath.trim()) return undefined; if (!modelPath.trim()) return undefined;
@ -56,7 +45,6 @@ export const GeneralTab = ({ onBackendsReady }: GeneralTabProps) => {
placeholder="Select a .gguf model file or enter a direct URL to file" placeholder="Select a .gguf model file or enter a direct URL to file"
value={modelPath} value={modelPath}
onChange={(e) => handleModelPathChange(e.target.value)} onChange={(e) => handleModelPathChange(e.target.value)}
color={getInputColor()}
error={ error={
validationState === 'invalid' ? getHelperText() : undefined validationState === 'invalid' ? getHelperText() : undefined
} }

View file

@ -1,5 +1,5 @@
import { Card, Container, Stack, Tabs, Group, Button } from '@mantine/core'; import { Card, Container, Stack, Tabs, Group, Button } from '@mantine/core';
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback, useRef } from 'react';
import { useLaunchConfig } from '@/hooks/useLaunchConfig'; import { useLaunchConfig } from '@/hooks/useLaunchConfig';
import { useLaunchLogic } from '@/hooks/useLaunchLogic'; import { useLaunchLogic } from '@/hooks/useLaunchLogic';
import { useWarnings } from '@/hooks/useWarnings'; import { useWarnings } from '@/hooks/useWarnings';
@ -26,6 +26,7 @@ export const LaunchScreen = ({
const [, setInstallDir] = useState<string>(''); const [, setInstallDir] = useState<string>('');
const [activeTab, setActiveTab] = useState<string | null>('general'); const [activeTab, setActiveTab] = useState<string | null>('general');
const [configLoaded, setConfigLoaded] = useState<boolean>(false); const [configLoaded, setConfigLoaded] = useState<boolean>(false);
const defaultsSetRef = useRef(false);
const { const {
gpuLayers, gpuLayers,
@ -86,22 +87,43 @@ export const LaunchScreen = ({
if (!backend && backends.length > 0) { if (!backend && backends.length > 0) {
handleBackendChange(backends[0].value); handleBackendChange(backends[0].value);
} }
if (!modelPath.trim() && !sdmodel.trim()) {
handleModelPathChange(DEFAULT_MODEL_URL);
}
} catch (error) { } catch (error) {
window.electronAPI.logs.logError( window.electronAPI.logs.logError(
'Failed to set defaults:', 'Failed to set defaults:',
error as Error error as Error
); );
} }
}, [backend, modelPath, sdmodel, handleBackendChange, handleModelPathChange]); }, [backend, handleBackendChange]);
const setInitialDefaults = useCallback(
async (currentModelPath: string, currentSdModel: string) => {
try {
if (
!defaultsSetRef.current &&
!currentModelPath.trim() &&
!currentSdModel.trim()
) {
handleModelPathChange(DEFAULT_MODEL_URL);
defaultsSetRef.current = true;
}
} catch (error) {
window.electronAPI.logs.logError(
'Failed to set initial defaults:',
error as Error
);
}
},
[handleModelPathChange]
);
useEffect(() => { useEffect(() => {
if (configLoaded) { if (configLoaded && !defaultsSetRef.current) {
void setHappyDefaults(); void setHappyDefaults();
if (!modelPath.trim() && !sdmodel.trim()) {
void setInitialDefaults(modelPath, sdmodel);
}
} }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [configLoaded, setHappyDefaults]); }, [configLoaded, setHappyDefaults]);
const loadConfigFiles = useCallback(async () => { const loadConfigFiles = useCallback(async () => {

View file

@ -5,5 +5,6 @@ export * from './hardware';
export * from './imageModelPresets'; export * from './imageModelPresets';
export * from './platform'; export * from './platform';
export * from './sounds'; export * from './sounds';
export * from './terminal';
export * from './validation'; export * from './validation';
export * from './versionUtils'; export * from './versionUtils';

28
src/utils/terminal.ts Normal file
View file

@ -0,0 +1,28 @@
import stripAnsi from 'strip-ansi';
export const handleTerminalOutput = (
prevContent: string,
newData: string
): string => {
try {
const cleanData = stripAnsi(newData);
if (cleanData.includes('\r')) {
const lines = (prevContent + cleanData).split('\n');
return lines
.map((line) => {
if (line.includes('\r')) {
const parts = line.split('\r');
return parts[parts.length - 1];
}
return line;
})
.join('\n');
}
return prevContent + cleanData;
} catch (error) {
window.electronAPI.logs.logError('Terminal Basic Error', error as Error);
return prevContent + newData;
}
};

View file

@ -1923,6 +1923,15 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@types/strip-ansi@npm:^5.2.1":
version: 5.2.1
resolution: "@types/strip-ansi@npm:5.2.1"
dependencies:
strip-ansi: "npm:*"
checksum: 10c0/052d73697ac18bf12ffc76e8695d65d8588ee53f68cd952870991c3e3bf0c1214d42aa13004b140c1095a8ea27d4915105bafa8be43869bd66e5badca75d2d36
languageName: node
linkType: hard
"@types/triple-beam@npm:^1.3.2": "@types/triple-beam@npm:^1.3.2":
version: 1.3.5 version: 1.3.5
resolution: "@types/triple-beam@npm:1.3.5" resolution: "@types/triple-beam@npm:1.3.5"
@ -2106,31 +2115,6 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "abbrev@npm:^1.0.0":
version: 1.1.1 version: 1.1.1
resolution: "abbrev@npm:1.1.1" resolution: "abbrev@npm:1.1.1"
@ -2258,6 +2242,17 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"ansi-to-html@npm:^0.7.2":
version: 0.7.2
resolution: "ansi-to-html@npm:0.7.2"
dependencies:
entities: "npm:^2.2.0"
bin:
ansi-to-html: bin/ansi-to-html
checksum: 10c0/031da78f716e7c6b0e391c64f7bc5e95f2d37123dcc3237d8c592dc35830dd0da05e0c3f3e3f8179856cfe5fd85c689d2ad85024b71b50014da9ef6e8fa021cf
languageName: node
linkType: hard
"app-builder-bin@npm:5.0.0-alpha.12": "app-builder-bin@npm:5.0.0-alpha.12":
version: 5.0.0-alpha.12 version: 5.0.0-alpha.12
resolution: "app-builder-bin@npm:5.0.0-alpha.12" resolution: "app-builder-bin@npm:5.0.0-alpha.12"
@ -3617,6 +3612,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"entities@npm:^2.2.0":
version: 2.2.0
resolution: "entities@npm:2.2.0"
checksum: 10c0/7fba6af1f116300d2ba1c5673fc218af1961b20908638391b4e1e6d5850314ee2ac3ec22d741b3a8060479911c99305164aed19b6254bde75e7e6b1b2c3f3aa3
languageName: node
linkType: hard
"env-paths@npm:^2.2.0": "env-paths@npm:^2.2.0":
version: 2.2.1 version: 2.2.1
resolution: "env-paths@npm:2.2.1" resolution: "env-paths@npm:2.2.1"
@ -4413,12 +4415,11 @@ __metadata:
"@types/node": "npm:^24.3.0" "@types/node": "npm:^24.3.0"
"@types/react": "npm:^19.1.11" "@types/react": "npm:^19.1.11"
"@types/react-dom": "npm:^19.1.7" "@types/react-dom": "npm:^19.1.7"
"@types/strip-ansi": "npm:^5.2.1"
"@typescript-eslint/eslint-plugin": "npm:^8.40.0" "@typescript-eslint/eslint-plugin": "npm:^8.40.0"
"@typescript-eslint/parser": "npm:^8.40.0" "@typescript-eslint/parser": "npm:^8.40.0"
"@vitejs/plugin-react": "npm:^5.0.1" "@vitejs/plugin-react": "npm:^5.0.1"
"@xterm/addon-fit": "npm:^0.10.0" ansi-to-html: "npm:^0.7.2"
"@xterm/addon-web-links": "npm:^0.11.0"
"@xterm/xterm": "npm:^5.5.0"
cross-env: "npm:^10.0.0" cross-env: "npm:^10.0.0"
cspell: "npm:^9.2.0" cspell: "npm:^9.2.0"
electron: "npm:^37.3.1" electron: "npm:^37.3.1"
@ -4442,6 +4443,7 @@ __metadata:
react: "npm:^19.1.1" react: "npm:^19.1.1"
react-dom: "npm:^19.1.1" react-dom: "npm:^19.1.1"
rollup-plugin-visualizer: "npm:^6.0.3" rollup-plugin-visualizer: "npm:^6.0.3"
strip-ansi: "npm:^7.1.0"
systeminformation: "npm:^5.27.7" systeminformation: "npm:^5.27.7"
typescript: "npm:^5.9.2" typescript: "npm:^5.9.2"
vite: "npm:^7.1.3" vite: "npm:^7.1.3"
@ -7813,7 +7815,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"strip-ansi@npm:^7.0.1, strip-ansi@npm:^7.1.0": "strip-ansi@npm:*, strip-ansi@npm:^7.0.1, strip-ansi@npm:^7.1.0":
version: 7.1.0 version: 7.1.0
resolution: "strip-ansi@npm:7.1.0" resolution: "strip-ansi@npm:7.1.0"
dependencies: dependencies: