several nice UI improvements
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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')} />}
|
||||||
|
|||||||
@@ -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 => (
|
||||||
|
|||||||
@@ -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); }
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user