update lots of stuff
Some checks failed
CI / check (push) Has been cancelled

This commit is contained in:
2026-06-19 10:41:32 +02:00
parent 5e5fc53dde
commit 6114ce440d
7 changed files with 299 additions and 104 deletions

View File

@@ -37,7 +37,7 @@ function Splitter({ orientation = 'v', onDelta }: { orientation?: 'v' | 'h'; onD
return <div className={'splitter' + (orientation === 'h' ? ' h' : '') + (drag ? ' drag' : '')} onMouseDown={down} /> return <div className={'splitter' + (orientation === 'h' ? ' h' : '') + (drag ? ' drag' : '')} onMouseDown={down} />
} }
function RightColumn({ width, onFocus }: { width: number; onFocus: () => void }): React.ReactElement { function RightColumn({ width, active, onFocus }: { width: number; active: boolean; onFocus: () => void }): React.ReactElement {
const [topFrac, setTopFrac] = useState(() => loadNum('helder.topFrac', 0.52)) const [topFrac, setTopFrac] = useState(() => loadNum('helder.topFrac', 0.52))
const ref = useRef<HTMLDivElement>(null) const ref = useRef<HTMLDivElement>(null)
useEffect(() => saveNum('helder.topFrac', topFrac), [topFrac]) useEffect(() => saveNum('helder.topFrac', topFrac), [topFrac])
@@ -46,7 +46,7 @@ function RightColumn({ width, onFocus }: { width: number; onFocus: () => void })
setTopFrac((f) => Math.max(0.18, Math.min(0.82, (f * h + dy) / h))) setTopFrac((f) => Math.max(0.18, Math.min(0.82, (f * h + dy) / h)))
} }
return ( return (
<div className="col right-col" style={{ width, flex: '0 0 ' + width + 'px' }} onMouseDownCapture={onFocus}> <div className={'col right-col' + (active ? ' panel-active' : '')} style={{ width, flex: '0 0 ' + width + 'px' }} onMouseDownCapture={onFocus}>
<div ref={ref} style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}> <div ref={ref} style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
<div style={{ flex: '0 0 ' + (topFrac * 100) + '%', minHeight: 0, display: 'flex' }}> <div style={{ flex: '0 0 ' + (topFrac * 100) + '%', minHeight: 0, display: 'flex' }}>
<Terminal kind="agent" /> <Terminal kind="agent" />
@@ -88,7 +88,7 @@ export function App(): React.ReactElement {
const [toasts, setToasts] = useState<Toast[]>([]) const [toasts, setToasts] = useState<Toast[]>([])
const [splitFor, setSplitFor] = useState<string | null>(null) const [splitFor, setSplitFor] = useState<string | null>(null)
const [commitMsg, setCommitMsg] = useState('') const [commitMsg, setCommitMsg] = useState('')
const [passPopup, setPassPopup] = useState<{ x: number; y: number; ref: string } | null>(null) const [passPopup, setPassPopup] = useState<{ x: number; y: number; ref: string; code?: string } | null>(null)
const [newFilePopup, setNewFilePopup] = useState<{ x: number; y: number; dir: string } | null>(null) const [newFilePopup, setNewFilePopup] = useState<{ x: number; y: number; dir: string } | null>(null)
const [newFolderPopup, setNewFolderPopup] = useState<{ x: number; y: number; dir: string } | null>(null) const [newFolderPopup, setNewFolderPopup] = useState<{ x: number; y: number; dir: string } | null>(null)
const [confirm, setConfirm] = useState<{ title: string; body?: string; confirmLabel: string; onConfirm: () => void } | null>(null) const [confirm, setConfirm] = useState<{ title: string; body?: string; confirmLabel: string; onConfirm: () => void } | null>(null)
@@ -161,15 +161,20 @@ export function App(): React.ReactElement {
} }
// Proportional columns, two regimes (Editor C is the flex remainder): // Proportional columns, two regimes (Editor C is the flex remainder):
// ≥ 1650px (roomy) → Git 10% · Explorer 10% · Editor 40% · Right 40% // ≥ 1600px (roomy) → Git 10% · Explorer 15% · Editor 37% · Right 38%
// (no focus-driven changes — everything fits) // (no focus-driven changes — everything fits)
// < 1650px (tight) → Git 15% · Explorer 15%, Editor/Right react to focus: // < 1600px (tight) → Git 15% · Explorer 15%, Editor/Right react to focus:
// default Editor 40% / Right 30% // default Editor 40% / Right 30%
// focus editor → Editor 50% / Right 20% // focus editor → Editor 50% / Right 20%
// focus agent/terminal → Editor 20% / Right 50% // focus agent/terminal → Editor 20% / Right 50%
// 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 = 1600
const [focusZone, setFocusZone] = useState<'default' | 'editor' | 'terminal'>('default') const [focusZone, setFocusZone] = useState<'default' | 'editor' | 'terminal'>('default')
// Which column currently has focus — drives the active-panel tint and keyboard
// navigation (arrows move a row cursor in Git/Explorer, ⌘→ opens its menu).
const [activePanel, setActivePanel] = useState<'git' | 'tree' | 'editor' | 'terminal' | null>(null)
const [gitSel, setGitSel] = useState(0)
const [treeSel, setTreeSel] = useState(0)
// Auto panel management: re-fit columns on resize/focus. Manually dragging a // Auto panel management: re-fit columns on resize/focus. Manually dragging a
// splitter switches it off (the user took control); the title-bar toggle // splitter switches it off (the user took control); the title-bar toggle
// turns it back on (and immediately re-fits). // turns it back on (and immediately re-fits).
@@ -184,8 +189,8 @@ export function App(): React.ReactElement {
const w = window.innerWidth const w = window.innerWidth
if (w >= FOCUS_RESIZE_BELOW) { if (w >= FOCUS_RESIZE_BELOW) {
setGitW(Math.round(w * 0.1)) setGitW(Math.round(w * 0.1))
setTreeW(Math.round(w * 0.1)) setTreeW(Math.round(w * 0.15))
setRightW(Math.round(w * 0.4)) setRightW(Math.round(w * 0.38))
} else { } else {
setGitW(Math.round(w * 0.15)) setGitW(Math.round(w * 0.15))
setTreeW(Math.round(w * 0.15)) setTreeW(Math.round(w * 0.15))
@@ -198,6 +203,43 @@ export function App(): React.ReactElement {
return () => window.removeEventListener('resize', apply) return () => window.removeEventListener('resize', apply)
}, [focusZone, autoResize]) }, [focusZone, autoResize])
// Flat, render-order lists of the rows in Git (A) and Explorer (B) — the targets
// for arrow-key navigation. Git: staged group then changes group. Tree: the
// currently-visible nodes (honouring expansion + the hidden-files toggle).
const gitNav = useMemo(() => {
const visible = proj.changes.filter((c) => !NO_COMMITTED.has(c.path))
const stagedRows = visible.filter((c) => proj.staged.has(c.path))
const changeRows = visible.filter((c) => !proj.staged.has(c.path))
return [...stagedRows, ...changeRows].map((c) => c.path)
}, [proj.changes, proj.staged])
const treeNav = useMemo(() => {
const out: { path: string; type: 'dir' | 'file' }[] = []
const walk = (node: FileNode): void => {
if (node.type === 'dir') {
const isOpen = openDirs.has(node.path) || node.path === ''
if (node.path !== '') out.push({ path: node.path, type: 'dir' })
if (isOpen) (node.children || []).filter((c) => showHidden || !c.name.startsWith('.')).forEach(walk)
} else {
out.push({ path: node.path, type: 'file' })
}
}
if (proj.tree) walk(proj.tree)
return out
}, [proj.tree, openDirs, showHidden])
const gitSelPath = gitNav[gitSel] ?? null
const treeSelItem = treeNav[treeSel] ?? null
// Keep the row cursors in range as the lists shrink/grow.
useEffect(() => { setGitSel((s) => Math.min(s, Math.max(0, gitNav.length - 1))) }, [gitNav.length])
useEffect(() => { setTreeSel((s) => Math.min(s, Math.max(0, treeNav.length - 1))) }, [treeNav.length])
// Scroll the selected row into view when navigating with the keyboard.
useEffect(() => {
if (activePanel === 'git') document.querySelector('.git-row.kbd')?.scrollIntoView({ block: 'nearest' })
}, [gitSel, activePanel])
useEffect(() => {
if (activePanel === 'tree') document.querySelector('.tree-row.kbd')?.scrollIntoView({ block: 'nearest' })
}, [treeSel, activePanel])
// Flash the branch · repository banner for ~2s each time the window gains focus. // Flash the branch · repository banner for ~2s each time the window gains focus.
useEffect(() => { useEffect(() => {
let timer: ReturnType<typeof setTimeout> let timer: ReturnType<typeof setTimeout>
@@ -412,41 +454,44 @@ export function App(): React.ReactElement {
} }
// ---- context menus ---- // ---- context menus ----
function openMenu(e: React.MouseEvent, target: ContextTarget): void { // Naming contract: every "Pass on …" action opens the input popup (so the user
e.preventDefault(); e.stopPropagation() // can attach a note), and its "Copy …" twin sits directly below it. Pass first,
const sparkSend = (ref: string): Menu['items'][number] => ({ icon: Icon.spark({ style: { color: 'var(--ren)' } }), label: 'Send reference to agent', onClick: () => { window.dispatchEvent(new CustomEvent('agentPaste', { detail: ref })); toast('Passed to agent', ref) } }) // Copy under it.
function buildMenu(target: ContextTarget, x: number, y: number): { items: Menu['items']; note: string; path?: string } {
const spark = Icon.spark({ style: { color: 'var(--ren)' } })
const openPass = (ref: string, code?: string): void => setPassPopup({ x, y, ref, code })
if (target.kind === 'editor') { if (target.kind === 'editor') {
const ref = target.sel ? `${target.path}:${target.sel.start}-${target.sel.end}` : `${target.path}:${target.line}` const hasCode = !!(target.code && target.code.length)
const mx = e.clientX, my = e.clientY const ref = target.sel
setMenu({ ? (target.sel.start === target.sel.end ? `${target.path}:${target.sel.start}` : `${target.path}:${target.sel.start}-${target.sel.end}`)
x: mx, y: my, note: ref, : `${target.path}:${target.line ?? 1}`
return {
note: ref,
items: [ items: [
{ primary: true, icon: Icon.copy(), label: 'Copy reference', onClick: () => copyText(ref) }, { primary: true, icon: spark, label: hasCode ? 'Pass on selection' : 'Pass on reference', onClick: () => openPass(ref, hasCode ? target.code : undefined) },
{ icon: Icon.spark({ style: { color: 'var(--ren)' } }), label: 'Pass on to Agent', onClick: () => setPassPopup({ x: mx, y: my, ref }) }, { icon: Icon.copy(), label: 'Copy reference', onClick: () => copyText(ref) },
], ],
}) }
} else { }
const isDir = target.kind === 'dir' const isDir = target.kind === 'dir'
const ref = isDir ? target.path + '/' : target.path const ref = isDir ? target.path + '/' : target.path
const name = target.path.split('/').pop() as string const name = target.path.split('/').pop() as string
const items: Menu['items'] = [ const items: Menu['items'] = [
{ primary: true, icon: Icon.copy(), label: 'Copy reference', onClick: () => copyText(ref) }, { primary: true, icon: spark, label: 'Pass on reference', onClick: () => openPass(ref) },
sparkSend(ref), { icon: Icon.copy(), label: 'Copy reference', onClick: () => copyText(ref) },
] ]
// For a folder, "Copy reference" already yields the path — so only files get // A folder's reference already is its path; only files get the basename twin.
// the extra "Copy file name" (just the basename, distinct from the path).
if (!isDir) { if (!isDir) {
items.push({ icon: spark, label: 'Pass on file name', onClick: () => openPass(name) })
items.push({ icon: Icon.copy({ style: { color: 'var(--mod)' } }), label: 'Copy file name', onClick: () => copyText(name, 'Copied') }) items.push({ icon: Icon.copy({ style: { color: 'var(--mod)' } }), label: 'Copy file name', onClick: () => copyText(name, 'Copied') })
} }
if (isDir) { if (isDir) {
const mx = e.clientX, my = e.clientY
items.push({ sep: true }) items.push({ sep: true })
items.push({ icon: Icon.file({ style: { color: 'var(--add)' } }), label: 'New file', onClick: () => setNewFilePopup({ x: mx, y: my, dir: target.path }) }) items.push({ icon: Icon.file({ style: { color: 'var(--add)' } }), label: 'New file', onClick: () => setNewFilePopup({ x, y, dir: target.path }) })
items.push({ icon: Icon.folder({ style: { color: 'var(--add)' } }), label: 'New folder', onClick: () => setNewFolderPopup({ x: mx, y: my, dir: target.path }) }) items.push({ icon: Icon.folder({ style: { color: 'var(--add)' } }), label: 'New folder', onClick: () => setNewFolderPopup({ x, y, dir: target.path }) })
} }
if (!isDir) {
items.push({ sep: true })
if (target.kind === 'git') { if (target.kind === 'git') {
items.push({ sep: true })
const isStaged = proj.staged.has(target.path) const isStaged = proj.staged.has(target.path)
items.push(isStaged items.push(isStaged
? { icon: Icon.minus({ style: { color: 'var(--mod)' } }), label: 'Unstage changes', onClick: () => unstageGuarded(target.path) } ? { icon: Icon.minus({ style: { color: 'var(--mod)' } }), label: 'Unstage changes', onClick: () => unstageGuarded(target.path) }
@@ -454,16 +499,45 @@ export function App(): React.ReactElement {
items.push({ icon: Icon.diff({ style: { color: 'var(--ren)' } }), label: 'Open diff', onClick: () => openFile(target.path, { diff: true }) }) items.push({ icon: Icon.diff({ style: { color: 'var(--ren)' } }), label: 'Open diff', onClick: () => openFile(target.path, { diff: true }) })
items.push({ icon: Icon.discard({ style: { color: 'var(--del)' } }), label: 'Discard changes', onClick: () => doDiscard(target.path) }) items.push({ icon: Icon.discard({ style: { color: 'var(--del)' } }), label: 'Discard changes', onClick: () => doDiscard(target.path) })
} }
items.push({ icon: Icon.file({ style: { color: 'var(--ren)' } }), label: 'Open file', onClick: () => openFile(target.path) })
}
// Show in Finder + delete — for explorer files and folders (not git rows). // Show in Finder + delete — for explorer files and folders (not git rows).
if (isDir || target.kind === 'file') { if (isDir || target.kind === 'file') {
items.push({ sep: true }) items.push({ sep: true })
items.push({ icon: Icon.finder({ style: { color: 'var(--mod)' } }), label: 'Show in Finder', onClick: () => revealInFinder(target.path) }) items.push({ icon: Icon.finder({ style: { color: 'var(--mod)' } }), label: 'Show in Finder', onClick: () => revealInFinder(target.path) })
items.push({ icon: Icon.trash({ style: { color: 'var(--del)' } }), label: isDir ? 'Delete folder' : 'Delete file', onClick: () => askDelete(target.path, isDir) }) items.push({ icon: Icon.trash({ style: { color: 'var(--del)' } }), label: isDir ? 'Delete folder' : 'Delete file', onClick: () => askDelete(target.path, isDir) })
} }
setMenu({ x: e.clientX, y: e.clientY, note: ref, path: target.path, items }) return { items, note: ref, path: target.path }
} }
function openMenuAt(x: number, y: number, target: ContextTarget): void {
const { items, note, path } = buildMenu(target, x, y)
setMenu({ x, y, note, path, items })
}
function openMenu(e: React.MouseEvent, target: ContextTarget): void {
e.preventDefault(); e.stopPropagation()
openMenuAt(e.clientX, e.clientY, target)
}
// ⌘→ inside Git/Explorer: open the selected row's menu, anchored to its row.
function openPanelMenu(): boolean {
if (activePanel === 'git' && gitSelPath) {
const r = document.querySelector('.git-row.kbd')?.getBoundingClientRect()
openMenuAt(r ? r.right - 40 : 220, r ? r.top + 4 : 120, { path: gitSelPath, kind: 'git', staged: proj.staged.has(gitSelPath) })
return true
}
if (activePanel === 'tree' && treeSelItem) {
const r = document.querySelector('.tree-row.kbd')?.getBoundingClientRect()
openMenuAt(r ? r.right - 40 : 220, r ? r.top + 4 : 120, { path: treeSelItem.path, kind: treeSelItem.type })
return true
}
return false
}
// ↵ inside Git/Explorer: open the selected file (git → diff), toggle a folder.
function openPanelSelection(): boolean {
if (activePanel === 'git' && gitSelPath) { openFile(gitSelPath, { diff: true }); return true }
if (activePanel === 'tree' && treeSelItem) {
if (treeSelItem.type === 'dir') toggleDir(treeSelItem.path)
else openFile(treeSelItem.path)
return true
}
return false
} }
// ⌘M cycles the active file through whatever views it supports: a changed file // ⌘M cycles the active file through whatever views it supports: a changed file
@@ -496,7 +570,20 @@ export function App(): React.ReactElement {
: (window.getSelection()?.toString() ?? '') : (window.getSelection()?.toString() ?? '')
return raw.split('\n')[0].trim() return raw.split('\n')[0].trim()
} }
// ⌘→ with a selection: open Pass-on-to-Agent for the selected line range. // Reconstruct the source for a line range from the rendered Diff/Original view
// (its rows carry data-line + a .ln-code span).
function codeFromDom(start: number, end: number): string {
const parts: string[] = []
document.querySelectorAll<HTMLElement>('.editor .ln-row').forEach((row) => {
const ln = row.dataset.line
if (!ln) return
const n = +ln
if (n >= start && n <= end) parts.push(row.querySelector('.ln-code')?.textContent ?? '')
})
return parts.join('\n')
}
// ⌘→ with a selection: open Pass-on-to-Agent for the selected line range,
// carrying the selected code so it's passed as a fenced block.
// Returns true when a selection was found (so we can swallow the key). // Returns true when a selection was found (so we can swallow the key).
function passSelection(): boolean { function passSelection(): boolean {
if (!active) return false if (!active) return false
@@ -507,20 +594,22 @@ export function App(): React.ReactElement {
const s = v.slice(0, ae.selectionStart).split('\n').length const s = v.slice(0, ae.selectionStart).split('\n').length
const en = v.slice(0, ae.selectionEnd).split('\n').length const en = v.slice(0, ae.selectionEnd).split('\n').length
const ref = s === en ? `${active}:${s}` : `${active}:${s}-${en}` const ref = s === en ? `${active}:${s}` : `${active}:${s}-${en}`
const code = v.slice(ae.selectionStart, ae.selectionEnd)
const r = ae.getBoundingClientRect() const r = ae.getBoundingClientRect()
setPassPopup({ x: r.left + 60, y: r.top + 70, ref }) setPassPopup({ x: r.left + 60, y: r.top + 70, ref, code })
return true return true
} }
// Diff / Original (PaneView) — line range tracked in `selection` state. // Diff / Original (PaneView) — line range tracked in `selection` state.
if (selection && selection.path === active && selection.start !== selection.end) { if (selection && selection.path === active && selection.start !== selection.end) {
const ref = `${active}:${selection.start}-${selection.end}` const ref = `${active}:${selection.start}-${selection.end}`
const code = codeFromDom(selection.start, selection.end)
const dom = window.getSelection() const dom = window.getSelection()
let x = window.innerWidth / 2, y = 150 let x = window.innerWidth / 2, y = 150
if (dom && dom.rangeCount && !dom.isCollapsed) { if (dom && dom.rangeCount && !dom.isCollapsed) {
const rr = dom.getRangeAt(0).getBoundingClientRect() const rr = dom.getRangeAt(0).getBoundingClientRect()
if (rr.width || rr.height) { x = rr.left; y = rr.bottom + 6 } if (rr.width || rr.height) { x = rr.left; y = rr.bottom + 6 }
} }
setPassPopup({ x, y, ref }) setPassPopup({ x, y, ref, code })
return true return true
} }
return false return false
@@ -541,6 +630,22 @@ export function App(): React.ReactElement {
const inField = !!ae && (ae.tagName === 'INPUT' || ae.tagName === 'TEXTAREA') const inField = !!ae && (ae.tagName === 'INPUT' || ae.tagName === 'TEXTAREA')
// The history navigator owns the keyboard while open (it listens in capture phase). // The history navigator owns the keyboard while open (it listens in capture phase).
if (overlay === 'history') return if (overlay === 'history') return
// An open context menu owns the keyboard (arrows / ↵ / esc handled there).
if (menu) return
const inPanel = activePanel === 'git' || activePanel === 'tree'
// Arrow up/down move the row cursor in the focused Git/Explorer panel.
if (!meta && !inField && inPanel && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
e.preventDefault()
const len = activePanel === 'git' ? gitNav.length : treeNav.length
if (len === 0) return
const set = activePanel === 'git' ? setGitSel : setTreeSel
set((s) => e.key === 'ArrowDown' ? Math.min(s + 1, len - 1) : Math.max(s - 1, 0))
return
}
// ↵ opens the selected row (git → diff, file → open, folder → toggle).
if (!meta && !inField && inPanel && e.key === 'Enter') {
if (openPanelSelection()) { e.preventDefault(); return }
}
if (e.key === 'Escape') { if (e.key === 'Escape') {
if (splitFor) setSplitFor(null) if (splitFor) setSplitFor(null)
else if (overlay) setOverlay(null) else if (overlay) setOverlay(null)
@@ -564,8 +669,12 @@ export function App(): React.ReactElement {
// ⌘D deletes the current file (with confirmation). // ⌘D deletes the current file (with confirmation).
else if (meta && e.key.toLowerCase() === 'd') { e.preventDefault(); if (active) askDelete(active, false) } else if (meta && e.key.toLowerCase() === 'd') { e.preventDefault(); if (active) askDelete(active, false) }
else if (meta && e.key.toLowerCase() === 'm') { e.preventDefault(); cycleMode() } else if (meta && e.key.toLowerCase() === 'm') { e.preventDefault(); cycleMode() }
// ⌘→ with a text selection passes that selection to the agent (else native nav). // ⌘→ in Git/Explorer opens the selected row's menu; in the editor it passes
else if (meta && e.key === 'ArrowRight') { if (passSelection()) e.preventDefault() } // the current text selection to the agent (else native nav).
else if (meta && e.key === 'ArrowRight') {
if (inPanel && !inField) { if (openPanelMenu()) e.preventDefault() }
else if (passSelection()) e.preventDefault()
}
// ⌘↵ commits the staged files (unless the commit box has focus — it handles ⇧/⌘↵ itself). // ⌘↵ commits the staged files (unless the commit box has focus — it handles ⇧/⌘↵ itself).
else if (meta && e.key === 'Enter') { else if (meta && e.key === 'Enter') {
if (ae && ae.classList.contains('commit-input')) return if (ae && ae.classList.contains('commit-input')) return
@@ -589,7 +698,7 @@ export function App(): React.ReactElement {
} }
window.addEventListener('keydown', onKey) window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey)
}, [active, splitFor, overlay, focusZone, history, tabMode, commitMsg, proj, selection, confirm]) }, [active, splitFor, overlay, focusZone, history, tabMode, commitMsg, proj, selection, confirm, menu, activePanel, gitNav, treeNav, gitSel, treeSel])
const mode: Mode = (active && tabMode[active]) || (active && proj.diffs[active] ? defaultMode : 'code') const mode: Mode = (active && tabMode[active]) || (active && proj.diffs[active] ? defaultMode : 'code')
@@ -647,25 +756,29 @@ export function App(): React.ReactElement {
{/* workbench */} {/* workbench */}
<div className="workbench"> <div className="workbench">
<div className="col" style={{ width: gitW, flex: '0 0 ' + gitW + 'px' }}> <div className={'col' + (activePanel === 'git' ? ' panel-active' : '')} style={{ width: gitW, flex: '0 0 ' + gitW + 'px' }}
onMouseDownCapture={() => setActivePanel('git')}>
<GitPanel branch={proj.branch} changes={proj.changes} staged={proj.staged} committed={NO_COMMITTED} <GitPanel branch={proj.branch} changes={proj.changes} staged={proj.staged} committed={NO_COMMITTED}
commitMsg={commitMsg} setCommitMsg={setCommitMsg} commitMsg={commitMsg} setCommitMsg={setCommitMsg}
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} ctxPath={menu?.path ?? null} showDir={gitW > 300} /> onOpen={openFile} onContext={openMenu} activePath={active} ctxPath={menu?.path ?? null}
kbdPath={activePanel === 'git' ? gitSelPath : null} showDir={gitW > 300} />
</div> </div>
<Splitter onDelta={(dx) => { setAutoResize(false); 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' + (activePanel === 'tree' ? ' panel-active' : '')} style={{ width: treeW, flex: '0 0 ' + treeW + 'px' }}
onMouseDownCapture={() => setActivePanel('tree')}>
{proj.tree ? ( {proj.tree ? (
<FileTree tree={proj.tree} openDirs={openDirs} toggleDir={toggleDir} onOpen={openFile} <FileTree tree={proj.tree} openDirs={openDirs} toggleDir={toggleDir} onOpen={openFile}
onContext={openMenu} activePath={active} ctxPath={menu?.path ?? null} changeMap={changeMap} committed={NO_COMMITTED} showHidden={showHidden} /> onContext={openMenu} activePath={active} ctxPath={menu?.path ?? null}
kbdPath={activePanel === 'tree' ? (treeSelItem?.path ?? null) : null} changeMap={changeMap} committed={NO_COMMITTED} showHidden={showHidden} />
) : ( ) : (
<div className="tree-body" /> <div className="tree-body" />
)} )}
</div> </div>
<Splitter onDelta={(dx) => { setAutoResize(false); 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' + (activePanel === 'editor' ? ' panel-active' : '')} onMouseDownCapture={() => { setFocusZone('editor'); setActivePanel('editor') }}>
<Editor active={active} mode={mode} <Editor active={active} mode={mode}
setMode={(m) => { if (active) { setTabMode((mm) => ({ ...mm, [active]: m })); setSplitFor(null); reloadFromDisk(active) } }} setMode={(m) => { if (active) { setTabMode((mm) => ({ ...mm, [active]: m })); setSplitFor(null); reloadFromDisk(active) } }}
onContext={openMenu} onContext={openMenu}
@@ -680,15 +793,15 @@ export function App(): React.ReactElement {
}) }} /> }) }} />
{/* 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} active={activePanel === 'terminal'}
onFocus={() => { setFocusZone('terminal'); setActivePanel('terminal') }} />}
</div> </div>
{/* overlays */} {/* overlays */}
{splitFor && <SplitView path={splitFor} onClose={() => setSplitFor(null)} onContext={openMenu} />} {splitFor && <SplitView path={splitFor} onClose={() => setSplitFor(null)} onContext={openMenu} />}
{passPopup && <PassPopup x={passPopup.x} y={passPopup.y} refStr={passPopup.ref} {passPopup && <PassPopup x={passPopup.x} y={passPopup.y} refStr={passPopup.ref} code={passPopup.code}
onConfirm={(text) => { onConfirm={(payload) => {
const line = (text && text.trim() ? text.trim() + ' ' : '') + passPopup.ref window.dispatchEvent(new CustomEvent('agentPaste', { detail: payload }))
window.dispatchEvent(new CustomEvent('agentPaste', { detail: line }))
setPassPopup(null) setPassPopup(null)
toast('Passed to agent', passPopup.ref) toast('Passed to agent', passPopup.ref)
}} }}

View File

@@ -61,15 +61,17 @@ export interface ContextTarget {
staged?: boolean staged?: boolean
sel?: { start: number; end: number } sel?: { start: number; end: number }
line?: number line?: number
code?: string
} }
export type OnContext = (e: React.MouseEvent, target: ContextTarget) => void export type OnContext = (e: React.MouseEvent, target: ContextTarget) => void
/* ============ Git / Source Control panel ============ */ /* ============ Git / Source Control panel ============ */
function GitRow({ c, staged, activePath, ctxPath, showDir, onOpen, onContext, onToggleStage }: { function GitRow({ c, staged, activePath, ctxPath, kbdPath, showDir, onOpen, onContext, onToggleStage }: {
c: Change c: Change
staged: boolean staged: boolean
activePath: string | null activePath: string | null
ctxPath: string | null ctxPath: string | null
kbdPath: string | null
showDir: boolean showDir: boolean
onOpen: OpenFile onOpen: OpenFile
onContext: OnContext onContext: OnContext
@@ -79,7 +81,7 @@ function GitRow({ c, staged, activePath, ctxPath, showDir, onOpen, onContext, on
const dir = c.path.split('/').slice(0, -1).join('/') const dir = c.path.split('/').slice(0, -1).join('/')
const dirShown = showDir && !!dir const dirShown = showDir && !!dir
return ( return (
<div className={'git-row' + (activePath === c.path ? ' active' : '') + (ctxPath === c.path ? ' ctx' : '')} <div className={'git-row' + (activePath === c.path ? ' active' : '') + (ctxPath === c.path ? ' ctx' : '') + (kbdPath === c.path ? ' kbd' : '')}
onClick={() => onOpen(c.path, { diff: true })} onClick={() => onOpen(c.path, { diff: true })}
onContextMenu={(e) => onContext(e, { path: c.path, kind: 'git', staged })} onContextMenu={(e) => onContext(e, { path: c.path, kind: 'git', staged })}
title={c.path}> title={c.path}>
@@ -95,7 +97,7 @@ function GitRow({ c, staged, activePath, ctxPath, showDir, onOpen, onContext, on
) )
} }
export function GitPanel({ branch, changes, staged, committed, commitMsg, setCommitMsg, onStage, onUnstage, onStageAll, onUnstageAll, onCommit, onOpen, onContext, activePath, ctxPath, showDir }: { export function GitPanel({ branch, changes, staged, committed, commitMsg, setCommitMsg, onStage, onUnstage, onStageAll, onUnstageAll, onCommit, onOpen, onContext, activePath, ctxPath, kbdPath, showDir }: {
branch: string branch: string
changes: Change[] changes: Change[]
staged: Set<string> staged: Set<string>
@@ -111,6 +113,7 @@ export function GitPanel({ branch, changes, staged, committed, commitMsg, setCom
onContext: OnContext onContext: OnContext
activePath: string | null activePath: string | null
ctxPath: string | null ctxPath: string | null
kbdPath: string | null
showDir: boolean showDir: boolean
}): React.ReactElement { }): React.ReactElement {
const visible = changes.filter((c) => !committed.has(c.path)) const visible = changes.filter((c) => !committed.has(c.path))
@@ -138,7 +141,7 @@ export function GitPanel({ branch, changes, staged, committed, commitMsg, setCom
{stagedList.length > 0 && <button className="grp-act" title="Unstage all" onClick={onUnstageAll}>{Icon.minus()}</button>} {stagedList.length > 0 && <button className="grp-act" title="Unstage all" onClick={onUnstageAll}>{Icon.minus()}</button>}
</div> </div>
{stagedList.length > 0 ? stagedList.map((c) => ( {stagedList.length > 0 ? stagedList.map((c) => (
<GitRow key={c.path} c={c} staged={true} activePath={activePath} ctxPath={ctxPath} showDir={showDir} <GitRow key={c.path} c={c} staged={true} activePath={activePath} ctxPath={ctxPath} kbdPath={kbdPath} showDir={showDir}
onOpen={onOpen} onContext={onContext} onToggleStage={onUnstage} /> onOpen={onOpen} onContext={onContext} onToggleStage={onUnstage} />
)) : ( )) : (
<div className="git-none">Nothing staged use <span className="key">+</span> to stage a file</div> <div className="git-none">Nothing staged use <span className="key">+</span> to stage a file</div>
@@ -151,7 +154,7 @@ export function GitPanel({ branch, changes, staged, committed, commitMsg, setCom
{changesList.length > 0 && <button className="grp-act" title="Stage all" onClick={onStageAll}>{Icon.plus()}</button>} {changesList.length > 0 && <button className="grp-act" title="Stage all" onClick={onStageAll}>{Icon.plus()}</button>}
</div> </div>
{changesList.length > 0 ? changesList.map((c) => ( {changesList.length > 0 ? changesList.map((c) => (
<GitRow key={c.path} c={c} staged={false} activePath={activePath} ctxPath={ctxPath} showDir={showDir} <GitRow key={c.path} c={c} staged={false} activePath={activePath} ctxPath={ctxPath} kbdPath={kbdPath} showDir={showDir}
onOpen={onOpen} onContext={onContext} onToggleStage={onStage} /> onOpen={onOpen} onContext={onContext} onToggleStage={onStage} />
)) : ( )) : (
<div className="git-none">All changes staged</div> <div className="git-none">All changes staged</div>
@@ -172,7 +175,7 @@ export function GitPanel({ branch, changes, staged, committed, commitMsg, setCom
} }
/* ============ File Tree ============ */ /* ============ File Tree ============ */
function TreeNode({ node, depth, openDirs, toggleDir, onOpen, onContext, activePath, ctxPath, changeMap, committed, showHidden }: { function TreeNode({ node, depth, openDirs, toggleDir, onOpen, onContext, activePath, ctxPath, kbdPath, changeMap, committed, showHidden }: {
node: FileNode node: FileNode
depth: number depth: number
openDirs: Set<string> openDirs: Set<string>
@@ -181,6 +184,7 @@ function TreeNode({ node, depth, openDirs, toggleDir, onOpen, onContext, activeP
onContext: OnContext onContext: OnContext
activePath: string | null activePath: string | null
ctxPath: string | null ctxPath: string | null
kbdPath: string | null
changeMap: Record<string, GitStatus> changeMap: Record<string, GitStatus>
committed: Set<string> committed: Set<string>
showHidden: boolean showHidden: boolean
@@ -191,7 +195,7 @@ function TreeNode({ node, depth, openDirs, toggleDir, onOpen, onContext, activeP
return ( return (
<Fragment> <Fragment>
{node.path !== '' && ( {node.path !== '' && (
<div className={'tree-row folder' + (ctxPath === node.path ? ' ctx' : '')} style={{ paddingLeft: pad }} <div className={'tree-row folder' + (ctxPath === node.path ? ' ctx' : '') + (kbdPath === node.path ? ' kbd' : '')} style={{ paddingLeft: pad }}
onClick={() => toggleDir(node.path)} onClick={() => toggleDir(node.path)}
onContextMenu={(e) => onContext(e, { path: node.path, kind: 'dir' })}> onContextMenu={(e) => onContext(e, { path: node.path, kind: 'dir' })}>
<span className="tw"><Chevron open={isOpen} /></span> <span className="tw"><Chevron open={isOpen} /></span>
@@ -204,14 +208,14 @@ function TreeNode({ node, depth, openDirs, toggleDir, onOpen, onContext, activeP
.map((c) => ( .map((c) => (
<TreeNode key={c.path} node={c} depth={node.path === '' ? 0 : depth + 1} <TreeNode key={c.path} node={c} depth={node.path === '' ? 0 : depth + 1}
openDirs={openDirs} toggleDir={toggleDir} onOpen={onOpen} openDirs={openDirs} toggleDir={toggleDir} onOpen={onOpen}
onContext={onContext} activePath={activePath} ctxPath={ctxPath} changeMap={changeMap} committed={committed} showHidden={showHidden} /> onContext={onContext} activePath={activePath} ctxPath={ctxPath} kbdPath={kbdPath} changeMap={changeMap} committed={committed} showHidden={showHidden} />
))} ))}
</Fragment> </Fragment>
) )
} }
const status = committed && committed.has(node.path) ? null : changeMap[node.path] const status = committed && committed.has(node.path) ? null : changeMap[node.path]
return ( return (
<div className={'tree-row' + (activePath === node.path ? ' active' : '') + (ctxPath === node.path ? ' ctx' : '')} <div className={'tree-row' + (activePath === node.path ? ' active' : '') + (ctxPath === node.path ? ' ctx' : '') + (kbdPath === node.path ? ' kbd' : '')}
style={{ paddingLeft: pad + 2 }} style={{ paddingLeft: pad + 2 }}
onClick={() => onOpen(node.path)} onClick={() => onOpen(node.path)}
onContextMenu={(e) => onContext(e, { path: node.path, kind: 'file' })} onContextMenu={(e) => onContext(e, { path: node.path, kind: 'file' })}
@@ -224,7 +228,7 @@ function TreeNode({ node, depth, openDirs, toggleDir, onOpen, onContext, activeP
) )
} }
export function FileTree({ tree, openDirs, toggleDir, onOpen, onContext, activePath, ctxPath, changeMap, committed, showHidden }: { export function FileTree({ tree, openDirs, toggleDir, onOpen, onContext, activePath, ctxPath, kbdPath, changeMap, committed, showHidden }: {
tree: FileNode tree: FileNode
openDirs: Set<string> openDirs: Set<string>
toggleDir: (path: string) => void toggleDir: (path: string) => void
@@ -232,6 +236,7 @@ export function FileTree({ tree, openDirs, toggleDir, onOpen, onContext, activeP
onContext: OnContext onContext: OnContext
activePath: string | null activePath: string | null
ctxPath: string | null ctxPath: string | null
kbdPath: string | null
changeMap: Record<string, GitStatus> changeMap: Record<string, GitStatus>
committed: Set<string> committed: Set<string>
showHidden: boolean showHidden: boolean
@@ -240,7 +245,7 @@ export function FileTree({ tree, openDirs, toggleDir, onOpen, onContext, activeP
<Fragment> <Fragment>
<div className="tree-body"> <div className="tree-body">
<TreeNode node={tree} depth={0} openDirs={openDirs} toggleDir={toggleDir} <TreeNode node={tree} depth={0} openDirs={openDirs} toggleDir={toggleDir}
onOpen={onOpen} onContext={onContext} activePath={activePath} ctxPath={ctxPath} changeMap={changeMap} committed={committed} showHidden={showHidden} /> onOpen={onOpen} onContext={onContext} activePath={activePath} ctxPath={ctxPath} kbdPath={kbdPath} changeMap={changeMap} committed={committed} showHidden={showHidden} />
</div> </div>
</Fragment> </Fragment>
) )

View File

@@ -54,7 +54,8 @@ function CodeEditor({ path, text, lang, onChange, onContext }: {
const info: Parameters<OnContext>[1] = { path, kind: 'editor', line: startLine } const info: Parameters<OnContext>[1] = { path, kind: 'editor', line: startLine }
if (ta.selectionEnd > ta.selectionStart) { if (ta.selectionEnd > ta.selectionStart) {
const endLine = text.slice(0, ta.selectionEnd).split('\n').length const endLine = text.slice(0, ta.selectionEnd).split('\n').length
if (endLine !== startLine) info.sel = { start: startLine, end: endLine } info.sel = { start: startLine, end: endLine }
info.code = ta.value.slice(ta.selectionStart, ta.selectionEnd)
} }
onContext(e, info) onContext(e, info)
} }
@@ -144,6 +145,10 @@ function PaneView({ cacheKey, path, lines, lang, showSign, cursor, selection, se
if (el) { setCursor({ path, line: +el.dataset.line!, col: caretCol(sel) }); setSelection(null) } if (el) { setCursor({ path, line: +el.dataset.line!, col: caretCol(sel) }); setSelection(null) }
} }
} }
// Join the source lines covered by a selection — the code-block payload for "Pass on selection".
function codeForRange(s: number, en: number): string {
return lines.filter((l) => l.no != null && l.no >= s && l.no <= en).map((l) => l.text).join('\n')
}
function handleContext(e: React.MouseEvent): void { function handleContext(e: React.MouseEvent): void {
e.preventDefault() e.preventDefault()
const sel = window.getSelection() const sel = window.getSelection()
@@ -152,9 +157,10 @@ function PaneView({ cacheKey, path, lines, lang, showSign, cursor, selection, se
const f = sel && sel.focusNode && climbToLine(sel.focusNode) const f = sel && sel.focusNode && climbToLine(sel.focusNode)
if (sel && !sel.isCollapsed && a && f && +a.dataset.line! !== +f.dataset.line!) { if (sel && !sel.isCollapsed && a && f && +a.dataset.line! !== +f.dataset.line!) {
const s = Math.min(+a.dataset.line!, +f.dataset.line!), en = Math.max(+a.dataset.line!, +f.dataset.line!) const s = Math.min(+a.dataset.line!, +f.dataset.line!), en = Math.max(+a.dataset.line!, +f.dataset.line!)
info.sel = { start: s, end: en }; info.line = s info.sel = { start: s, end: en }; info.line = s; info.code = codeForRange(s, en)
} else if (selection && selection.path === path && selection.start !== selection.end) { } else if (selection && selection.path === path && selection.start !== selection.end) {
info.sel = { start: selection.start, end: selection.end }; info.line = selection.start info.sel = { start: selection.start, end: selection.end }; info.line = selection.start
info.code = codeForRange(selection.start, selection.end)
} else { } else {
let no: number | null = null let no: number | null = null
const r = document.caretRangeFromPoint ? document.caretRangeFromPoint(e.clientX, e.clientY) : null const r = document.caretRangeFromPoint ? document.caretRangeFromPoint(e.clientX, e.clientY) : null

View File

@@ -15,6 +15,18 @@ export interface MenuItem {
} }
export interface Menu { x: number; y: number; note?: string; path?: string; items: MenuItem[] } export interface Menu { x: number; y: number; note?: string; path?: string; items: MenuItem[] }
export interface Toast { id: number; title: string; ref?: string } export interface Toast { id: number; title: string; ref?: string }
/** Build the exact text inserted into the agent for a "Pass on …" action.
* - Plain reference / name → `note => thing` (or just `thing` with no note).
* - Selected code → `note [ref](ref)` followed by the code in a fenced block. */
export function buildPass(note: string, ref: string, code?: string): string {
const n = note.trim()
if (code != null) {
const head = (n ? n + ' ' : '') + `[${ref}](${ref})`
return head + '\n```\n' + code + '\n```'
}
return n ? `${n} => ${ref}` : ref
}
interface ContentHit { no: number; ln: string; ix: number } interface ContentHit { no: number; ln: string; ix: number }
interface ContentGroup { path: string; hits: ContentHit[] } interface ContentGroup { path: string; hits: ContentHit[] }
@@ -343,7 +355,9 @@ const SHORTCUTS: { keys: string[]; label: string }[] = [
{ keys: ['↵'], label: 'Open the selected list item' }, { keys: ['↵'], label: 'Open the selected list item' },
{ keys: ['⌘', '←'], label: 'Search: focus the column to the left (this file · project · names)' }, { keys: ['⌘', '←'], label: 'Search: focus the column to the left (this file · project · names)' },
{ keys: ['⌘', '→'], label: 'Search: focus the column to the right' }, { keys: ['⌘', '→'], label: 'Search: focus the column to the right' },
{ keys: ['', ''], label: 'Pass the selected text to the agent' }, { keys: ['', ''], label: 'Git/Explorer: move the row cursor (panel must be focused)' },
{ keys: ['↵'], label: 'Git/Explorer: open the selected row' },
{ keys: ['⌘', '→'], label: 'Git/Explorer: open the row menu · Editor: pass the selection to the agent' },
{ keys: ['⌘', 'M'], label: 'Cycle view: Updated · Original · Diff · Split' }, { keys: ['⌘', 'M'], label: 'Cycle view: Updated · Original · Diff · Split' },
{ keys: ['⌘', 'C'], label: 'Focus the commit message' }, { keys: ['⌘', 'C'], label: 'Focus the commit message' },
{ keys: ['⌘', '↵'], label: 'Commit the staged files' }, { keys: ['⌘', '↵'], label: 'Commit the staged files' },
@@ -414,13 +428,43 @@ export function ConfirmModal({ title, body, confirmLabel, danger, onConfirm, onC
export function ContextMenu({ menu, onClose }: { menu: Menu | null; onClose: () => void }): React.ReactElement | null { export function ContextMenu({ menu, onClose }: { menu: Menu | null; onClose: () => void }): React.ReactElement | null {
const ref = useRef<HTMLDivElement>(null) const ref = useRef<HTMLDivElement>(null)
// Index of the first selectable (non-separator) item, for keyboard highlight.
const items = menu?.items ?? []
const firstSel = items.findIndex((it) => !it.sep)
const [hi, setHi] = useState(firstSel)
const hiRef = useRef(hi); hiRef.current = hi
// Reset the highlight to the first selectable item whenever the menu reopens
// (right-click can swap the target without unmounting this component).
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => { setHi(items.findIndex((it) => !it.sep)) }, [menu])
// Step the highlight to the next/previous selectable item, skipping separators.
function step(dir: 1 | -1): void {
setHi((cur) => {
let i = cur
for (let n = 0; n < items.length; n++) {
i = (i + dir + items.length) % items.length
if (!items[i].sep) return i
}
return cur
})
}
useEffect(() => { useEffect(() => {
const h = (e: MouseEvent): void => { if (ref.current && !ref.current.contains(e.target as Node)) onClose() } const h = (e: MouseEvent): void => { if (ref.current && !ref.current.contains(e.target as Node)) onClose() }
const k = (e: KeyboardEvent): void => { if (e.key === 'Escape') onClose() } const k = (e: KeyboardEvent): void => {
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); onClose() }
else if (e.key === 'ArrowDown') { e.preventDefault(); e.stopPropagation(); step(1) }
else if (e.key === 'ArrowUp') { e.preventDefault(); e.stopPropagation(); step(-1) }
else if (e.key === 'Enter') {
e.preventDefault(); e.stopPropagation()
const it = items[hiRef.current]
if (it && !it.sep) { it.onClick?.(); onClose() }
}
}
document.addEventListener('mousedown', h) document.addEventListener('mousedown', h)
document.addEventListener('keydown', k) document.addEventListener('keydown', k, true)
return () => { document.removeEventListener('mousedown', h); document.removeEventListener('keydown', k) } return () => { document.removeEventListener('mousedown', h); document.removeEventListener('keydown', k, true) }
}, []) // eslint-disable-next-line react-hooks/exhaustive-deps
}, [items.length])
if (!menu) return null if (!menu) return null
const x = Math.min(menu.x, window.innerWidth - 270) const x = Math.min(menu.x, window.innerWidth - 270)
const y = Math.min(menu.y, window.innerHeight - (menu.items.length * 34 + 60)) const y = Math.min(menu.y, window.innerHeight - (menu.items.length * 34 + 60))
@@ -428,7 +472,8 @@ export function ContextMenu({ menu, onClose }: { menu: Menu | null; onClose: ()
<div className="ctx" ref={ref} style={{ left: x, top: y }}> <div className="ctx" ref={ref} style={{ left: x, top: y }}>
{menu.note && <div className="ctx-note">{menu.note}</div>} {menu.note && <div className="ctx-note">{menu.note}</div>}
{menu.items.map((it, i) => it.sep ? <div key={i} className="ctx-sep" /> : ( {menu.items.map((it, i) => it.sep ? <div key={i} className="ctx-sep" /> : (
<div key={i} className={'ctx-item' + (it.primary ? ' primary' : '')} <div key={i} className={'ctx-item' + (it.primary ? ' primary' : '') + (i === hi ? ' hi' : '')}
onMouseEnter={() => setHi(i)}
onClick={() => { it.onClick?.(); onClose() }}> onClick={() => { it.onClick?.(); onClose() }}>
<span className="ic">{it.icon}</span> <span className="ic">{it.icon}</span>
<span>{it.label}</span> <span>{it.label}</span>
@@ -453,11 +498,12 @@ export function Toasts({ toasts }: { toasts: Toast[] }): React.ReactElement {
) )
} }
export function PassPopup({ x, y, refStr, onConfirm, onCancel }: { export function PassPopup({ x, y, refStr, code, onConfirm, onCancel }: {
x: number x: number
y: number y: number
refStr: string refStr: string
onConfirm: (text: string) => void code?: string
onConfirm: (payload: string) => void
onCancel: () => void onCancel: () => void
}): React.ReactElement { }): React.ReactElement {
const [text, setText] = useState('') const [text, setText] = useState('')
@@ -473,7 +519,7 @@ export function PassPopup({ x, y, refStr, onConfirm, onCancel }: {
}, []) }, [])
const left = Math.min(x, window.innerWidth - 360) const left = Math.min(x, window.innerWidth - 360)
const top = Math.min(y + 6, window.innerHeight - 150) const top = Math.min(y + 6, window.innerHeight - 150)
const preview = (text.trim() ? text.trim() + ' ' : '') + refStr const payload = buildPass(text, refStr, code)
return ( return (
<div className="pass-pop" ref={boxRef} style={{ left, top }}> <div className="pass-pop" ref={boxRef} style={{ left, top }}>
<div className="pass-head">{Icon.spark()}<span>Pass on to Agent</span><span className="pass-esc">esc</span></div> <div className="pass-head">{Icon.spark()}<span>Pass on to Agent</span><span className="pass-esc">esc</span></div>
@@ -481,10 +527,10 @@ export function PassPopup({ x, y, refStr, onConfirm, onCancel }: {
placeholder="Add a note (optional)…" placeholder="Add a note (optional)…"
onChange={(e) => setText(e.target.value)} onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter') { e.preventDefault(); onConfirm(text) } if (e.key === 'Enter') { e.preventDefault(); onConfirm(payload) }
else if (e.key === 'Escape') { e.preventDefault(); onCancel() } else if (e.key === 'Escape') { e.preventDefault(); onCancel() }
}} /> }} />
<div className="pass-preview"><span className="pp-lbl">inserts</span><code>{preview}</code></div> <div className="pass-preview"><span className="pp-lbl">inserts</span><code className={code != null ? 'pp-code multiline' : 'pp-code'}>{payload}</code></div>
<div className="pass-foot"><kbd></kbd> insert into agent · <kbd>esc</kbd> cancel</div> <div className="pass-foot"><kbd></kbd> insert into agent · <kbd>esc</kbd> cancel</div>
</div> </div>
) )

View File

@@ -120,8 +120,13 @@ body {
.workbench { flex:1; display:flex; min-height:0; } .workbench { flex:1; display:flex; min-height:0; }
.col { display:flex; flex-direction:column; height:100%; min-width:0; background:var(--bg-2); } .col { display:flex; flex-direction:column; height:100%; min-width:0; background:var(--bg-2); }
.col.editor-col { flex:1; background:var(--bg-0); min-width:240px; } /* Editor (C) shares the side panels' background (--bg-2), matching B (and A). */
.col.editor-col { flex:1; min-width:240px; }
.col.right-col { background:var(--bg-1); } .col.right-col { background:var(--bg-1); }
/* Active panel: subtle lighter-gray tint on the focused column. C uses the same
tint as the side panels, so the file view stays in step with B in/out of focus. */
.col.panel-active { background:#22252a; }
.col.right-col.panel-active { background:#1e2024; }
.splitter { flex:0 0 5px; cursor:col-resize; background:transparent; position:relative; z-index:5; } .splitter { flex:0 0 5px; cursor:col-resize; background:transparent; position:relative; z-index:5; }
.splitter::after { content:""; position:absolute; inset:0 2px; background:var(--border); transition:background .12s; } .splitter::after { content:""; position:absolute; inset:0 2px; background:var(--border); transition:background .12s; }
@@ -165,6 +170,8 @@ body {
.git-row.ctx .git-act { visibility:visible; } .git-row.ctx .git-act { visibility:visible; }
.git-row.active { background:var(--sel); } .git-row.active { background:var(--sel); }
.git-row.active::before { content:""; position:absolute; left:0; top:0; bottom:0; width:2px; background:var(--accent); } .git-row.active::before { content:""; position:absolute; left:0; top:0; bottom:0; width:2px; background:var(--accent); }
.git-row.kbd { background:var(--hover); box-shadow:inset 2px 0 0 var(--accent-line); }
.git-row.kbd .git-act { visibility:visible; }
.git-stat { width:13px; text-align:center; font-family:var(--mono); font-size:11px; font-weight:600; flex:0 0 13px; } .git-stat { width:13px; text-align:center; font-family:var(--mono); font-size:11px; font-weight:600; flex:0 0 13px; }
.git-stat.M{color:var(--mod);} .git-stat.A{color:var(--add);} .git-stat.D{color:var(--del);} .git-stat.R{color:var(--ren);} .git-stat.U{color:var(--add);} .git-stat.M{color:var(--mod);} .git-stat.A{color:var(--add);} .git-stat.D{color:var(--del);} .git-stat.R{color:var(--ren);} .git-stat.U{color:var(--add);}
.git-name { font-size:12.5px; color:var(--fg-1); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } .git-name { font-size:12.5px; color:var(--fg-1); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
@@ -185,6 +192,7 @@ body {
.tree-row:hover, .tree-row.ctx { background:var(--hover); } .tree-row:hover, .tree-row.ctx { background:var(--hover); }
.tree-row.active { background:var(--sel); } .tree-row.active { background:var(--sel); }
.tree-row.active::before { content:""; position:absolute; left:0; top:0; bottom:0; width:2px; background:var(--accent); } .tree-row.active::before { content:""; position:absolute; left:0; top:0; bottom:0; width:2px; background:var(--accent); }
.tree-row.kbd { background:var(--hover); box-shadow:inset 2px 0 0 var(--accent-line); }
.tw { display:inline-flex; align-items:center; justify-content:center; width:14px; flex:0 0 14px; color:var(--fg-3); font-size:10px; } .tw { display:inline-flex; align-items:center; justify-content:center; width:14px; flex:0 0 14px; color:var(--fg-3); font-size:10px; }
.tree-label { font-size:12.5px; color:var(--fg-1); overflow:hidden; text-overflow:ellipsis; } .tree-label { font-size:12.5px; color:var(--fg-1); overflow:hidden; text-overflow:ellipsis; }
.tree-row.active .tree-label { color:var(--fg-0); } .tree-row.active .tree-label { color:var(--fg-0); }
@@ -399,6 +407,7 @@ body {
.pass-preview { margin-top:9px; display:flex; align-items:center; gap:8px; min-width:0; } .pass-preview { margin-top:9px; display:flex; align-items:center; gap:8px; min-width:0; }
.pass-preview .pp-lbl { font-size:10px; text-transform:uppercase; letter-spacing:.06em; color:var(--fg-3); flex:0 0 auto; } .pass-preview .pp-lbl { font-size:10px; text-transform:uppercase; letter-spacing:.06em; color:var(--fg-3); flex:0 0 auto; }
.pass-preview code { font-family:var(--mono); font-size:11.5px; color:var(--accent); background:var(--accent-soft); border-radius:5px; padding:3px 7px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } .pass-preview code { font-family:var(--mono); font-size:11.5px; color:var(--accent); background:var(--accent-soft); border-radius:5px; padding:3px 7px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.pass-preview code.multiline { white-space:pre-wrap; text-overflow:clip; max-height:132px; overflow:auto; word-break:break-word; }
.pass-foot { margin-top:9px; font-size:10.5px; color:var(--fg-3); } .pass-foot { margin-top:9px; font-size:10.5px; color:var(--fg-3); }
.pass-foot kbd { font-family:var(--mono); font-size:11.5px; color:var(--fg-2); border:1px solid var(--border-2); border-radius:4px; padding:0 5px; } .pass-foot kbd { font-family:var(--mono); font-size:11.5px; color:var(--fg-2); border:1px solid var(--border-2); border-radius:4px; padding:0 5px; }
@@ -410,7 +419,7 @@ body {
/* context menu */ /* context menu */
.ctx { position:fixed; z-index:80; background:#23272d; border:1px solid var(--border-2); border-radius:9px; padding:5px; min-width:248px; box-shadow:0 16px 44px rgba(0,0,0,.5); } .ctx { position:fixed; z-index:80; background:#23272d; border:1px solid var(--border-2); border-radius:9px; padding:5px; min-width:248px; box-shadow:0 16px 44px rgba(0,0,0,.5); }
.ctx-item { display:flex; align-items:center; gap:10px; padding:7px 10px; border-radius:6px; cursor:pointer; font-size:12.5px; color:var(--fg-1); } .ctx-item { display:flex; align-items:center; gap:10px; padding:7px 10px; border-radius:6px; cursor:pointer; font-size:12.5px; color:var(--fg-1); }
.ctx-item:hover { background:var(--accent-soft); color:var(--fg-0); } .ctx-item:hover, .ctx-item.hi { background:var(--accent-soft); color:var(--fg-0); }
.ctx-item .kc { margin-left:auto; font-family:var(--mono); font-size:10.5px; color:var(--fg-3); } .ctx-item .kc { margin-left:auto; font-family:var(--mono); font-size:10.5px; color:var(--fg-3); }
.ctx-item.primary { color:var(--fg-0); } .ctx-item.primary { color:var(--fg-0); }
.ctx-item.primary .ic { color:var(--accent); } .ctx-item.primary .ic { color:var(--accent); }

View File

@@ -35,6 +35,11 @@ export function Terminal({ kind }: { kind: 'agent' | 'shell' }): React.ReactElem
const termRef = useRef<XTerm | null>(null) const termRef = useRef<XTerm | null>(null)
const [, setLive] = useState(kind === 'agent') const [, setLive] = useState(kind === 'agent')
const [menu, setMenu] = useState<Menu | null>(null) const [menu, setMenu] = useState<Menu | null>(null)
// Best-effort "is the agent composer non-empty?" flag. A passed reference must
// land on its own line, so we prepend a newline — UNLESS the composer is empty
// (no leading blank line). We can't read the CLI's input buffer, so we infer:
// typing a printable char marks it dirty, pressing Enter (submit) clears it.
const composerDirty = useRef(false)
useEffect(() => { useEffect(() => {
const bridge = window.helder const bridge = window.helder
@@ -75,7 +80,10 @@ export function Terminal({ kind }: { kind: 'agent' | 'shell' }): React.ReactElem
function onPaste(e: Event): void { function onPaste(e: Event): void {
if (kind !== 'agent' || !bridge || id < 0) return if (kind !== 'agent' || !bridge || id < 0) return
const text = (e as CustomEvent<string>).detail const text = (e as CustomEvent<string>).detail
bridge.pty.write(id, '\x1b[200~' + text + '\n\x1b[201~') // Own line for the reference; skip the leading newline on an empty composer.
const lead = composerDirty.current ? '\n' : ''
bridge.pty.write(id, '\x1b[200~' + lead + text + '\x1b[201~')
composerDirty.current = true
term.focus() term.focus()
} }
@@ -90,7 +98,15 @@ export function Terminal({ kind }: { kind: 'agent' | 'shell' }): React.ReactElem
} }
offData = bridge.pty.onData((tid, data) => { if (tid === id) term.write(data) }) offData = bridge.pty.onData((tid, data) => { if (tid === id) term.write(data) })
offExit = bridge.pty.onExit((tid) => { if (tid === id) { term.write('\r\n\x1b[90m[process exited]\x1b[0m\r\n'); setLive(false) } }) offExit = bridge.pty.onExit((tid) => { if (tid === id) { term.write('\r\n\x1b[90m[process exited]\x1b[0m\r\n'); setLive(false) } })
term.onData((d) => bridge.pty.write(id, d)) term.onData((d) => {
// Track composer emptiness for the agent pane: Enter submits (→ empty),
// a printable keystroke means there's content on the current line.
if (kind === 'agent') {
if (d.includes('\r')) composerDirty.current = false
else if (d >= ' ') composerDirty.current = true
}
bridge.pty.write(id, d)
})
term.onResize(({ cols, rows }) => bridge.pty.resize(id, cols, rows)) term.onResize(({ cols, rows }) => bridge.pty.resize(id, cols, rows))
if (kind === 'agent') window.addEventListener('agentPaste', onPaste) if (kind === 'agent') window.addEventListener('agentPaste', onPaste)
}) })

View File

@@ -37,7 +37,7 @@ async function expandTo(c: HTMLElement, ...folders: string[]): Promise<void> {
} }
describe('Pass on to Agent', () => { describe('Pass on to Agent', () => {
it('inserts "<note> <path:line>" via the agentPaste event', async () => { it('inserts "<note> => <path:line>" via the agentPaste event', async () => {
const received: string[] = [] const received: string[] = []
const handler = (e: Event): void => { received.push((e as CustomEvent<string>).detail) } const handler = (e: Event): void => { received.push((e as CustomEvent<string>).detail) }
window.addEventListener('agentPaste', handler) window.addEventListener('agentPaste', handler)
@@ -57,7 +57,7 @@ describe('Pass on to Agent', () => {
}) })
fireEvent.contextMenu(ta) fireEvent.contextMenu(ta)
const pass = await waitFor(() => { const pass = await waitFor(() => {
const item = find(c, '.ctx-item', 'Pass on to Agent') const item = find(c, '.ctx-item', 'Pass on reference')
if (!item) throw new Error('menu not open') if (!item) throw new Error('menu not open')
return item return item
}) })
@@ -70,7 +70,7 @@ describe('Pass on to Agent', () => {
fireEvent.change(input, { target: { value: 'look here' } }) fireEvent.change(input, { target: { value: 'look here' } })
fireEvent.keyDown(input, { key: 'Enter' }) fireEvent.keyDown(input, { key: 'Enter' })
await waitFor(() => expect(received.length).toBeGreaterThan(0)) await waitFor(() => expect(received.length).toBeGreaterThan(0))
expect(received[0]).toBe('look here public/assets/store.js:1') expect(received[0]).toBe('look here => public/assets/store.js:1')
} finally { } finally {
window.removeEventListener('agentPaste', handler) window.removeEventListener('agentPaste', handler)
} }