From 7bac3fe7c591f47f948e1944e22bd3b2b1bd84fa Mon Sep 17 00:00:00 2001 From: Jonathan van Rij Date: Tue, 16 Jun 2026 08:52:22 +0200 Subject: [PATCH] several nice UI improvements --- src/main/pty-service.ts | 30 +++++++++++++++++++---- src/renderer/src/App.tsx | 19 +++++++++++---- src/renderer/src/components.tsx | 1 + src/renderer/src/styles.css | 42 ++++++++++++++++++--------------- src/renderer/src/terminals.tsx | 7 +----- 5 files changed, 64 insertions(+), 35 deletions(-) diff --git a/src/main/pty-service.ts b/src/main/pty-service.ts index 03f4984..13b5fce 100644 --- a/src/main/pty-service.ts +++ b/src/main/pty-service.ts @@ -25,6 +25,19 @@ try { const terms = new Map() 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 { const configured = getConfig().terminal.shell 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 { if (!pty) return -1 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', cols: cols || 80, rows: rows || 24, cwd, - env: process.env as { [key: string]: string }, + env: ptyEnv(), }) const id = ++seq 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.onExit(() => { terms.delete(id); if (!sender.isDestroyed()) sender.send('pty:exit', { id }) }) - const ai = getConfig().ai - if (kind === 'agent' && ai.autoLaunch) { - // small delay so the shell prompt is ready before we type the command + // Windows path keeps the type-into-shell launch (no `-i -c` semantics there). + if (kind === 'agent' && ai.autoLaunch && process.platform === 'win32') { setTimeout(() => { try { proc.write(ai.command + '\r') } catch { /* exited */ } }, 350) } return id diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 143a7fd..d776bff 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -140,10 +140,15 @@ export function App(): React.ReactElement { // Re-applied on resize + focus change; dragging still works in between. const FOCUS_RESIZE_BELOW = 1650 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 [treeW, setTreeW] = useState(() => Math.round(window.innerWidth * 0.15)) const [rightW, setRightW] = useState(() => Math.round(window.innerWidth * 0.3)) useEffect(() => { + if (!autoResize) return function apply(): void { const w = window.innerWidth if (w >= FOCUS_RESIZE_BELOW) { @@ -160,7 +165,7 @@ export function App(): React.ReactElement { apply() window.addEventListener('resize', apply) return () => window.removeEventListener('resize', apply) - }, [focusZone]) + }, [focusZone, autoResize]) // Seed explorer expansion from the tree's `open` flags once per opened project. const seededRoot = useRef(undefined) @@ -368,6 +373,10 @@ export function App(): React.ReactElement {
+
@@ -379,7 +388,7 @@ export function App(): React.ReactElement { onStage={stageGuarded} onUnstage={unstageGuarded} onStageAll={actions.stageAll} onUnstageAll={actions.unstageAll} onCommit={commit} onOpen={openFile} onContext={openMenu} activePath={active} /> - setGitW((w) => clamp(w + dx, 160, 460))} /> + { setAutoResize(false); setGitW((w) => clamp(w + dx, 160, 460)) }} />
{proj.tree ? ( @@ -389,7 +398,7 @@ export function App(): React.ReactElement {
)}
- setTreeW((w) => clamp(w + dx, 160, 520))} /> + { setAutoResize(false); setTreeW((w) => clamp(w + dx, 160, 520)) }} />
setFocusZone('editor')}>
- setRightW((w) => { + { setAutoResize(false); setRightW((w) => { // grow until the editor would drop below ~280px (rather than a fixed cap) const max = Math.max(280, window.innerWidth - gitW - treeW - 280) return clamp(w - dx, 280, max) - })} /> + }) }} /> {/* keyed by root so the PTYs respawn in the new cwd when the project switches */} {proj.ready && setFocusZone('terminal')} />} diff --git a/src/renderer/src/components.tsx b/src/renderer/src/components.tsx index ec5c4dc..2edce2d 100644 --- a/src/renderer/src/components.tsx +++ b/src/renderer/src/components.tsx @@ -20,6 +20,7 @@ export const Icon: Record React.ReactElement> = { minus: (p) => (), check: (p) => (), discard: (p) => (), + layout: (p) => (), } export const Chevron = ({ open }: { open: boolean }): React.ReactElement => ( diff --git a/src/renderer/src/styles.css b/src/renderer/src/styles.css index 9773637..cd51716 100644 --- a/src/renderer/src/styles.css +++ b/src/renderer/src/styles.css @@ -81,6 +81,9 @@ body { } .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-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; } @@ -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 span { text-transform:none; letter-spacing:0; color:var(--fg-3); font-family:var(--mono); font-size:10.5px; } -/* syntax token colors */ -.ln-code .token.keyword,.ln-code .token.rule,.ln-code .token.atrule,.ln-code .token.important{color:var(--t-key);} -.ln-code .token.string,.ln-code .token.attr-value,.ln-code .token.char,.ln-code .token.regex{color:var(--t-str);} -.ln-code .token.number,.ln-code .token.unit{color:var(--t-num);} -.ln-code .token.function,.ln-code .token.method{color:var(--t-fn);} -.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.tag{color:var(--t-tag);} -.ln-code .token.attr-name{color:var(--t-attr);} -.ln-code .token.punctuation{color:var(--t-punc);} -.ln-code .token.operator{color:var(--t-punc);} -.ln-code .token.variable,.ln-code .token.symbol{color:var(--t-var);} -.ln-code .token.constant,.ln-code .token.boolean,.ln-code .token.builtin{color:var(--t-const);} -.ln-code .token.property,.ln-code .token.property-access{color:var(--t-prop);} -.ln-code .token.class-name,.ln-code .token.maybe-class-name{color:var(--t-attr);} -.ln-code .token.parameter{color:var(--fg-0);} -.ln-code .token.namespace{color:var(--fg-2);} -.ln-code .token.selector{color:var(--t-tag);} -.ln-code .token.entity,.ln-code .token.url{color:var(--t-prop);} -.ln-code .token.deleted{color:var(--del);} .ln-code .token.inserted{color:var(--add);} +/* syntax token colors — applied to both the read-only line views (.ln-code) + * and the editable buffer's highlight layer (.ce-pre) */ +.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.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.number,.ln-code .token.unit,.ce-pre .token.number,.ce-pre .token.unit{color:var(--t-num);} +.ln-code .token.function,.ln-code .token.method,.ce-pre .token.function,.ce-pre .token.method{color:var(--t-fn);} +.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.tag,.ce-pre .token.tag{color:var(--t-tag);} +.ln-code .token.attr-name,.ce-pre .token.attr-name{color:var(--t-attr);} +.ln-code .token.punctuation,.ce-pre .token.punctuation{color:var(--t-punc);} +.ln-code .token.operator,.ce-pre .token.operator{color:var(--t-punc);} +.ln-code .token.variable,.ln-code .token.symbol,.ce-pre .token.variable,.ce-pre .token.symbol{color:var(--t-var);} +.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.property,.ln-code .token.property-access,.ce-pre .token.property,.ce-pre .token.property-access{color:var(--t-prop);} +.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.parameter,.ce-pre .token.parameter{color:var(--fg-0);} +.ln-code .token.namespace,.ce-pre .token.namespace{color:var(--fg-2);} +.ln-code .token.selector,.ce-pre .token.selector{color:var(--t-tag);} +.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) ============ */ .term-pane { display:flex; flex-direction:column; min-height:0; background:var(--bg-1); } diff --git a/src/renderer/src/terminals.tsx b/src/renderer/src/terminals.tsx index d5ca1b7..f1aed5c 100644 --- a/src/renderer/src/terminals.tsx +++ b/src/renderer/src/terminals.tsx @@ -29,7 +29,7 @@ const THEME = { export function Terminal({ kind }: { kind: 'agent' | 'shell' }): React.ReactElement { const hostRef = useRef(null) - const [live, setLive] = useState(kind === 'agent') + const [, setLive] = useState(kind === 'agent') useEffect(() => { const bridge = window.helder @@ -102,11 +102,6 @@ export function Terminal({ kind }: { kind: 'agent' | 'shell' }): React.ReactElem return (
hostRef.current?.querySelector('textarea')?.focus()}> -
- - {kind === 'agent' ? 'claude' : 'zsh'} - {kind === 'agent' ? 'agent session' : '— shell'} -
)