several nice UI improvements

This commit is contained in:
2026-06-16 08:52:22 +02:00
parent 6a940b9b7a
commit 7bac3fe7c5
5 changed files with 64 additions and 35 deletions

View File

@@ -25,6 +25,19 @@ try {
const terms = new Map<number, import('node-pty').IPty>() const terms = new Map<number, import('node-pty').IPty>()
let seq = 0 let seq = 0
/**
* Env for spawned PTYs. Electron launched from a Homebrew/GUI context leaks
* `npm_config_prefix` (e.g. "/opt/homebrew") into the child shell, which makes
* nvm refuse to load ("nvm is not compatible with the npm_config_prefix
* environment variable"). Strip it so the user's shell init runs cleanly.
*/
function ptyEnv(): { [key: string]: string } {
const env = { ...process.env } as { [key: string]: string }
delete env.npm_config_prefix
delete env.npm_config_globalconfig
return env
}
function defaultShell(): string { function defaultShell(): string {
const configured = getConfig().terminal.shell const configured = getConfig().terminal.shell
if (configured) return configured if (configured) return configured
@@ -39,12 +52,20 @@ export function ptyAvailable(): boolean {
export function createPty(sender: WebContents, kind: 'agent' | 'shell', cols: number, rows: number): number { export function createPty(sender: WebContents, kind: 'agent' | 'shell', cols: number, rows: number): number {
if (!pty) return -1 if (!pty) return -1
const cwd = getRoot() || process.env.HOME || process.cwd() const cwd = getRoot() || process.env.HOME || process.cwd()
const proc = pty.spawn(defaultShell(), [], { const shell = defaultShell()
const ai = getConfig().ai
const launchAgent = kind === 'agent' && ai.autoLaunch && process.platform !== 'win32'
// For the agent pane we exec the `claude` CLI directly as the shell's command
// (`zsh -i -c 'claude'`) instead of typing it into an interactive prompt — `-i`
// still sources the user's rc (nvm etc.), but there's no prompt line and no
// echoed command cluttering the pane; claude takes over a clean terminal.
const args = launchAgent ? ['-i', '-c', ai.command] : []
const proc = pty.spawn(shell, args, {
name: 'xterm-color', name: 'xterm-color',
cols: cols || 80, cols: cols || 80,
rows: rows || 24, rows: rows || 24,
cwd, cwd,
env: process.env as { [key: string]: string }, env: ptyEnv(),
}) })
const id = ++seq const id = ++seq
terms.set(id, proc) terms.set(id, proc)
@@ -52,9 +73,8 @@ export function createPty(sender: WebContents, kind: 'agent' | 'shell', cols: nu
proc.onData((data) => { if (!sender.isDestroyed()) sender.send('pty:data', { id, data }) }) proc.onData((data) => { if (!sender.isDestroyed()) sender.send('pty:data', { id, data }) })
proc.onExit(() => { terms.delete(id); if (!sender.isDestroyed()) sender.send('pty:exit', { id }) }) proc.onExit(() => { terms.delete(id); if (!sender.isDestroyed()) sender.send('pty:exit', { id }) })
const ai = getConfig().ai // Windows path keeps the type-into-shell launch (no `-i -c` semantics there).
if (kind === 'agent' && ai.autoLaunch) { if (kind === 'agent' && ai.autoLaunch && process.platform === 'win32') {
// small delay so the shell prompt is ready before we type the command
setTimeout(() => { try { proc.write(ai.command + '\r') } catch { /* exited */ } }, 350) setTimeout(() => { try { proc.write(ai.command + '\r') } catch { /* exited */ } }, 350)
} }
return id return id

View File

@@ -140,10 +140,15 @@ export function App(): React.ReactElement {
// Re-applied on resize + focus change; dragging still works in between. // Re-applied on resize + focus change; dragging still works in between.
const FOCUS_RESIZE_BELOW = 1650 const FOCUS_RESIZE_BELOW = 1650
const [focusZone, setFocusZone] = useState<'default' | 'editor' | 'terminal'>('default') const [focusZone, setFocusZone] = useState<'default' | 'editor' | 'terminal'>('default')
// Auto panel management: re-fit columns on resize/focus. Manually dragging a
// splitter switches it off (the user took control); the title-bar toggle
// turns it back on (and immediately re-fits).
const [autoResize, setAutoResize] = useState(true)
const [gitW, setGitW] = useState(() => Math.round(window.innerWidth * 0.15)) const [gitW, setGitW] = useState(() => Math.round(window.innerWidth * 0.15))
const [treeW, setTreeW] = useState(() => Math.round(window.innerWidth * 0.15)) const [treeW, setTreeW] = useState(() => Math.round(window.innerWidth * 0.15))
const [rightW, setRightW] = useState(() => Math.round(window.innerWidth * 0.3)) const [rightW, setRightW] = useState(() => Math.round(window.innerWidth * 0.3))
useEffect(() => { useEffect(() => {
if (!autoResize) return
function apply(): void { function apply(): void {
const w = window.innerWidth const w = window.innerWidth
if (w >= FOCUS_RESIZE_BELOW) { if (w >= FOCUS_RESIZE_BELOW) {
@@ -160,7 +165,7 @@ export function App(): React.ReactElement {
apply() apply()
window.addEventListener('resize', apply) window.addEventListener('resize', apply)
return () => window.removeEventListener('resize', apply) return () => window.removeEventListener('resize', apply)
}, [focusZone]) }, [focusZone, autoResize])
// Seed explorer expansion from the tree's `open` flags once per opened project. // Seed explorer expansion from the tree's `open` flags once per opened project.
const seededRoot = useRef<string | null | undefined>(undefined) const seededRoot = useRef<string | null | undefined>(undefined)
@@ -368,6 +373,10 @@ export function App(): React.ReactElement {
<div className="tb-spacer" /> <div className="tb-spacer" />
<div className="tb-actions"> <div className="tb-actions">
<button className="tb-btn" onClick={() => setOverlay('search')}>{Icon.search()} Search <kbd>F</kbd></button> <button className="tb-btn" onClick={() => setOverlay('search')}>{Icon.search()} Search <kbd>F</kbd></button>
<button className={'tb-btn tb-toggle' + (autoResize ? ' on' : '')} onClick={() => setAutoResize((v) => !v)}
title={autoResize ? 'Auto-fit panels: on — columns re-fit on resize/focus. Click to lock current sizes.' : 'Auto-fit panels: off — sizes locked. Click to re-enable.'}>
{Icon.layout()} Auto-fit <span className="tb-state">{autoResize ? 'On' : 'Off'}</span>
</button>
</div> </div>
</div> </div>
@@ -379,7 +388,7 @@ export function App(): React.ReactElement {
onStage={stageGuarded} onUnstage={unstageGuarded} onStageAll={actions.stageAll} onUnstageAll={actions.unstageAll} onCommit={commit} onStage={stageGuarded} onUnstage={unstageGuarded} onStageAll={actions.stageAll} onUnstageAll={actions.unstageAll} onCommit={commit}
onOpen={openFile} onContext={openMenu} activePath={active} /> onOpen={openFile} onContext={openMenu} activePath={active} />
</div> </div>
<Splitter onDelta={(dx) => setGitW((w) => clamp(w + dx, 160, 460))} /> <Splitter onDelta={(dx) => { setAutoResize(false); setGitW((w) => clamp(w + dx, 160, 460)) }} />
<div className="col" style={{ width: treeW, flex: '0 0 ' + treeW + 'px' }}> <div className="col" style={{ width: treeW, flex: '0 0 ' + treeW + 'px' }}>
{proj.tree ? ( {proj.tree ? (
@@ -389,7 +398,7 @@ export function App(): React.ReactElement {
<div className="tree-body" /> <div className="tree-body" />
)} )}
</div> </div>
<Splitter onDelta={(dx) => setTreeW((w) => clamp(w + dx, 160, 520))} /> <Splitter onDelta={(dx) => { setAutoResize(false); setTreeW((w) => clamp(w + dx, 160, 520)) }} />
<div className="col editor-col" onMouseDownCapture={() => setFocusZone('editor')}> <div className="col editor-col" onMouseDownCapture={() => setFocusZone('editor')}>
<Editor tabs={resolvedTabs} active={active} mode={mode} <Editor tabs={resolvedTabs} active={active} mode={mode}
@@ -399,11 +408,11 @@ export function App(): React.ReactElement {
cursor={cursor} selection={selection} setCursor={setCursor} setSelection={setSelection} cursor={cursor} selection={selection} setCursor={setCursor} setSelection={setSelection}
bufferText={bufferText(active)} onEdit={onEdit} /> bufferText={bufferText(active)} onEdit={onEdit} />
</div> </div>
<Splitter onDelta={(dx) => setRightW((w) => { <Splitter onDelta={(dx) => { setAutoResize(false); setRightW((w) => {
// grow until the editor would drop below ~280px (rather than a fixed cap) // grow until the editor would drop below ~280px (rather than a fixed cap)
const max = Math.max(280, window.innerWidth - gitW - treeW - 280) const max = Math.max(280, window.innerWidth - gitW - treeW - 280)
return clamp(w - dx, 280, max) return clamp(w - dx, 280, max)
})} /> }) }} />
{/* keyed by root so the PTYs respawn in the new cwd when the project switches */} {/* keyed by root so the PTYs respawn in the new cwd when the project switches */}
{proj.ready && <RightColumn key={proj.root ?? 'none'} width={rightW} onFocus={() => setFocusZone('terminal')} />} {proj.ready && <RightColumn key={proj.root ?? 'none'} width={rightW} onFocus={() => setFocusZone('terminal')} />}

View File

@@ -20,6 +20,7 @@ export const Icon: Record<string, (p?: SvgProps) => React.ReactElement> = {
minus: (p) => (<svg width="13" height="13" viewBox="0 0 14 14" fill="none" {...p}><path d="M2.5 7h9" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" /></svg>), minus: (p) => (<svg width="13" height="13" viewBox="0 0 14 14" fill="none" {...p}><path d="M2.5 7h9" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" /></svg>),
check: (p) => (<svg width="13" height="13" viewBox="0 0 14 14" fill="none" {...p}><path d="M2.5 7.5l2.8 3L11.5 3.5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" /></svg>), check: (p) => (<svg width="13" height="13" viewBox="0 0 14 14" fill="none" {...p}><path d="M2.5 7.5l2.8 3L11.5 3.5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" /></svg>),
discard: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><path d="M12.5 5.5A5 5 0 1 0 13 9" stroke="currentColor" strokeWidth="1.3" fill="none" strokeLinecap="round" /><path d="M12.5 2.5v3h-3" stroke="currentColor" strokeWidth="1.3" fill="none" strokeLinecap="round" strokeLinejoin="round" /></svg>), discard: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><path d="M12.5 5.5A5 5 0 1 0 13 9" stroke="currentColor" strokeWidth="1.3" fill="none" strokeLinecap="round" /><path d="M12.5 2.5v3h-3" stroke="currentColor" strokeWidth="1.3" fill="none" strokeLinecap="round" strokeLinejoin="round" /></svg>),
layout: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><rect x="2" y="3" width="12" height="10" rx="1.5" stroke="currentColor" strokeWidth="1.3" /><path d="M6 3v10M10 3v10" stroke="currentColor" strokeWidth="1.3" /></svg>),
} }
export const Chevron = ({ open }: { open: boolean }): React.ReactElement => ( export const Chevron = ({ open }: { open: boolean }): React.ReactElement => (

View File

@@ -81,6 +81,9 @@ body {
} }
.tb-btn:hover { background:var(--hover); color:var(--fg-0); } .tb-btn:hover { background:var(--hover); color:var(--fg-0); }
.tb-btn kbd { font-family:var(--mono); font-size:10px; color:var(--fg-3); border:1px solid var(--border-2); border-radius:4px; padding:1px 5px; } .tb-btn kbd { font-family:var(--mono); font-size:10px; color:var(--fg-3); border:1px solid var(--border-2); border-radius:4px; padding:1px 5px; }
.tb-toggle .tb-state { font-family:var(--mono); font-size:10px; border-radius:4px; padding:1px 5px; background:var(--bg-1); color:var(--fg-3); }
.tb-toggle.on { color:var(--fg-1); border-color:var(--border-2); }
.tb-toggle.on .tb-state { background:rgba(77,141,255,0.16); color:var(--accent); }
.workbench { flex:1; display:flex; min-height:0; } .workbench { flex:1; display:flex; min-height:0; }
@@ -239,25 +242,26 @@ body {
.split-label { height:27px; flex:0 0 27px; display:flex; align-items:center; gap:9px; padding:0 16px; font-size:10.5px; letter-spacing:.07em; text-transform:uppercase; color:var(--fg-2); background:var(--bg-2); border-bottom:1px solid var(--border); } .split-label { height:27px; flex:0 0 27px; display:flex; align-items:center; gap:9px; padding:0 16px; font-size:10.5px; letter-spacing:.07em; text-transform:uppercase; color:var(--fg-2); background:var(--bg-2); border-bottom:1px solid var(--border); }
.split-label span { text-transform:none; letter-spacing:0; color:var(--fg-3); font-family:var(--mono); font-size:10.5px; } .split-label span { text-transform:none; letter-spacing:0; color:var(--fg-3); font-family:var(--mono); font-size:10.5px; }
/* syntax token colors */ /* syntax token colors — applied to both the read-only line views (.ln-code)
.ln-code .token.keyword,.ln-code .token.rule,.ln-code .token.atrule,.ln-code .token.important{color:var(--t-key);} * and the editable buffer's highlight layer (.ce-pre) */
.ln-code .token.string,.ln-code .token.attr-value,.ln-code .token.char,.ln-code .token.regex{color:var(--t-str);} .ln-code .token.keyword,.ln-code .token.rule,.ln-code .token.atrule,.ln-code .token.important,.ce-pre .token.keyword,.ce-pre .token.rule,.ce-pre .token.atrule,.ce-pre .token.important{color:var(--t-key);}
.ln-code .token.number,.ln-code .token.unit{color:var(--t-num);} .ln-code .token.string,.ln-code .token.attr-value,.ln-code .token.char,.ln-code .token.regex,.ce-pre .token.string,.ce-pre .token.attr-value,.ce-pre .token.char,.ce-pre .token.regex{color:var(--t-str);}
.ln-code .token.function,.ln-code .token.method{color:var(--t-fn);} .ln-code .token.number,.ln-code .token.unit,.ce-pre .token.number,.ce-pre .token.unit{color:var(--t-num);}
.ln-code .token.comment,.ln-code .token.prolog,.ln-code .token.doctype,.ln-code .token.cdata{color:var(--t-com);font-style:italic;} .ln-code .token.function,.ln-code .token.method,.ce-pre .token.function,.ce-pre .token.method{color:var(--t-fn);}
.ln-code .token.tag{color:var(--t-tag);} .ln-code .token.comment,.ln-code .token.prolog,.ln-code .token.doctype,.ln-code .token.cdata,.ce-pre .token.comment,.ce-pre .token.prolog,.ce-pre .token.doctype,.ce-pre .token.cdata{color:var(--t-com);font-style:italic;}
.ln-code .token.attr-name{color:var(--t-attr);} .ln-code .token.tag,.ce-pre .token.tag{color:var(--t-tag);}
.ln-code .token.punctuation{color:var(--t-punc);} .ln-code .token.attr-name,.ce-pre .token.attr-name{color:var(--t-attr);}
.ln-code .token.operator{color:var(--t-punc);} .ln-code .token.punctuation,.ce-pre .token.punctuation{color:var(--t-punc);}
.ln-code .token.variable,.ln-code .token.symbol{color:var(--t-var);} .ln-code .token.operator,.ce-pre .token.operator{color:var(--t-punc);}
.ln-code .token.constant,.ln-code .token.boolean,.ln-code .token.builtin{color:var(--t-const);} .ln-code .token.variable,.ln-code .token.symbol,.ce-pre .token.variable,.ce-pre .token.symbol{color:var(--t-var);}
.ln-code .token.property,.ln-code .token.property-access{color:var(--t-prop);} .ln-code .token.constant,.ln-code .token.boolean,.ln-code .token.builtin,.ce-pre .token.constant,.ce-pre .token.boolean,.ce-pre .token.builtin{color:var(--t-const);}
.ln-code .token.class-name,.ln-code .token.maybe-class-name{color:var(--t-attr);} .ln-code .token.property,.ln-code .token.property-access,.ce-pre .token.property,.ce-pre .token.property-access{color:var(--t-prop);}
.ln-code .token.parameter{color:var(--fg-0);} .ln-code .token.class-name,.ln-code .token.maybe-class-name,.ce-pre .token.class-name,.ce-pre .token.maybe-class-name{color:var(--t-attr);}
.ln-code .token.namespace{color:var(--fg-2);} .ln-code .token.parameter,.ce-pre .token.parameter{color:var(--fg-0);}
.ln-code .token.selector{color:var(--t-tag);} .ln-code .token.namespace,.ce-pre .token.namespace{color:var(--fg-2);}
.ln-code .token.entity,.ln-code .token.url{color:var(--t-prop);} .ln-code .token.selector,.ce-pre .token.selector{color:var(--t-tag);}
.ln-code .token.deleted{color:var(--del);} .ln-code .token.inserted{color:var(--add);} .ln-code .token.entity,.ln-code .token.url,.ce-pre .token.entity,.ce-pre .token.url{color:var(--t-prop);}
.ln-code .token.deleted,.ce-pre .token.deleted{color:var(--del);} .ln-code .token.inserted,.ce-pre .token.inserted{color:var(--add);}
/* ============ terminals (right column) ============ */ /* ============ terminals (right column) ============ */
.term-pane { display:flex; flex-direction:column; min-height:0; background:var(--bg-1); } .term-pane { display:flex; flex-direction:column; min-height:0; background:var(--bg-1); }

View File

@@ -29,7 +29,7 @@ const THEME = {
export function Terminal({ kind }: { kind: 'agent' | 'shell' }): React.ReactElement { export function Terminal({ kind }: { kind: 'agent' | 'shell' }): React.ReactElement {
const hostRef = useRef<HTMLDivElement>(null) const hostRef = useRef<HTMLDivElement>(null)
const [live, setLive] = useState(kind === 'agent') const [, setLive] = useState(kind === 'agent')
useEffect(() => { useEffect(() => {
const bridge = window.helder const bridge = window.helder
@@ -102,11 +102,6 @@ export function Terminal({ kind }: { kind: 'agent' | 'shell' }): React.ReactElem
return ( return (
<div className="term-pane" style={{ flex: 1, minHeight: 0 }} onMouseDown={() => hostRef.current?.querySelector('textarea')?.focus()}> <div className="term-pane" style={{ flex: 1, minHeight: 0 }} onMouseDown={() => hostRef.current?.querySelector('textarea')?.focus()}>
<div className="term-head">
<span className={'dot' + (live ? ' live' : '')}></span>
<span className="lbl">{kind === 'agent' ? 'claude' : 'zsh'}</span>
<span className="tag">{kind === 'agent' ? 'agent session' : '— shell'}</span>
</div>
<div className="term-xterm" ref={hostRef} /> <div className="term-xterm" ref={hostRef} />
</div> </div>
) )