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
AMDGPU
ansi
ANSI
APPIMAGE
stripansi
asar
Autoencoder
BLAS

View file

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

View file

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

View file

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

View file

@ -1,10 +1,15 @@
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 { 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 { UI } from '@/constants';
import { handleTerminalOutput } from '@/utils';
interface TerminalTabProps {
onServerReady?: (serverUrl: string) => void;
@ -14,105 +19,42 @@ export const TerminalTab = ({ onServerReady }: TerminalTabProps) => {
const computedColorScheme = useComputedColorScheme('light', {
getInitialValueInEffect: false,
});
const terminalRef = useRef<HTMLDivElement>(null);
const xtermRef = useRef<Terminal | null>(null);
const fitAddonRef = useRef<FitAddon | null>(null);
const [terminalContent, setTerminalContent] = useState<string>('');
const [isUserScrolling, setIsUserScrolling] = useState<boolean>(false);
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';
useEffect(() => {
if (!terminalRef.current) return;
const handleScroll = ({ y }: { y: number }) => {
if (!viewportRef.current) return;
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,
smoothScrollDuration: 0,
});
const { scrollHeight, clientHeight } = viewportRef.current;
const isAtBottomNow = y + clientHeight >= scrollHeight - 10;
const fitAddon = new FitAddon();
const webLinksAddon = new WebLinksAddon();
if (y < lastScrollTop.current) {
setIsUserScrolling(true);
setShouldAutoScroll(false);
} else if (isAtBottomNow) {
setIsUserScrolling(false);
setShouldAutoScroll(true);
}
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;
lastScrollTop.current = y;
};
}, []);
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;
if (shouldAutoScroll && !isUserScrolling && viewportRef.current) {
const viewport = viewportRef.current;
viewport.scrollTop = viewport.scrollHeight;
}
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);
return () => {
window.removeEventListener('resize', handleResize);
resizeObserver.disconnect();
};
}, []);
}, [terminalContent, shouldAutoScroll, isUserScrolling]);
useEffect(() => {
const cleanup = window.electronAPI.kobold.onKoboldOutput((data: string) => {
if (!xtermRef.current) return;
setTerminalContent((prev) => {
const newData = data.toString();
if (
@ -128,38 +70,88 @@ export const TerminalTab = ({ onServerReady }: TerminalTabProps) => {
}
}
xtermRef.current.write(newData);
xtermRef.current.scrollToBottom();
return handleTerminalOutput(prev, newData);
});
});
return cleanup;
}, [onServerReady]);
const scrollToBottom = () => {
if (viewportRef.current) {
const viewport = viewportRef.current;
viewport.scrollTop = viewport.scrollHeight;
setShouldAutoScroll(true);
setIsUserScrolling(false);
}
};
return (
<Box
style={{
height: `calc(100vh - ${UI.HEADER_HEIGHT}px)`,
display: 'flex',
flexDirection: 'column',
backgroundColor: isDark
? 'var(--mantine-color-dark-filled)'
: 'var(--mantine-color-gray-0)',
borderRadius: 'inherit',
padding: '0.5rem',
display: 'flex',
flexDirection: 'column',
position: 'relative',
}}
>
<ScrollArea
ref={scrollAreaRef}
viewportRef={viewportRef}
onScrollPositionChange={handleScroll}
className={styles.terminalScrollArea}
scrollbarSize={8}
offsetScrollbars={false}
>
<Box p="md">
{terminalContent.length === 0 ? (
<Text c="dimmed" style={{ fontFamily: 'inherit' }}>
Starting KoboldCpp...
</Text>
) : (
<div
ref={terminalRef}
style={{
height: '100%',
width: '100%',
flex: 1,
minHeight: 0,
backgroundColor: isDark ? '#1a1b1e' : '#ffffff',
borderRadius: '0.25rem',
overflow: 'hidden',
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>
);
};

View file

@ -21,17 +21,6 @@ export const GeneralTab = ({ onBackendsReady }: GeneralTabProps) => {
const validationState = getInputValidationState(modelPath);
const getInputColor = () => {
switch (validationState) {
case 'valid':
return 'green';
case 'invalid':
return 'red';
default:
return undefined;
}
};
const getHelperText = () => {
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"
value={modelPath}
onChange={(e) => handleModelPathChange(e.target.value)}
color={getInputColor()}
error={
validationState === 'invalid' ? getHelperText() : undefined
}

View file

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

View file

@ -5,5 +5,6 @@ export * from './hardware';
export * from './imageModelPresets';
export * from './platform';
export * from './sounds';
export * from './terminal';
export * from './validation';
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
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":
version: 1.3.5
resolution: "@types/triple-beam@npm:1.3.5"
@ -2106,31 +2115,6 @@ __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"
@ -2258,6 +2242,17 @@ __metadata:
languageName: node
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":
version: 5.0.0-alpha.12
resolution: "app-builder-bin@npm:5.0.0-alpha.12"
@ -3617,6 +3612,13 @@ __metadata:
languageName: node
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":
version: 2.2.1
resolution: "env-paths@npm:2.2.1"
@ -4413,12 +4415,11 @@ __metadata:
"@types/node": "npm:^24.3.0"
"@types/react": "npm:^19.1.11"
"@types/react-dom": "npm:^19.1.7"
"@types/strip-ansi": "npm:^5.2.1"
"@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"
ansi-to-html: "npm:^0.7.2"
cross-env: "npm:^10.0.0"
cspell: "npm:^9.2.0"
electron: "npm:^37.3.1"
@ -4442,6 +4443,7 @@ __metadata:
react: "npm:^19.1.1"
react-dom: "npm:^19.1.1"
rollup-plugin-visualizer: "npm:^6.0.3"
strip-ansi: "npm:^7.1.0"
systeminformation: "npm:^5.27.7"
typescript: "npm:^5.9.2"
vite: "npm:^7.1.3"
@ -7813,7 +7815,7 @@ __metadata:
languageName: node
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
resolution: "strip-ansi@npm:7.1.0"
dependencies: