terminal improvements

This commit is contained in:
Neil 2026-03-21 13:18:13 -07:00
parent 173965b57f
commit b093a5ab96
10 changed files with 184 additions and 52 deletions

View file

@ -58,7 +58,7 @@
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"electron": "^41.0.2",
"electron-builder": "^26.0.12",
"electron-builder": "^26.8.1",
"electron-vite": "^5.0.0",
"husky": "^9.1.7",
"lint-staged": "^16.4.0",

View file

@ -100,7 +100,7 @@ importers:
specifier: ^41.0.2
version: 41.0.2
electron-builder:
specifier: ^26.0.12
specifier: ^26.8.1
version: 26.8.1(electron-builder-squirrel-windows@26.8.1)
electron-vite:
specifier: ^5.0.0
@ -2019,8 +2019,8 @@ packages:
'@types/cacheable-request@6.0.3':
resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==}
'@types/debug@4.1.12':
resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==}
'@types/debug@4.1.13':
resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==}
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
@ -3876,8 +3876,8 @@ packages:
safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
sanitize-filename@1.6.3:
resolution: {integrity: sha512-y/52Mcy7aw3gRm7IrcGDFx/bCk4AhRh2eI9luHOQM86nZsqwiRkkq2GekHXBBD+SmPidc8i2PqtYZl+pWJ8Oeg==}
sanitize-filename@1.6.4:
resolution: {integrity: sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg==}
sax@1.6.0:
resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==}
@ -4088,8 +4088,8 @@ packages:
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
engines: {node: '>=6'}
tar@7.5.11:
resolution: {integrity: sha512-ChjMH33/KetonMTAtpYdgUFr0tbz69Fp2v7zWxQfYZX4g5ZN2nOBXm1R2xyA+lMIKrLKIoKAwFj93jE/avX9cQ==}
tar@7.5.12:
resolution: {integrity: sha512-9TsuLcdhOn4XztcQqhNyq1KOwOOED/3k58JAvtULiYqbO8B/0IBAAIE1hj0Svmm58k27TmcigyDI0deMlgG3uw==}
engines: {node: '>=18'}
temp-file@3.4.0:
@ -4756,7 +4756,7 @@ snapshots:
ora: 5.4.1
read-binary-file-arch: 1.0.6
semver: 7.7.4
tar: 7.5.11
tar: 7.5.12
yargs: 17.7.2
transitivePeerDependencies:
- supports-color
@ -6177,7 +6177,7 @@ snapshots:
'@types/node': 25.5.0
'@types/responselike': 1.0.3
'@types/debug@4.1.12':
'@types/debug@4.1.13':
dependencies:
'@types/ms': 2.1.0
@ -6342,7 +6342,7 @@ snapshots:
proper-lockfile: 4.1.2
resedit: 1.7.2
semver: 7.7.4
tar: 7.5.11
tar: 7.5.12
temp-file: 3.4.0
tiny-async-pool: 1.3.0
which: 5.0.0
@ -6448,7 +6448,7 @@ snapshots:
builder-util@26.8.1:
dependencies:
7zip-bin: 5.2.0
'@types/debug': 4.1.12
'@types/debug': 4.1.13
app-builder-bin: 5.0.0-alpha.12
builder-util-runtime: 9.5.1
chalk: 4.1.2
@ -6458,7 +6458,7 @@ snapshots:
http-proxy-agent: 7.0.2
https-proxy-agent: 7.0.6
js-yaml: 4.1.1
sanitize-filename: 1.6.3
sanitize-filename: 1.6.4
source-map-support: 0.5.21
stat-mode: 1.0.0
temp-file: 3.4.0
@ -6486,7 +6486,7 @@ snapshots:
minipass-pipeline: 1.2.4
p-map: 7.0.4
ssri: 12.0.0
tar: 7.5.11
tar: 7.5.12
unique-filename: 4.0.0
cacheable-lookup@5.0.4: {}
@ -7773,7 +7773,7 @@ snapshots:
nopt: 8.1.0
proc-log: 5.0.0
semver: 7.7.4
tar: 7.5.11
tar: 7.5.12
tinyglobby: 0.2.15
which: 5.0.0
transitivePeerDependencies:
@ -8255,7 +8255,7 @@ snapshots:
safer-buffer@2.1.2: {}
sanitize-filename@1.6.3:
sanitize-filename@1.6.4:
dependencies:
truncate-utf8-bytes: 1.0.2
@ -8522,7 +8522,7 @@ snapshots:
tapable@2.3.0: {}
tar@7.5.11:
tar@7.5.12:
dependencies:
'@isaacs/fs-minipass': 4.0.1
chownr: 3.0.0

23
resources/logo.svg Normal file
View file

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
version="1.1"
id="svg1"
width="318.60233"
height="202.66667"
viewBox="0 0 318.60232 202.66667"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs1" />
<g
id="g1"
style="display:inline"
transform="translate(-6.6666669,-70.666669)">
<path
style="display:inline;fill:#ffffff"
d="m 177.81311,248.33334 c 23.82304,-41.29793 40.54045,-66.84626 49.51207,-75.66667 6.81685,-6.70196 10.07373,-8.7374 20.07265,-12.54475 34.57822,-13.16655 61.04674,-26.78733 72.37222,-37.24295 9.62924,-8.88966 9.34286,-9.01142 -23.43671,-9.964 -35.71756,-1.03796 -43.72989,0.42119 -62.17546,11.323 -16.72118,9.88265 -34.20103,30.11225 -42.74704,49.47157 -2.57353,5.82985 -14.81294,44.3056 -27.96399,87.90747 -2.86036,9.48343 -3.02466,11.71633 -0.86213,11.71633 0.44382,0 7.29659,-11.25 15.22839,-25 z m -65.14644,-8.32267 C 120,239.3326 130.5,237.50979 136,235.95998 c 5.5,-1.5498 12.25,-3.13783 15,-3.52895 2.75,-0.39111 5,-0.95485 5,-1.25275 0,-0.29789 2.15135,-7.58487 4.78078,-16.19328 8.49209,-27.80201 12.21334,-40.41629 21.13747,-71.65166 4.81891,-16.86667 11.23502,-39.185 14.25802,-49.596301 5.12803,-17.66103 5.74763,-23.07037 2.64253,-23.07037 -1.84887,0 -4.07048,6.908293 -16.72243,52.000001 -21.78975,77.65896 -20.80806,74.74393 -26.84794,79.72251 -7.5925,6.25838 -25.03916,14.82524 -36.10856,17.73044 -17.0947,4.48656 -33.410599,3.86724 -53.116765,-2.01622 -18.569242,-5.54403 -23.142662,-5.80284 -33.639754,-1.9037 -5.875424,2.18242 -9.864152,5.04363 -16.716684,11.99127 -4.95,5.0187 -9.0000001,10.02884 -9.0000001,11.13364 0,1.75174 5.9276921,2.00299 46.3333351,1.96383 25.483334,-0.0247 52.333338,-0.59969 59.666668,-1.27777 z M 252.69513,104.63708 c 12.18267,-3.48651 15.77304,-7.895503 9.63821,-11.835773 -10.19296,-6.546726 -36.19849,-1.77301 -41.19436,7.561863 -1.2556,2.3461 -0.98698,3.2037 1.68353,5.375 2.69471,2.19098 4.59991,2.47691 12.53928,1.88189 5.14899,-0.3859 12.94899,-1.72824 17.33334,-2.98298 z"
id="path1" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View file

@ -66,6 +66,23 @@ function createWindow(): BrowserWindow {
return { action: 'deny' }
})
// File drag-and-drop: the preload script handles the drop event (because
// File.path is only available there), sends paths here, and we relay to renderer.
ipcMain.on('terminal:file-dropped-from-preload', (_event, args: { paths: string[] }) => {
if (!mainWindow.isDestroyed()) {
for (const p of args.paths) {
mainWindow.webContents.send('terminal:file-drop', { path: p })
}
}
})
// Safety net: block any file:// navigation that might slip through
mainWindow.webContents.on('will-navigate', (event, url) => {
if (url.startsWith('file://')) {
event.preventDefault()
}
})
// Handle zoom shortcuts reliably via before-input-event
mainWindow.webContents.on('before-input-event', (_event, input) => {
if (input.type !== 'keyDown') return
@ -101,8 +118,8 @@ app.whenReady().then(() => {
electronApp.setAppUserModelId('com.stablyai.orca')
app.setName('Orca')
if (process.platform === 'darwin') {
const dockIcon = nativeImage.createFromPath(is.dev ? devIcon : icon)
if (process.platform === 'darwin' && is.dev) {
const dockIcon = nativeImage.createFromPath(devIcon)
app.dock?.setIcon(dockIcon)
}

View file

@ -96,6 +96,7 @@ interface UIApi {
set: (args: Partial<PersistedUIState>) => Promise<void>
onOpenSettings: (callback: () => void) => () => void
onTerminalZoom: (callback: (direction: 'in' | 'out' | 'reset') => void) => () => void
onFileDrop: (callback: (data: { path: string }) => void) => () => void
getZoomLevel: () => number
setZoomLevel: (level: number) => void
}

View file

@ -1,6 +1,42 @@
import { contextBridge, ipcRenderer, webFrame } from 'electron'
import { contextBridge, ipcRenderer, webFrame, webUtils } from 'electron'
import { electronAPI } from '@electron-toolkit/preload'
// ---------------------------------------------------------------------------
// File drag-and-drop: handled here in the preload because webUtils (which
// resolves File objects to filesystem paths) is only available in Electron's
// preload/main worlds, not the renderer's isolated main world.
// ---------------------------------------------------------------------------
document.addEventListener(
'dragover',
(e) => {
e.preventDefault()
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'
},
true
)
document.addEventListener(
'drop',
(e) => {
e.preventDefault()
e.stopPropagation()
const files = e.dataTransfer?.files
if (!files || files.length === 0) return
const paths: string[] = []
for (let i = 0; i < files.length; i++) {
// webUtils.getPathForFile is the Electron 28+ replacement for File.path
const filePath = webUtils.getPathForFile(files[i])
if (filePath) paths.push(filePath)
}
if (paths.length > 0) {
ipcRenderer.send('terminal:file-dropped-from-preload', { paths })
}
},
true
)
// Custom APIs for renderer
const api = {
repos: {
@ -151,6 +187,11 @@ const api = {
ipcRenderer.on('terminal:zoom', listener)
return () => ipcRenderer.removeListener('terminal:zoom', listener)
},
onFileDrop: (callback: (data: { path: string }) => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent, data: { path: string }) => callback(data)
ipcRenderer.on('terminal:file-drop', listener)
return () => ipcRenderer.removeListener('terminal:file-drop', listener)
},
getZoomLevel: (): number => webFrame.getZoomLevel(),
setZoomLevel: (level: number): void => webFrame.setZoomLevel(level)
}

View file

@ -1,7 +1,7 @@
import { useMemo } from 'react'
import { FolderPlus, GitBranchPlus } from 'lucide-react'
import { useAppStore } from '../store'
import logo from '../../../../resources/icon.png'
import logo from '../../../../resources/logo.svg'
type ShortcutItem = {
id: string
@ -38,11 +38,12 @@ export default function Landing(): React.JSX.Element {
<div className="flex-1 flex items-center justify-center bg-background">
<div className="w-full max-w-lg px-6">
<div className="flex flex-col items-center gap-4 py-8">
<img
src={logo}
alt="Orca logo"
className="size-16 rounded-xl shadow-lg shadow-black/40"
/>
<div
className="flex items-center justify-center size-20 rounded-2xl border border-border/80 shadow-lg shadow-black/40"
style={{ backgroundColor: '#12181e' }}
>
<img src={logo} alt="Orca logo" className="size-12" />
</div>
<h1 className="text-4xl font-bold text-foreground tracking-tight">ORCA</h1>
<p className="text-sm text-muted-foreground text-center">

View file

@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import type { CSSProperties } from 'react'
import type { ITheme } from '@xterm/xterm'
import {
@ -1159,9 +1160,32 @@ export default function TerminalPane({
)
}
// Get the search addon for the active pane
// Get the search addon for the active pane and its container for portal
const activePane = managerRef.current?.getActivePane()
const activeSearchAddon = activePane?.searchAddon ?? null
const activePaneContainer = activePane?.container ?? null
// Drag & drop file paths into terminal.
// The preload script handles dragover/drop (File.path is only available there),
// sends paths to main process, which relays them here via IPC.
useEffect(() => {
if (!isActive) return
const shellEscape = (p: string): string => {
if (/^[a-zA-Z0-9_./@:-]+$/.test(p)) return p
return "'" + p.replace(/'/g, "'\\''") + "'"
}
return window.api.ui.onFileDrop(({ path: filePath }) => {
const manager = managerRef.current
if (!manager) return
const pane = manager.getActivePane() ?? manager.getPanes()[0]
if (!pane) return
const transport = paneTransportsRef.current.get(pane.id)
if (!transport) return
transport.sendInput(shellEscape(filePath))
})
}, [isActive])
return (
<>
@ -1193,11 +1217,15 @@ export default function TerminalPane({
setTerminalMenuOpen(true)
}}
/>
<TerminalSearch
isOpen={searchOpen}
onClose={() => setSearchOpen(false)}
searchAddon={activeSearchAddon}
/>
{activePaneContainer &&
createPortal(
<TerminalSearch
isOpen={searchOpen}
onClose={() => setSearchOpen(false)}
searchAddon={activeSearchAddon}
/>,
activePaneContainer
)}
<DropdownMenu open={terminalMenuOpen} onOpenChange={setTerminalMenuOpen} modal={false}>
<DropdownMenuTrigger asChild>
<button

View file

@ -366,8 +366,28 @@ export class PaneManager {
const fitAddon = new FitAddon()
const searchAddon = new SearchAddon()
const unicode11Addon = new Unicode11Addon()
// URL tooltip element — Ghostty-style bottom-left hint on hover
const linkTooltip = document.createElement('div')
linkTooltip.className = 'pane-link-tooltip'
linkTooltip.style.cssText =
'display:none;position:absolute;bottom:4px;left:8px;z-index:40;' +
'padding:2px 8px;border-radius:4px;font-size:11px;font-family:inherit;' +
'color:#a1a1aa;background:rgba(24,24,27,0.85);border:1px solid rgba(63,63,70,0.6);' +
'pointer-events:none;max-width:80%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;'
container.appendChild(linkTooltip)
const webLinksAddon = new WebLinksAddon(
this.options.onLinkClick ? (_event, uri) => this.options.onLinkClick!(uri) : undefined
this.options.onLinkClick ? (_event, uri) => this.options.onLinkClick!(uri) : undefined,
{
hover: (event, uri) => {
if (event.type === 'mouseover' && uri) {
linkTooltip.textContent = uri
linkTooltip.style.display = ''
} else {
linkTooltip.style.display = 'none'
}
}
}
)
const pane: ManagedPaneInternal = {

View file

@ -5,29 +5,30 @@ export const TERMINAL_THEMES: Record<string, ITheme> = {
// Defaults
// ──────────────────────────────────────────────
// Exact colors from Ghostty source: src/terminal/color.zig + src/config/Config.zig
'Ghostty Default Style Dark': {
background: '#282c34',
foreground: '#c0c5ce',
cursor: '#c0c5ce',
foreground: '#ffffff',
cursor: '#ffffff',
cursorAccent: '#282c34',
selectionBackground: '#3e4451',
selectionForeground: '#c0c5ce',
black: '#1d2021',
red: '#cc241d',
green: '#98971a',
yellow: '#d79921',
blue: '#458588',
magenta: '#b16286',
cyan: '#689d6a',
white: '#a89984',
brightBlack: '#928374',
brightRed: '#fb4934',
brightGreen: '#b8bb26',
brightYellow: '#fabd2f',
brightBlue: '#83a598',
brightMagenta: '#d3869b',
brightCyan: '#8ec07c',
brightWhite: '#ebdbb2'
selectionForeground: '#ffffff',
black: '#1d1f21',
red: '#cc6666',
green: '#b5bd68',
yellow: '#f0c674',
blue: '#81a2be',
magenta: '#b294bb',
cyan: '#8abeb7',
white: '#c5c8c6',
brightBlack: '#666666',
brightRed: '#d54e53',
brightGreen: '#b9ca4a',
brightYellow: '#e7c547',
brightBlue: '#7aa6da',
brightMagenta: '#c397d8',
brightCyan: '#70c0b1',
brightWhite: '#eaeaea'
},
'Builtin Tango Light': {