adds handoff

This commit is contained in:
2026-06-15 10:03:57 +02:00
parent 2e84cbd0fa
commit 3d77fdfeff
11 changed files with 2993 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
[ 282ms] [INFO] You are running a development build of Vue.
Make sure to use the production build (*.prod.js) when deploying for production. @ https://unpkg.com/vue@3/dist/vue.global.js:12834
[ 292ms] Identifier 'ref' has already been declared
[ 293ms] Identifier 'ref' has already been declared
[ 293ms] Identifier 'ref' has already been declared
[ 293ms] Identifier 'ref' has already been declared
[ 298ms] [ERROR] Failed to load resource: the server responded with a status of 404 (File not found) @ http://localhost:8753/favicon.ico:0

View File

@@ -0,0 +1,40 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Helder — AI Code Workbench</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="styles.css" />
<!-- Prism (manual highlighting) -->
<script>window.Prism = window.Prism || {}; window.Prism.manual = true;</script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-markup-templating.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-php.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-python.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-typescript.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-json.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-bash.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-markdown.min.js"></script>
<!-- Vue 3 global build (runtime template compiler included) -->
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<!-- data + helpers (plain JS) -->
<script src="src/data.js"></script>
<script src="src/highlight.js"></script>
</head>
<body>
<div id="app"></div>
<!-- components (plain JS, no build) -->
<script src="src/components.js"></script>
<script src="src/editor.js"></script>
<script src="src/terminals.js"></script>
<script src="src/overlays.js"></script>
<script src="src/app.js"></script>
</body>
</html>

View File

@@ -0,0 +1,464 @@
/* App shell: 4 resizable columns, keyboard shortcuts, copy-reference, status bar */
const { ref, computed, reactive, onMounted, onUnmounted, watch, nextTick } = Vue;
/* ---- Helpers ---- */
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
function ancestors(path) {
const parts = path.split("/"); const out = [];
for (let i = 1; i < parts.length; i++) out.push(parts.slice(0, i).join("/"));
return out;
}
function initialOpenDirs(node, set) {
if (node.type === "dir") {
if (node.open && node.path) set.add(node.path);
(node.children || []).forEach((c) => initialOpenDirs(c, set));
}
return set;
}
/* ---- Splitter ---- */
const Splitter = {
props: {
orientation: { type: String, default: "v" },
onDelta: { type: Function, required: true },
},
setup(props) {
const drag = ref(false);
function down(e) {
e.preventDefault();
let last = { x: e.clientX, y: e.clientY };
drag.value = true;
document.body.style.cursor = props.orientation === "v" ? "col-resize" : "row-resize";
document.body.style.userSelect = "none";
function mv(ev) {
props.onDelta(ev.clientX - last.x, ev.clientY - last.y);
last = { x: ev.clientX, y: ev.clientY };
}
function up() {
drag.value = false;
document.body.style.cursor = "";
document.body.style.userSelect = "";
document.removeEventListener("mousemove", mv);
document.removeEventListener("mouseup", up);
}
document.addEventListener("mousemove", mv);
document.addEventListener("mouseup", up);
}
return { drag, down };
},
template: `<div :class="'splitter' + (orientation === 'h' ? ' h' : '') + (drag ? ' drag' : '')" @mousedown="down" />`,
};
/* ---- RightColumn ---- */
const RightColumn = {
props: {
width: { type: Number, required: true },
},
setup(props) {
const topFrac = ref(0.52);
const colRef = ref(null);
function delta(dx, dy) {
const h = colRef.value ? colRef.value.clientHeight : 600;
topFrac.value = Math.max(0.18, Math.min(0.82, (topFrac.value * h + dy) / h));
}
const agent = agentSeed();
const shell = shellSeed();
return { topFrac, colRef, delta, agent, shell };
},
template: `
<div class="col right-col" :style="{ width: width + 'px', flex: '0 0 ' + width + 'px' }">
<div ref="colRef" :style="{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }">
<div :style="{ flex: '0 0 ' + (topFrac * 100) + '%', minHeight: 0, display: 'flex' }">
<Terminal kind="agent" :seed="agent" />
</div>
<Splitter orientation="h" :onDelta="delta" />
<div :style="{ flex: 1, minHeight: 0, display: 'flex' }">
<Terminal kind="shell" :seed="shell" />
</div>
</div>
</div>
`,
};
/* ---- App ---- */
const App = {
setup() {
/* --- derived constants (no reactivity needed) --- */
const changeMap = computed(() => Object.fromEntries(PROJECT.changes.map((c) => [c.path, c.status])));
const changeSet = computed(() => new Set(PROJECT.changes.map((c) => c.path)));
/* --- state --- */
const tabs = ref([
{ path: "src/Http/Controller/UserController.php" },
{ path: "public/assets/app.js" },
{ path: "src/types/api.ts" },
]);
const active = ref("src/Http/Controller/UserController.php");
const tabMode = ref({ "src/Http/Controller/UserController.php": "diff" });
const openDirs = ref(initialOpenDirs(PROJECT.tree, new Set()));
const cursor = ref({ path: "src/Http/Controller/UserController.php", line: 1, col: 1 });
const selection = ref(null);
const overlay = ref(null);
const menu = ref(null);
const toasts = ref([]);
const splitFor = ref(null);
const staged = ref(new Set(["src/Service/PaymentService.php", "config/app.json"]));
const committed = ref(new Set());
const commitMsg = ref("");
const passPopup = ref(null);
const gitW = ref(232);
const treeW = ref(244);
const rightW = ref(444);
/* --- computed --- */
const MODE_LABEL = { original: "orig", updated: "upd", diff: "diff", code: "" };
const MODE_WORD = { original: "Original", updated: "Updated", diff: "Diff" };
const resolvedTabs = computed(() =>
tabs.value.map((t) => {
const changed = !!PROJECT.diffs[t.path];
const m = tabMode.value[t.path] || (changed ? "diff" : "code");
return { ...t, changed, modeLabel: splitFor.value === t.path ? "split" : MODE_LABEL[m] };
})
);
const mode = computed(() => tabMode.value[active.value] || (PROJECT.diffs[active.value] ? "diff" : "code"));
const totals = computed(() => PROJECT.changes.reduce((a, c) => ({ add: a.add + c.add, del: a.del + c.del }), { add: 0, del: 0 }));
const activeLang = computed(() => active.value ? HL.langLabel(active.value) : "");
const crumb = computed(() => active.value ? active.value.split("/") : []);
/* --- helpers --- */
function toast(title, refText) {
const id = lid();
toasts.value = [...toasts.value, { id, title, ref: refText }];
setTimeout(() => { toasts.value = toasts.value.filter((x) => x.id !== id); }, 2300);
}
async function copyText(text, label) {
try { await navigator.clipboard.writeText(text); }
catch (e) {
const ta = document.createElement("textarea"); ta.value = text;
ta.style.position = "fixed"; ta.style.opacity = "0"; document.body.appendChild(ta);
ta.select(); try { document.execCommand("copy"); } catch (_) {} ta.remove();
}
toast(label || "Copied reference", text);
}
function toggleDir(p) {
const n = new Set(openDirs.value);
n.has(p) ? n.delete(p) : n.add(p);
openDirs.value = n;
}
function reveal(path) {
const n = new Set(openDirs.value);
ancestors(path).forEach((a) => n.add(a));
openDirs.value = n;
}
function stage(p) {
const n = new Set(staged.value);
n.add(p);
staged.value = n;
}
function unstage(p) {
const n = new Set(staged.value);
n.delete(p);
staged.value = n;
}
function stageAll() {
staged.value = new Set(PROJECT.changes.filter((c) => !committed.value.has(c.path)).map((c) => c.path));
}
function unstageAll() {
staged.value = new Set();
}
function commit() {
const list = PROJECT.changes.filter((c) => staged.value.has(c.path) && !committed.value.has(c.path));
if (!list.length || !commitMsg.value.trim()) return;
const n = new Set(committed.value);
list.forEach((c) => n.add(c.path));
committed.value = n;
staged.value = new Set();
const msg = commitMsg.value.trim();
commitMsg.value = "";
toast(`Committed ${list.length} file${list.length > 1 ? "s" : ""}`, msg.length > 34 ? msg.slice(0, 34) + "…" : msg);
}
function openFile(path, opts = {}) {
const changed = !!PROJECT.diffs[path];
if (!tabs.value.some((x) => x.path === path)) {
tabs.value = [...tabs.value, { path }];
}
active.value = path;
if (opts.diff && changed) {
tabMode.value = { ...tabMode.value, [path]: "diff" };
} else if (!tabMode.value[path]) {
tabMode.value = { ...tabMode.value, [path]: changed ? "diff" : "code" };
}
reveal(path);
if (opts.line) {
// show the current/updated file so line numbers map to search hits
splitFor.value = null;
tabMode.value = { ...tabMode.value, [path]: changed ? "updated" : "code" };
cursor.value = { path, line: opts.line, col: 1 };
selection.value = null;
setTimeout(() => {
const row = document.querySelector('.editor .ln-row[data-line="' + opts.line + '"]');
if (row) { const ed = row.closest(".editor"); const er = ed.getBoundingClientRect(), rr = row.getBoundingClientRect(); ed.scrollTop += (rr.top - er.top) - ed.clientHeight / 2; }
}, 70);
}
}
function closeTab(path) {
const t = tabs.value;
const ix = t.findIndex((x) => x.path === path);
const next = t.filter((x) => x.path !== path);
if (path === active.value) {
const fallback = next[ix] || next[ix - 1] || next[next.length - 1];
active.value = fallback ? fallback.path : null;
}
tabs.value = next;
}
/* ---- context menus ---- */
function openMenu(e, target) {
e.preventDefault(); e.stopPropagation();
const sparkSend = (ref) => ({
icon: "spark",
label: "Send reference to agent",
onClick: () => {
window.dispatchEvent(new CustomEvent("agentPaste", { detail: ref }));
toast("Passed to agent", ref);
},
});
if (target.kind === "editor") {
const ref = target.sel
? `${target.path}:${target.sel.start}-${target.sel.end}`
: `${target.path}:${target.line}`;
const mx = e.clientX, my = e.clientY;
menu.value = {
x: mx, y: my, note: ref,
items: [
{ primary: true, icon: "copy", label: "Copy reference", onClick: () => copyText(ref) },
{ icon: "spark", label: "Pass on to Agent", onClick: () => { passPopup.value = { x: mx, y: my, ref }; } },
],
};
} else {
const isDir = target.kind === "dir";
const ref = isDir ? target.path + "/" : target.path;
const name = target.path.split("/").pop();
const items = [
{ primary: true, icon: "copy", label: "Copy reference", onClick: () => copyText(ref) },
sparkSend(ref),
{ icon: "copy", label: isDir ? "Copy folder path" : "Copy file name", onClick: () => copyText(isDir ? target.path : name, "Copied") },
];
if (!isDir) {
items.push({ sep: true });
if (target.kind === "git") {
const isStaged = staged.value.has(target.path);
items.push(isStaged
? { icon: "minus", label: "Unstage changes", onClick: () => unstage(target.path) }
: { icon: "plus", label: "Stage changes", onClick: () => stage(target.path) });
items.push({ icon: "diff", label: "Open diff", onClick: () => openFile(target.path, { diff: true }) });
}
items.push({ icon: "file", label: "Open file", onClick: () => openFile(target.path) });
items.push({ icon: "reveal", label: "Reveal in Explorer", onClick: () => reveal(target.path) });
}
menu.value = { x: e.clientX, y: e.clientY, note: ref, items };
}
}
/* ---- keyboard shortcuts ---- */
function onKey(e) {
const meta = e.metaKey || e.ctrlKey;
if (meta && e.key.toLowerCase() === "f") { e.preventDefault(); overlay.value = "search"; }
else if (meta && e.key.toLowerCase() === "w") { e.preventDefault(); if (active.value) closeTab(active.value); }
else if (e.key === "Escape") {
if (splitFor.value) splitFor.value = null;
else { overlay.value = null; menu.value = null; }
}
}
onMounted(() => window.addEventListener("keydown", onKey));
onUnmounted(() => window.removeEventListener("keydown", onKey));
/* --- passPopup confirm handler (passed as prop) --- */
function onPassConfirm(text) {
const line = (text && text.trim() ? text.trim() + " " : "") + passPopup.value.ref;
window.dispatchEvent(new CustomEvent("agentPaste", { detail: line }));
const savedRef = passPopup.value.ref;
passPopup.value = null;
toast("Passed to agent", savedRef);
}
function onPassCancel() { passPopup.value = null; }
function setCommitMsg(v) { commitMsg.value = v; }
function setActive(p) { active.value = p; }
function setMode(m) { tabMode.value = { ...tabMode.value, [active.value]: m }; splitFor.value = null; }
function setCursorVal(v) { cursor.value = v; }
function setSelectionVal(v) { selection.value = v; }
function setSplitFor(p) { splitFor.value = p; }
function clampGitW(dx) { gitW.value = clamp(gitW.value + dx, 160, 460); }
function clampTreeW(dx) { treeW.value = clamp(treeW.value + dx, 160, 520); }
function clampRightW(dx) { rightW.value = clamp(rightW.value - dx, 280, 780); }
return {
changeMap, changeSet,
tabs, active, tabMode, openDirs, cursor, selection, overlay, menu, toasts, splitFor,
staged, committed, commitMsg, passPopup,
gitW, treeW, rightW,
resolvedTabs, mode, totals, activeLang, crumb,
MODE_LABEL, MODE_WORD,
toast, copyText, toggleDir, reveal,
stage, unstage, stageAll, unstageAll, commit,
openFile, closeTab, openMenu,
onPassConfirm, onPassCancel,
setCommitMsg, setActive, setMode, setCursorVal, setSelectionVal, setSplitFor,
clampGitW, clampTreeW, clampRightW,
PROJECT, /* expose global so template can reference it */
};
},
template: `
<div class="app">
<!-- title bar -->
<div class="titlebar">
<div class="traffic"><i class="r" /><i class="y" /><i class="g" /></div>
<div class="tb-title">
<Icon name="spark" style="color:var(--accent)" />
<b>Helder</b>
<span :style="{ color: 'var(--fg-3)' }">—</span>
<span :style="{ color: 'var(--fg-2)' }">{{ PROJECT.name }}</span>
</div>
<div v-if="active" class="tb-crumb">
<template v-for="(s, i) in crumb" :key="i">
<span v-if="i > 0" class="seg"> </span>
<span :style="i === crumb.length - 1 ? { color: 'var(--fg-1)' } : null">{{ s }}</span>
</template>
</div>
<div class="tb-spacer" />
<div class="tb-actions">
<button class="tb-btn" @click="overlay = 'search'">
<Icon name="search" /> Search <kbd>⌘F</kbd>
</button>
</div>
</div>
<!-- workbench -->
<div class="workbench">
<div class="col" :style="{ width: gitW + 'px', flex: '0 0 ' + gitW + 'px' }">
<GitPanel
:changes="PROJECT.changes"
:staged="staged"
:committed="committed"
:commitMsg="commitMsg"
:setCommitMsg="setCommitMsg"
:onStage="stage"
:onUnstage="unstage"
:onStageAll="stageAll"
:onUnstageAll="unstageAll"
:onCommit="commit"
:onOpen="openFile"
:onContext="openMenu"
:activePath="active" />
</div>
<Splitter :onDelta="clampGitW" />
<div class="col" :style="{ width: treeW + 'px', flex: '0 0 ' + treeW + 'px' }">
<FileTree
:tree="PROJECT.tree"
:openDirs="openDirs"
:toggleDir="toggleDir"
:onOpen="openFile"
:onContext="openMenu"
:activePath="active"
:changeMap="changeMap"
:committed="committed" />
</div>
<Splitter :onDelta="clampTreeW" />
<div class="col editor-col">
<Editor
:tabs="resolvedTabs"
:active="active"
:mode="mode"
:setMode="setMode"
:onActivate="setActive"
:onClose="closeTab"
:onContext="openMenu"
:onSplit="setSplitFor"
:splitOpen="splitFor === active"
:cursor="cursor"
:selection="selection"
:setCursor="setCursorVal"
:setSelection="setSelectionVal" />
</div>
<Splitter :onDelta="clampRightW" />
<RightColumn :width="rightW" />
</div>
<!-- status bar -->
<div class="statusbar">
<div class="sb accent">
<Icon name="branch" :w="12" :h="12" />
<span style="color:#0c1320">{{ PROJECT.branch }}</span>
</div>
<div class="sb">
<span class="a">+{{ totals.add }}</span>
<span class="d"> {{ totals.del }}</span>
</div>
<div class="sb spacer" />
<div v-if="active" class="sb">
<template v-if="selection && selection.path === active && selection.start !== selection.end">
{{ selection.end - selection.start + 1 }} lines selected
</template>
<template v-else>
Ln {{ cursor.path === active ? cursor.line : 1 }}, Col {{ cursor.path === active ? cursor.col : 1 }}
</template>
</div>
<div v-if="active" class="sb">Spaces: 4</div>
<div v-if="active" class="sb">UTF-8</div>
<div v-if="active" class="sb"><b>{{ activeLang }}</b></div>
<div v-if="active && PROJECT.diffs[active]" class="sb">
{{ splitFor === active ? 'Split' : (MODE_WORD[mode] || '') }}
</div>
</div>
<!-- overlays -->
<SplitView v-if="splitFor" :path="splitFor" :onClose="() => { setSplitFor(null); }" :onContext="openMenu" />
<PassPopup
v-if="passPopup"
:x="passPopup.x"
:y="passPopup.y"
:refStr="passPopup.ref"
:onConfirm="onPassConfirm"
:onCancel="onPassCancel" />
<SearchModal
v-if="overlay === 'search'"
:onOpen="openFile"
:onOpenAt="(p, n) => openFile(p, { line: n })"
:onClose="() => { overlay = null; }"
:changeSet="changeSet" />
<ContextMenu v-if="menu" :menu="menu" :onClose="() => { menu = null; }" />
<Toasts :toasts="toasts" />
</div>
`,
};
/* ---- Mount ---- */
const app = Vue.createApp(App);
Object.entries(window.HelderComponents).forEach(([n, c]) => app.component(n, c));
app.component("Splitter", Splitter);
app.component("RightColumn", RightColumn);
app.mount("#app");

View File

@@ -0,0 +1,395 @@
/* Shared icons, FileIcon, GitPanel, FileTree — Vue 3 classic-script port */
const { ref, computed, onMounted, onUnmounted, watch, nextTick } = Vue;
/* ---- Icon component ---- */
/* Internal map: name → { vb, inner } */
const _iconMap = {
search: {
vb: "0 0 16 16",
inner: `<circle cx="7" cy="7" r="4.5" stroke="currentColor" stroke-width="1.4"/><line x1="10.5" y1="10.5" x2="14" y2="14" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>`
},
branch: {
vb: "0 0 16 16",
inner: `<circle cx="4" cy="3.5" r="1.8" stroke="currentColor" stroke-width="1.3"/><circle cx="4" cy="12.5" r="1.8" stroke="currentColor" stroke-width="1.3"/><circle cx="12" cy="5" r="1.8" stroke="currentColor" stroke-width="1.3"/><path d="M4 5.3v5.4M5.8 5C9 5 10 6.2 10 9v0" stroke="currentColor" stroke-width="1.3" fill="none"/>`
},
close: {
vb: "0 0 12 12",
inner: `<path d="M3 3l6 6M9 3l-6 6" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>`
},
copy: {
vb: "0 0 16 16",
inner: `<rect x="5" y="5" width="8" height="9" rx="1.5" stroke="currentColor" stroke-width="1.3"/><path d="M3 11V3a1 1 0 0 1 1-1h6" stroke="currentColor" stroke-width="1.3" fill="none"/>`
},
terminal: {
vb: "0 0 16 16",
inner: `<path d="M3 4l3 3-3 3M8 11h5" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>`
},
spark: {
vb: "0 0 16 16",
inner: `<path d="M8 1.5l1.6 4.9L14.5 8l-4.9 1.6L8 14.5 6.4 9.6 1.5 8l4.9-1.6L8 1.5z" stroke="currentColor" stroke-width="1.1" fill="none" stroke-linejoin="round"/>`
},
file: {
vb: "0 0 16 16",
inner: `<path d="M4 2h5l3 3v9H4V2z" stroke="currentColor" stroke-width="1.2" fill="none"/><path d="M9 2v3h3" stroke="currentColor" stroke-width="1.2" fill="none"/>`
},
reveal: {
vb: "0 0 16 16",
inner: `<path d="M2 4.5h4l1.3 1.5H14V13H2V4.5z" stroke="currentColor" stroke-width="1.2" fill="none"/>`
},
diff: {
vb: "0 0 16 16",
inner: `<path d="M4 2v8M4 12.5v1.5M2 4h4M2 8h4" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/><path d="M12 14V6M12 3.5V2M10 12h4M10 8h4" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/>`
},
plus: {
vb: "0 0 14 14",
inner: `<path d="M7 2.5v9M2.5 7h9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>`
},
minus: {
vb: "0 0 14 14",
inner: `<path d="M2.5 7h9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>`
},
check: {
vb: "0 0 14 14",
inner: `<path d="M2.5 7.5l2.8 3L11.5 3.5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>`
},
discard: {
vb: "0 0 16 16",
inner: `<path d="M12.5 5.5A5 5 0 1 0 13 9" stroke="currentColor" stroke-width="1.3" fill="none" stroke-linecap="round"/><path d="M12.5 2.5v3h-3" stroke="currentColor" stroke-width="1.3" fill="none" stroke-linecap="round" stroke-linejoin="round"/>`
},
};
const Icon = {
props: {
name: { type: String, required: true },
w: { type: Number, default: 13 },
h: { type: Number, default: 13 },
},
setup(props) {
const entry = computed(() => _iconMap[props.name] || null);
const vb = computed(() => entry.value ? entry.value.vb : "0 0 16 16");
const inner = computed(() => entry.value ? entry.value.inner : "");
return { vb, inner };
},
template: `<svg :width="w" :height="h" :viewBox="vb" fill="none" v-html="inner"></svg>`,
};
/* ---- Chevron ---- */
const Chevron = {
props: {
open: { type: Boolean, default: false },
},
template: `
<svg width="9" height="9" viewBox="0 0 10 10"
:style="{ transform: open ? 'rotate(90deg)' : 'none', transition: 'transform .12s' }">
<path d="M3.5 2l3.5 3-3.5 3" stroke="currentColor" stroke-width="1.4" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
`,
};
/* ---- FolderIcon ---- */
const FolderIcon = {
props: {
open: { type: Boolean, default: false },
},
template: `
<svg class="folder-ic" width="14" height="14" viewBox="0 0 16 16" fill="none">
<path
:d="open ? 'M1.5 4.5h4l1.2 1.4H14V13H2V4.5z' : 'M1.5 4.5h4l1.2 1.4H14V13H1.5V4.5z'"
:fill="open ? 'rgba(122,131,140,.18)' : 'rgba(122,131,140,.12)'"
stroke="currentColor" stroke-width="1.1"/>
</svg>
`,
};
/* ---- FileIcon ---- */
const FileIcon = {
props: {
path: { type: String, required: true },
},
setup(props) {
const ic = computed(() => HL.iconFor(props.path));
return { ic };
},
template: `
<span class="ficon" :style="{ background: ic.c }"><span>{{ ic.t }}</span></span>
`,
};
/* ---- GitRow ---- */
const GitRow = {
props: {
c: { type: Object, required: true },
staged: { type: Boolean, required: true },
activePath: { type: String, default: null },
onOpen: { type: Function, required: true },
onContext: { type: Function, required: true },
onToggleStage: { type: Function, required: true },
},
components: { Icon, FileIcon },
setup(props) {
const name = computed(() => props.c.path.split("/").pop());
const dir = computed(() => props.c.path.split("/").slice(0, -1).join("/"));
return { name, dir };
},
template: `
<div
:class="'git-row' + (activePath === c.path ? ' active' : '')"
@click="onOpen(c.path, { diff: true })"
@contextmenu="(e) => onContext(e, { path: c.path, kind: 'git', staged })"
:title="c.path">
<span :class="'git-stat ' + c.status">{{ c.status }}</span>
<FileIcon :path="c.path" />
<span :class="'git-name' + (c.deleted ? ' del' : '')">{{ name }}</span>
<span v-if="dir" class="git-dir">{{ dir }}/</span>
<button
class="git-act"
:title="staged ? 'Unstage changes' : 'Stage changes'"
@click.stop="onToggleStage(c.path)">
<Icon v-if="staged" name="minus" />
<Icon v-else name="plus" />
</button>
<span class="git-delta">
<span v-if="c.add > 0" class="a">+{{ c.add }}</span>
<span v-if="c.del > 0" class="d">-{{ c.del }}</span>
</span>
</div>
`,
};
/* ---- GitPanel (multi-root fragment) ---- */
const GitPanel = {
props: {
changes: { type: Array, required: true },
staged: { type: Object, required: true }, /* Set */
committed: { type: Object, required: true }, /* Set */
commitMsg: { type: String, required: true },
setCommitMsg: { type: Function, required: true },
onStage: { type: Function, required: true },
onUnstage: { type: Function, required: true },
onStageAll: { type: Function, required: true },
onUnstageAll: { type: Function, required: true },
onCommit: { type: Function, required: true },
onOpen: { type: Function, required: true },
onContext: { type: Function, required: true },
activePath: { type: String, default: null },
},
components: { Icon, GitRow },
setup(props) {
const visible = computed(() => props.changes.filter((c) => !props.committed.has(c.path)));
const stagedList = computed(() => visible.value.filter((c) => props.staged.has(c.path)));
const changesList = computed(() => visible.value.filter((c) => !props.staged.has(c.path)));
const totals = computed(() =>
visible.value.reduce((a, c) => ({ add: a.add + c.add, del: a.del + c.del }), { add: 0, del: 0 })
);
const canCommit = computed(() => stagedList.value.length > 0 && props.commitMsg.trim().length > 0);
function handleKeyDown(e) {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter" && canCommit.value) {
e.preventDefault();
props.onCommit();
}
}
return { visible, stagedList, changesList, totals, canCommit, handleKeyDown };
},
/* Vue 3 allows multi-root templates */
template: `
<div class="phead">
<Icon name="branch" /><span>Source Control</span>
<span class="ct">{{ visible.length }}</span>
</div>
<div class="commit-box">
<textarea
class="commit-input"
rows="1"
:value="commitMsg"
:spellcheck="false"
placeholder="Message (⌘↵ to commit)"
@input="(e) => setCommitMsg(e.target.value)"
@keydown="handleKeyDown">
</textarea>
<button
class="commit-btn"
:disabled="!canCommit"
@click="onCommit"
:title="canCommit ? 'Commit staged changes' : 'Stage files and write a message to commit'">
<Icon name="check" /><span>Commit{{ stagedList.length ? ' ' + stagedList.length : '' }}</span>
</button>
</div>
<div class="git-body">
<div v-if="visible.length === 0" class="git-empty">
<Icon name="check" :w="20" :h="20" /><span>No changes — working tree clean</span>
</div>
<template v-else>
<div class="git-group">
Staged Changes <span class="gc">{{ stagedList.length }}</span>
<button v-if="stagedList.length > 0" class="grp-act" title="Unstage all" @click="onUnstageAll">
<Icon name="minus" />
</button>
</div>
<template v-if="stagedList.length > 0">
<GitRow
v-for="c in stagedList"
:key="c.path"
:c="c"
:staged="true"
:activePath="activePath"
:onOpen="onOpen"
:onContext="onContext"
:onToggleStage="onUnstage" />
</template>
<div v-else class="git-none">Nothing staged — use <span class="key">+</span> to stage a file</div>
<div class="git-divider"></div>
<div class="git-group">
Changes <span class="gc">{{ changesList.length }}</span>
<button v-if="changesList.length > 0" class="grp-act" title="Stage all" @click="onStageAll">
<Icon name="plus" />
</button>
</div>
<template v-if="changesList.length > 0">
<GitRow
v-for="c in changesList"
:key="c.path"
:c="c"
:staged="false"
:activePath="activePath"
:onOpen="onOpen"
:onContext="onContext"
:onToggleStage="onStage" />
</template>
<div v-else class="git-none">All changes staged</div>
</template>
</div>
<div class="git-foot">
<span class="branch-chip"><Icon name="branch" /><b>{{ PROJECT.branch }}</b></span>
<span :style="{ marginLeft: 'auto', fontFamily: 'var(--mono)' }">
<span class="a" :style="{ color: 'var(--add)' }">+{{ totals.add }}</span>{{ ' ' }}
<span class="d" :style="{ color: 'var(--del)' }">-{{ totals.del }}</span>
</span>
</div>
`,
};
/* ---- TreeNode (recursive — registered globally) ---- */
const TreeNode = {
name: "TreeNode",
props: {
node: { type: Object, required: true },
depth: { type: Number, required: true },
openDirs: { type: Object, required: true }, /* Set */
toggleDir: { type: Function, required: true },
onOpen: { type: Function, required: true },
onContext: { type: Function, required: true },
activePath: { type: String, default: null },
changeMap: { type: Object, required: true },
committed: { type: Object, default: null }, /* Set or null */
},
components: { Chevron, FolderIcon, FileIcon },
setup(props) {
const pad = computed(() => 10 + props.depth * 13);
const isOpen = computed(() => props.node.type === "dir" && (props.openDirs.has(props.node.path) || props.node.path === ""));
const status = computed(() => {
if (props.node.type === "dir") return null;
return (props.committed && props.committed.has(props.node.path)) ? null : props.changeMap[props.node.path];
});
return { pad, isOpen, status };
},
/* Multi-root fragment for the dir case */
template: `
<template v-if="node.type === 'dir'">
<div
v-if="node.path !== ''"
class="tree-row folder"
:style="{ paddingLeft: pad + 'px' }"
@click="toggleDir(node.path)"
@contextmenu="(e) => onContext(e, { path: node.path, kind: 'dir' })">
<span class="tw"><Chevron :open="isOpen" /></span>
<FolderIcon :open="isOpen" />
<span class="tree-label">{{ node.name }}</span>
</div>
<template v-if="isOpen">
<TreeNode
v-for="c in node.children"
:key="c.path"
:node="c"
:depth="node.path === '' ? 0 : depth + 1"
:openDirs="openDirs"
:toggleDir="toggleDir"
:onOpen="onOpen"
:onContext="onContext"
:activePath="activePath"
:changeMap="changeMap"
:committed="committed" />
</template>
</template>
<div
v-else
:class="'tree-row' + (activePath === node.path ? ' active' : '')"
:style="{ paddingLeft: (pad + 2) + 'px' }"
@click="onOpen(node.path)"
@contextmenu="(e) => onContext(e, { path: node.path, kind: 'file' })"
:title="node.path">
<span class="tw"></span>
<FileIcon :path="node.path" />
<span
class="tree-label"
:style="status === 'D' ? { textDecoration: 'line-through', color: 'var(--fg-3)' } : null">
{{ node.name }}
</span>
<span v-if="status" :class="'tree-badge ' + status">{{ status }}</span>
</div>
`,
};
/* Self-reference: register TreeNode in itself for recursion */
TreeNode.components = Object.assign(TreeNode.components || {}, { TreeNode });
/* ---- FileTree (multi-root fragment) ---- */
const FileTree = {
props: {
tree: { type: Object, required: true },
openDirs: { type: Object, required: true }, /* Set */
toggleDir: { type: Function, required: true },
onOpen: { type: Function, required: true },
onContext: { type: Function, required: true },
activePath: { type: String, default: null },
changeMap: { type: Object, required: true },
committed: { type: Object, default: null }, /* Set or null */
},
components: { TreeNode },
template: `
<div class="phead">
<span>Explorer</span>
<span :style="{ marginLeft: 'auto', color: 'var(--fg-3)', textTransform: 'none', letterSpacing: 0, fontFamily: 'var(--mono)', fontSize: '10.5px' }">{{ tree.name }}</span>
</div>
<div class="tree-body">
<TreeNode
:node="tree"
:depth="0"
:openDirs="openDirs"
:toggleDir="toggleDir"
:onOpen="onOpen"
:onContext="onContext"
:activePath="activePath"
:changeMap="changeMap"
:committed="committed" />
</div>
`,
};
/* ---- Global export ---- */
window.HelderComponents = Object.assign(window.HelderComponents || {}, {
Icon,
Chevron,
FolderIcon,
FileIcon,
GitRow,
GitPanel,
TreeNode,
FileTree,
});

View File

@@ -0,0 +1,712 @@
/* Mock project: filesystem tree, file contents, before/after pairs, runtime diff. */
(function () {
// ---- working-tree (current / updated) file contents ----------------
const F = {};
F["src/Http/Controller/UserController.php"] = `<?php
namespace App\\Http\\Controller;
use App\\Service\\PaymentService;
use App\\Repository\\UserRepository;
use Psr\\Http\\Message\\ResponseInterface;
use Psr\\Http\\Message\\ServerRequestInterface;
final class UserController
{
public function __construct(
private readonly UserRepository $users,
private readonly PaymentService $payments,
) {}
public function show(ServerRequestInterface $request, string $id): ResponseInterface
{
$user = $this->users->find($id);
if ($user === null) {
return $this->json(['error' => 'user_not_found'], 404);
}
$balance = $this->payments->balanceFor($user);
return $this->json([
'id' => $user->id,
'email' => $user->email,
'plan' => $user->plan->value,
'name' => $user->name,
'currency' => $user->currency,
'balance' => $balance->toArray(),
]);
}
public function update(ServerRequestInterface $request, string $id): ResponseInterface
{
$user = $this->users->find($id);
if ($user === null) {
return $this->json(['error' => 'user_not_found'], 404);
}
$data = (array) $request->getParsedBody();
$user->fill($this->onlyFillable($data));
$this->users->save($user);
return $this->json($user->toArray());
}
/** @return array<string,mixed> */
private function onlyFillable(array $data): array
{
$allowed = ['email', 'plan', 'name'];
return array_intersect_key($data, array_flip($allowed));
}
}
`;
F["src/Service/PaymentService.php"] = `<?php
namespace App\\Service;
use App\\Entity\\User;
use App\\ValueObject\\Money;
use App\\Gateway\\PaymentGateway;
use Psr\\Log\\LoggerInterface;
final class PaymentService
{
public function __construct(
private readonly PaymentGateway $gateway,
private readonly LoggerInterface $logger,
) {}
public function balanceFor(User $user): Money
{
$cents = $this->gateway->lookupBalance($user->id);
return Money::fromCents($cents, $user->currency ?? 'EUR');
}
public function charge(User $user, Money $amount, string $reason): bool
{
if ($amount->isZero()) {
$this->logger->warning('Skipped zero charge', ['user' => $user->id]);
return false;
}
$result = $this->gateway->charge($user->paymentToken, $amount->cents());
$this->logger->info('Charge attempt', [
'user' => $user->id,
'amount' => $amount->cents(),
'ok' => $result->success,
]);
return $result->success;
}
}
`;
F["public/assets/app.js"] = `import { createStore } from './store.js';
import { mountRouter } from './router.js';
const store = createStore({
user: null,
notifications: [],
theme: 'dark',
});
async function bootstrap() {
const res = await fetch('/api/session', { credentials: 'include' });
if (res.ok) {
const session = await res.json();
store.set('user', session.user);
store.set('theme', session.user.theme ?? 'dark');
}
mountRouter(document.querySelector('#app'), store);
store.subscribe('notifications', renderToasts);
}
function renderToasts(list) {
const host = document.querySelector('#toasts');
host.replaceChildren(...list.map((n) => {
const el = document.createElement('div');
el.className = \\\`toast toast--\\\${n.level}\\\`;
el.textContent = n.message;
return el;
}));
}
document.addEventListener('DOMContentLoaded', bootstrap);
`;
F["public/assets/store.js"] = `export function createStore(initial = {}) {
let state = { ...initial };
const subs = new Map();
return {
get: (key) => state[key],
set(key, value) {
state = { ...state, [key]: value };
(subs.get(key) || []).forEach((fn) => fn(value, state));
},
subscribe(key, fn) {
const list = subs.get(key) || [];
list.push(fn);
subs.set(key, list);
return () => subs.set(key, list.filter((f) => f !== fn));
},
};
}
`;
F["public/assets/styles.css"] = `:root {
--brand: #4d8dff;
--ink: #15171a;
--paper: #ffffff;
--radius: 10px;
}
body {
margin: 0;
font-family: system-ui, sans-serif;
background: var(--ink);
color: #e6e8ea;
}
.toast {
padding: 10px 14px;
border-radius: var(--radius);
border-left: 3px solid var(--brand);
}
.toast--error { border-left-color: #e0696a; }
.toast--success { border-left-color: #5cbd6b; }
`;
F["src/types/api.ts"] = `export type Plan = 'free' | 'pro' | 'enterprise';
export interface User {
id: string;
email: string;
name: string;
plan: Plan;
currency: string;
createdAt: string;
}
export interface Balance {
cents: number;
currency: string;
formatted: string;
}
export type ApiResult<T> =
| { ok: true; data: T }
| { ok: false; error: string; status: number };
export async function getUser(id: string): Promise<ApiResult<User>> {
const res = await fetch(\\\`/api/users/\\\${id}\\\`);
if (!res.ok) {
return { ok: false, error: 'request_failed', status: res.status };
}
return { ok: true, data: (await res.json()) as User };
}
`;
F["scripts/migrate.py"] = `#!/usr/bin/env python3
"""Run pending database migrations in order."""
import sys
from pathlib import Path
from db import connect, applied_migrations
MIGRATIONS = Path(__file__).parent / "migrations"
def pending(conn):
done = applied_migrations(conn)
files = sorted(MIGRATIONS.glob("*.sql"))
return [f for f in files if f.stem not in done]
def run(conn, migration: Path) -> None:
sql = migration.read_text()
print(f" -> applying {migration.stem}")
with conn.cursor() as cur:
cur.execute(sql)
cur.execute(
"INSERT INTO schema_migrations (version) VALUES (%s)",
(migration.stem,),
)
conn.commit()
def main() -> int:
conn = connect()
todo = pending(conn)
if not todo:
print("Database is up to date.")
return 0
print(f"Applying {len(todo)} migration(s)...")
for migration in todo:
run(conn, migration)
print("Done.")
return 0
if __name__ == "__main__":
sys.exit(main())
`;
F["scripts/seed.py"] = `#!/usr/bin/env python3
"""Seed the database with demo data for local development."""
import random
from db import connect
PLANS = ["free", "pro", "enterprise"]
def seed_users(conn, count: int = 25) -> None:
with conn.cursor() as cur:
for i in range(count):
cur.execute(
"INSERT INTO users (email, plan) VALUES (%s, %s)",
(f"user{i}@example.com", random.choice(PLANS)),
)
conn.commit()
print(f"Seeded {count} users.")
if __name__ == "__main__":
seed_users(connect())
`;
F["templates/dashboard.html"] = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Dashboard</title>
<link rel="stylesheet" href="/assets/styles.css">
</head>
<body>
<main id="app" class="layout">
<header class="topbar">
<h1 class="logo">Console</h1>
<nav class="nav">
<a href="/users" class="nav__link">Users</a>
<a href="/billing" class="nav__link">Billing</a>
</nav>
</header>
<section id="content" class="content"></section>
</main>
<div id="toasts" class="toast-host"></div>
<script type="module" src="/assets/app.js"></script>
</body>
</html>
`;
F["config/app.json"] = `{
"name": "console",
"env": "production",
"features": {
"billing": true,
"newDashboard": true,
"exportCsv": false
},
"payment": {
"gateway": "stripe",
"currency": "EUR",
"retryLimit": 3
},
"logging": {
"level": "info",
"channel": "stdout"
}
}
`;
F["composer.json"] = `{
"name": "blijnder/console",
"type": "project",
"require": {
"php": ">=8.2",
"psr/log": "^3.0",
"psr/http-message": "^2.0",
"nyholm/psr7": "^1.8"
},
"require-dev": {
"phpunit/phpunit": "^11.0",
"phpstan/phpstan": "^1.11"
},
"autoload": {
"psr-4": { "App\\\\": "src/" }
}
}
`;
F["package.json"] = `{
"name": "console-frontend",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"test": "vitest run",
"lint": "eslint ."
},
"devDependencies": {
"vite": "^5.3.0",
"vitest": "^2.0.0",
"typescript": "^5.5.0"
}
}
`;
F["README.md"] = `# Console
Internal admin console. PHP API + small vanilla JS frontend.
## Getting started
composer install
npm install
python scripts/migrate.py
npm run dev
## Layout
- \`src/\` PHP application code (PSR-4, \`App\\\` namespace)
- \`public/\` Document root and frontend assets
- \`scripts/\` Python maintenance + migration scripts
- \`templates/\` Server-rendered HTML
`;
F[".env"] = `APP_ENV=production
APP_DEBUG=false
DATABASE_URL=postgres://localhost:5432/console
PAYMENT_GATEWAY=stripe
PAYMENT_CURRENCY=EUR
LOG_LEVEL=info
`;
// ---- ORIGINAL (pre-edit) versions of changed files ----------------
const O = {};
O["src/Http/Controller/UserController.php"] = `<?php
namespace App\\Http\\Controller;
use App\\Service\\PaymentService;
use App\\Repository\\UserRepository;
use Psr\\Http\\Message\\ResponseInterface;
use Psr\\Http\\Message\\ServerRequestInterface;
final class UserController
{
public function __construct(
private readonly UserRepository $users,
private readonly PaymentService $payments,
) {}
public function show(ServerRequestInterface $request, string $id): ResponseInterface
{
$user = $this->users->find($id);
if ($user === null) {
return $this->json(['error' => 'user_not_found'], 404);
}
$balance = $this->payments->balanceFor($user);
return $this->json([
'id' => $user->id,
'email' => $user->email,
'plan' => $user->plan,
'balance' => $balance->toArray(),
]);
}
public function update(ServerRequestInterface $request, string $id): ResponseInterface
{
$user = $this->users->find($id);
if ($user === null) {
return $this->json(['error' => 'user_not_found'], 404);
}
$data = (array) $request->getParsedBody();
$user->fill($this->onlyFillable($data));
$this->users->save($user);
return $this->json($user->toArray());
}
private function onlyFillable(array $data): array
{
return array_intersect_key($data, array_flip(['email', 'plan']));
}
}
`;
O["public/assets/app.js"] = `import { createStore } from './store.js';
import { mountRouter } from './router.js';
const store = createStore({
user: null,
notifications: [],
theme: 'dark',
});
async function bootstrap() {
const res = await fetch('/api/session');
if (res.ok) {
const session = await res.json();
store.set('user', session.user);
}
mountRouter(document.querySelector('#app'), store);
store.subscribe('notifications', renderToasts);
}
function renderToasts(list) {
const host = document.querySelector('#toasts');
host.replaceChildren(...list.map((n) => {
const el = document.createElement('div');
el.className = \\\`toast toast--\\\${n.level}\\\`;
el.textContent = n.message;
return el;
}));
}
document.addEventListener('DOMContentLoaded', bootstrap);
`;
O["config/app.json"] = `{
"name": "console",
"env": "production",
"features": {
"billing": true,
"newDashboard": false
},
"payment": {
"gateway": "stripe",
"currency": "EUR",
"retryLimit": 3
},
"logging": {
"level": "info",
"channel": "stdout"
}
}
`;
O["scripts/migrate.py"] = `#!/usr/bin/env python3
"""Run pending database migrations in order."""
import sys
from pathlib import Path
from db import connect, applied_migrations
MIGRATIONS = Path(__file__).parent / "migrations"
def pending(conn):
done = applied_migrations(conn)
files = sorted(MIGRATIONS.glob("*.sql"))
return [f for f in files if f.stem not in done]
def run(conn, migration: Path) -> None:
sql = migration.read_text()
print(f" -> applying {migration.stem}")
with conn.cursor() as cur:
cur.execute(sql)
cur.execute(
"INSERT INTO schema_migrations (version) VALUES (%s)",
(migration.stem,),
)
conn.commit()
def main() -> int:
conn = connect()
todo = pending(conn)
if not todo:
print("Database is up to date.")
return 0
for migration in todo:
run(conn, migration)
print("Done.")
return 0
if __name__ == "__main__":
sys.exit(main())
`;
// PaymentService is a brand-new file (added) -> original is empty
O["src/Service/PaymentService.php"] = "";
// LegacyUser was deleted -> original content, no working-tree version
O["src/Model/LegacyUser.php"] = `<?php
namespace App\\Model;
/**
* @deprecated Superseded by App\\Entity\\User. Kept only for the
* legacy billing import; safe to remove once the importer is gone.
*/
final class LegacyUser
{
public function __construct(
public readonly int $id,
public readonly string $email,
public readonly ?string $plan = null,
) {}
public static function fromRow(array $row): self
{
return new self(
(int) $row['id'],
(string) $row['email'],
$row['plan'] ?? null,
);
}
public function toArray(): array
{
return [
'id' => $this->id,
'email' => $this->email,
'plan' => $this->plan,
];
}
}
`;
// ---- file tree (nested) -------------------------------------------
const tree = {
name: "console", type: "dir", path: "", open: true, children: [
{ name: "config", type: "dir", path: "config", open: false, children: [
{ name: "app.json", type: "file", path: "config/app.json" },
]},
{ name: "public", type: "dir", path: "public", open: true, children: [
{ name: "assets", type: "dir", path: "public/assets", open: true, children: [
{ name: "app.js", type: "file", path: "public/assets/app.js" },
{ name: "store.js", type: "file", path: "public/assets/store.js" },
{ name: "styles.css", type: "file", path: "public/assets/styles.css" },
]},
]},
{ name: "scripts", type: "dir", path: "scripts", open: false, children: [
{ name: "migrate.py", type: "file", path: "scripts/migrate.py" },
{ name: "seed.py", type: "file", path: "scripts/seed.py" },
]},
{ name: "src", type: "dir", path: "src", open: true, children: [
{ name: "Http", type: "dir", path: "src/Http", open: true, children: [
{ name: "Controller", type: "dir", path: "src/Http/Controller", open: true, children: [
{ name: "UserController.php", type: "file", path: "src/Http/Controller/UserController.php" },
]},
]},
{ name: "Service", type: "dir", path: "src/Service", open: true, children: [
{ name: "PaymentService.php", type: "file", path: "src/Service/PaymentService.php" },
]},
{ name: "types", type: "dir", path: "src/types", open: false, children: [
{ name: "api.ts", type: "file", path: "src/types/api.ts" },
]},
]},
{ name: "templates", type: "dir", path: "templates", open: false, children: [
{ name: "dashboard.html", type: "file", path: "templates/dashboard.html" },
]},
{ name: ".env", type: "file", path: ".env" },
{ name: "composer.json", type: "file", path: "composer.json" },
{ name: "package.json", type: "file", path: "package.json" },
{ name: "README.md", type: "file", path: "README.md" },
],
};
// ---- line-based LCS diff ------------------------------------------
function buildDiff(origText, updText) {
const a = origText === "" ? [] : origText.replace(/\n$/, "").split("\n");
const b = updText === "" ? [] : updText.replace(/\n$/, "").split("\n");
const n = a.length, m = b.length;
const dp = Array.from({ length: n + 1 }, () => new Int32Array(m + 1));
for (let i = n - 1; i >= 0; i--)
for (let j = m - 1; j >= 0; j--)
dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
const ops = [];
let i = 0, j = 0;
while (i < n && j < m) {
if (a[i] === b[j]) { ops.push({ t: "same", a: i, b: j }); i++; j++; }
else if (dp[i + 1][j] >= dp[i][j + 1]) { ops.push({ t: "del", a: i }); i++; }
else { ops.push({ t: "add", b: j }); j++; }
}
while (i < n) { ops.push({ t: "del", a: i++ }); }
while (j < m) { ops.push({ t: "add", b: j++ }); }
const rows = [], left = [], right = [], split = [];
const delSet = new Set(), addSet = new Set();
let add = 0, del = 0;
for (const op of ops) {
if (op.t === "same") {
rows.push({ sign: " ", oldNo: op.a + 1, newNo: op.b + 1, text: a[op.a] });
} else if (op.t === "del") {
rows.push({ sign: "-", oldNo: op.a + 1, newNo: null, text: a[op.a] });
delSet.add(op.a); del++;
} else {
rows.push({ sign: "+", oldNo: null, newNo: op.b + 1, text: b[op.b] });
addSet.add(op.b); add++;
}
}
a.forEach((text, idx) => left.push({ no: idx + 1, text, mark: delSet.has(idx) ? "del" : null }));
b.forEach((text, idx) => right.push({ no: idx + 1, text, mark: addSet.has(idx) ? "add" : null }));
// aligned split rows (pair del/add blocks)
let dbuf = [], abuf = [];
const flush = () => {
const k = Math.max(dbuf.length, abuf.length);
for (let x = 0; x < k; x++) split.push({ l: dbuf[x] || null, r: abuf[x] || null });
dbuf = []; abuf = [];
};
for (const op of ops) {
if (op.t === "same") { flush(); split.push({ l: { no: op.a + 1, text: a[op.a] }, r: { no: op.b + 1, text: b[op.b] } }); }
else if (op.t === "del") dbuf.push({ no: op.a + 1, text: a[op.a], mark: "del" });
else abuf.push({ no: op.b + 1, text: b[op.b], mark: "add" });
}
flush();
return { rows, left, right, split, add, del };
}
// ---- changed files -------------------------------------------------
const changeDefs = [
{ path: "src/Service/PaymentService.php", status: "A" },
{ path: "src/Http/Controller/UserController.php", status: "M" },
{ path: "public/assets/app.js", status: "M" },
{ path: "config/app.json", status: "M" },
{ path: "scripts/migrate.py", status: "M" },
{ path: "src/Model/LegacyUser.php", status: "D" },
];
const diffs = {};
const changes = changeDefs.map((c) => {
const orig = O[c.path] != null ? O[c.path] : "";
const upd = F[c.path] != null ? F[c.path] : "";
const d = buildDiff(orig, upd);
diffs[c.path] = Object.assign(d, {
deleted: c.status === "D",
added: c.status === "A",
original: orig,
updated: upd,
});
return { path: c.path, status: c.status, add: d.add, del: d.del, deleted: c.status === "D" };
});
window.PROJECT = {
name: "console",
branch: "feat/payments-balance",
files: F,
originals: O,
tree,
diffs,
changes,
};
})();

View File

@@ -0,0 +1,331 @@
/* Editor: tabs + four view modes (Original / Updated / Diff / Split) + line selection */
const { ref, computed, onMounted, nextTick, watch } = Vue;
/* ---------- framework-agnostic helpers (ported verbatim) ---------- */
function climbToLine(node) {
let el = node && node.nodeType === 3 ? node.parentElement : node;
while (el && !(el.dataset && el.dataset.line)) el = el.parentElement;
return el || null;
}
function buildLines(mode, diff, fileText) {
if (mode === "original") return { lines: diff.left.map((l) => ({ no: l.no, text: l.text, row: l.mark === "del" ? "bar-del" : null })), showSign: false };
if (mode === "updated") return { lines: diff.right.map((l) => ({ no: l.no, text: l.text, row: l.mark === "add" ? "bar-add" : null })), showSign: false };
if (mode === "diff") return { lines: diff.rows.map((r) => ({ no: r.newNo || r.oldNo, text: r.text, sign: r.sign, row: r.sign === "+" ? "add" : r.sign === "-" ? "del" : null })), showSign: true };
// plain file
const arr = (fileText || "").replace(/\n$/, "").split("\n");
return { lines: arr.map((t, i) => ({ no: i + 1, text: t })), showSign: false };
}
const SEGMENTS = [
{ id: "original", label: "Original" },
{ id: "updated", label: "Updated" },
{ id: "diff", label: "Diff" },
];
/* ---------- EditorTabs ---------- */
const EditorTabs = {
props: ["tabs", "active", "onActivate", "onClose"],
setup(props) {
const rootRef = ref(null);
watch(() => props.active, async () => {
await nextTick();
const el = rootRef.value && rootRef.value.querySelector(".tab.active");
if (el) el.scrollIntoView({ block: "nearest", inline: "nearest" });
});
return { rootRef };
},
template: `
<div class="tabs" ref="rootRef">
<div
v-for="t in tabs"
:key="t.path"
:class="'tab' + (active === t.path ? ' active' : '')"
@click="onActivate(t.path)"
@auxclick="(e) => { if (e.button === 1) { e.preventDefault(); onClose(t.path); } }"
:title="t.path"
>
<FileIcon :path="t.path" />
<span class="tname">{{ t.path.split('/').pop() }}</span>
<span v-if="t.changed" class="tab-mode">{{ t.modeLabel }}</span>
<span class="tclose" @click.stop="onClose(t.path)">
<Icon name="close" />
</span>
</div>
</div>
`,
};
/* ---------- PaneView ---------- */
const PaneView = {
props: ["cacheKey", "path", "lines", "lang", "showSign", "refLine", "cursor", "selection", "setCursor", "setSelection", "onContext"],
setup(props) {
// anchor for shift-click range selection
const anchorRef = ref(null);
// highlighted HTML — recomputed when cacheKey changes
const html = computed(() => props.lines.map((l) => HL.hlLine(l.text, props.lang)));
function gutterClick(e, no) {
if (no == null) return;
e.stopPropagation();
if (e.shiftKey && anchorRef.value != null) {
const a = anchorRef.value;
props.setSelection({ path: props.path, start: Math.min(a, no), end: Math.max(a, no), anchor: a });
} else {
anchorRef.value = no;
props.setSelection({ path: props.path, start: no, end: no, anchor: no });
}
props.setCursor({ path: props.path, line: no, col: 1 });
}
function caretCol(sel) {
try {
const el = climbToLine(sel.focusNode);
const code = el.querySelector(".ln-code");
const r = document.createRange();
r.setStart(code, 0); r.setEnd(sel.focusNode, sel.focusOffset);
return r.toString().length + 1;
} catch (e) { return 1; }
}
function onMouseUp() {
const sel = window.getSelection();
if (sel && !sel.isCollapsed) {
const a = climbToLine(sel.anchorNode), f = climbToLine(sel.focusNode);
if (a && f) {
const an = +a.dataset.line, fn = +f.dataset.line;
const s = Math.min(an, fn), e = Math.max(an, fn);
if (s !== e) { props.setSelection({ path: props.path, start: s, end: e, anchor: an }); props.setCursor({ path: props.path, line: fn, col: caretCol(sel) }); return; }
}
}
if (sel && sel.focusNode) {
const el = climbToLine(sel.focusNode);
if (el) { props.setCursor({ path: props.path, line: +el.dataset.line, col: caretCol(sel) }); props.setSelection(null); }
}
}
function handleContext(e) {
e.preventDefault();
const sel = window.getSelection();
let info = { path: props.path, kind: "editor" };
const a = sel && sel.anchorNode && climbToLine(sel.anchorNode);
const f = sel && sel.focusNode && climbToLine(sel.focusNode);
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);
info.sel = { start: s, end: en }; info.line = s;
} else if (props.selection && props.selection.path === props.path && props.selection.start !== props.selection.end) {
info.sel = { start: props.selection.start, end: props.selection.end }; info.line = props.selection.start;
} else {
let no = null;
const r = document.caretRangeFromPoint ? document.caretRangeFromPoint(e.clientX, e.clientY) : null;
const el = r && climbToLine(r.startContainer);
if (el) no = +el.dataset.line;
info.line = no || (props.cursor && props.cursor.path === props.path ? props.cursor.line : 1);
}
props.setCursor({ path: props.path, line: info.line, col: 1 });
props.onContext(e, info);
}
return { html, gutterClick, onMouseUp, handleContext };
},
template: `
<div :class="'editor' + (showSign ? ' diff' : '')" @mouseup="onMouseUp" @contextmenu="handleContext">
<template v-for="(l, i) in lines" :key="i">
<div
:data-line="l.no == null ? undefined : l.no"
:class="'ln-row'
+ (l.row === 'add' ? ' add' : l.row === 'del' ? ' del' : '')
+ (l.row === 'bar-add' ? ' bar-add' : l.row === 'bar-del' ? ' bar-del' : '')
+ (l.no === (cursor && cursor.path === path ? cursor.line : -1) && !(selection && selection.path === path && l.no != null && l.no >= selection.start && l.no <= selection.end) && !l.row ? ' cursor' : '')
+ (selection && selection.path === path && l.no != null && l.no >= selection.start && l.no <= selection.end ? ' selrange' : '')"
>
<span class="ln-gutter" @click="(e) => gutterClick(e, l.no)">{{ l.no == null ? '' : l.no }}</span>
<span v-if="showSign" class="ln-sign">{{ l.sign === ' ' || !l.sign ? '' : l.sign }}</span>
<span class="ln-code" v-html="html[i]" />
</div>
</template>
</div>
`,
};
/* ---------- Editor ---------- */
const Editor = {
props: ["tabs", "active", "mode", "setMode", "onActivate", "onClose", "onContext", "onSplit", "splitOpen", "cursor", "selection", "setCursor", "setSelection"],
setup(props) {
const tab = computed(() => props.tabs.find((t) => t.path === props.active));
const change = computed(() => tab.value ? PROJECT.changes.find((c) => c.path === tab.value.path) : null);
const diff = computed(() => tab.value ? PROJECT.diffs[tab.value.path] : null);
const lang = computed(() => tab.value ? HL.langFor(tab.value.path) : null);
const effMode = computed(() => change.value ? props.mode : "code");
const built = computed(() => {
if (!tab.value) return null;
if (change.value && diff.value) return buildLines(effMode.value, diff.value, PROJECT.files[tab.value.path]);
return buildLines("code", null, PROJECT.files[tab.value.path]);
});
const statusWord = computed(() => {
if (!change.value) return "";
return change.value.status === "A" ? "Added" : change.value.status === "D" ? "Deleted" : "Modified";
});
const activeSeg = computed(() => props.splitOpen ? "split" : effMode.value);
const emptyUpdated = computed(() => effMode.value === "updated" && built.value && built.value.lines.length === 0);
const emptyOriginal = computed(() => effMode.value === "original" && built.value && built.value.lines.length === 0);
return { tab, change, diff, lang, effMode, built, statusWord, activeSeg, emptyUpdated, emptyOriginal, SEGMENTS };
},
template: `
<EditorTabs :tabs="tabs" :active="active" :onActivate="onActivate" :onClose="onClose" />
<div v-if="!tab" class="empty-ed">
<div :style="{ opacity: 0.5 }"><Icon name="file" :w="30" :h="30" /></div>
<div class="big">No file open</div>
<div class="klist">
<div><span>Search files &amp; content</span><kbd>⌘ F</kbd></div>
<div><span>Copy reference</span><kbd>right-click</kbd></div>
<div><span>Pass on to Agent</span><kbd>right-click</kbd></div>
</div>
</div>
<div v-else class="editor-wrap">
<div v-if="change" class="diff-bar">
<span :class="'git-stat ' + change.status" style="width:auto">{{ statusWord }}</span>
<span v-if="change.add > 0" class="a">+{{ change.add }}</span>
<span v-if="change.del > 0" class="d">{{ change.del }}</span>
<div class="seg">
<button
v-for="s in SEGMENTS"
:key="s.id"
:class="activeSeg === s.id ? 'on' : ''"
@click="setMode(s.id)"
>{{ s.label }}</button>
<button
:class="'split-btn' + (activeSeg === 'split' ? ' on' : '')"
@click="onSplit(tab.path)"
title="Split — full screen side-by-side"
>
<svg width="11" height="11" viewBox="0 0 12 12" fill="none"><rect x="1" y="1.5" width="10" height="9" rx="1.5" stroke="currentColor" stroke-width="1.2"/><line x1="6" y1="1.5" x2="6" y2="10.5" stroke="currentColor" stroke-width="1.2"/></svg>
Split
</button>
</div>
</div>
<div v-if="emptyUpdated" class="empty-ed">
<div class="big" :style="{ color: 'var(--del)' }">No updated version</div>
<div :style="{ fontFamily: 'var(--mono)', fontSize: '12px', color: 'var(--fg-3)' }">This file was deleted in the change.</div>
</div>
<div v-else-if="emptyOriginal" class="empty-ed">
<div class="big" :style="{ color: 'var(--add)' }">No original version</div>
<div :style="{ fontFamily: 'var(--mono)', fontSize: '12px', color: 'var(--fg-3)' }">This file is new in the change.</div>
</div>
<PaneView
v-else
:cacheKey="tab.path + ':' + effMode"
:path="tab.path"
:lines="built.lines"
:lang="lang"
:showSign="built.showSign"
:cursor="cursor"
:selection="selection"
:setCursor="setCursor"
:setSelection="setSelection"
:onContext="onContext"
/>
</div>
`,
};
/* ---------- SplitView ---------- */
const SplitView = {
props: ["path", "onClose", "onContext"],
setup(props) {
const diff = computed(() => PROJECT.diffs[props.path]);
const lang = computed(() => HL.langFor(props.path));
const change = computed(() => PROJECT.changes.find((c) => c.path === props.path));
const leftRef = ref(null);
const rightRef = ref(null);
const lock = ref(false);
const leftHtml = computed(() => diff.value.split.map((r) => r.l ? HL.hlLine(r.l.text, lang.value) : ""));
const rightHtml = computed(() => diff.value.split.map((r) => r.r ? HL.hlLine(r.r.text, lang.value) : ""));
function sync(from, to) {
if (lock.value) return; lock.value = true;
to.scrollTop = from.scrollTop;
to.scrollLeft = from.scrollLeft;
requestAnimationFrame(() => { lock.value = false; });
}
function ctx(e, side) {
e.preventDefault();
let no = null;
const r = document.caretRangeFromPoint ? document.caretRangeFromPoint(e.clientX, e.clientY) : null;
const el = r && climbToLine(r.startContainer);
if (el) no = +el.dataset.line;
props.onContext(e, { path: props.path, kind: "editor", line: no || 1 });
}
return { diff, lang, change, leftRef, rightRef, sync, ctx, leftHtml, rightHtml };
},
template: `
<div class="split-overlay">
<div class="split-head">
<FileIcon :path="path" />
<span class="sh-name">{{ path }}</span>
<span v-if="change" :class="'git-stat ' + change.status" style="width:auto">{{ change.status === 'A' ? 'Added' : change.status === 'D' ? 'Deleted' : 'Modified' }}</span>
<span v-if="change && change.add > 0" class="a" :style="{ fontFamily: 'var(--mono)', color: 'var(--add)' }">+{{ change.add }}</span>
<span v-if="change && change.del > 0" class="d" :style="{ fontFamily: 'var(--mono)', color: 'var(--del)' }">{{ change.del }}</span>
<button class="split-exit" @click="onClose">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none"><path d="M7 1.5h3.5V5M5 10.5H1.5V7M10.5 1.5L7 5M1.5 10.5L5 7" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>
Collapse <kbd>Esc</kbd>
</button>
</div>
<div class="split-body">
<div class="split-pane left">
<div class="split-label">Original <span>before</span></div>
<div class="editor" ref="leftRef" @scroll="sync(leftRef, rightRef)" @contextmenu="(e) => ctx(e, 'l')">
<div
v-for="(row, i) in diff.split"
:key="i"
:data-line="row.l ? row.l.no : undefined"
:class="'ln-row' + (row.l && row.l.mark === 'del' ? ' bar-del' : '') + (!row.l ? ' empty' : '')"
>
<span class="ln-gutter">{{ row.l ? row.l.no : '' }}</span>
<span class="ln-code" v-html="row.l ? leftHtml[i] : ''" />
</div>
</div>
</div>
<div class="split-pane right">
<div class="split-label">Updated <span>after</span></div>
<div class="editor" ref="rightRef" @scroll="sync(rightRef, leftRef)" @contextmenu="(e) => ctx(e, 'r')">
<div
v-for="(row, i) in diff.split"
:key="i"
:data-line="row.r ? row.r.no : undefined"
:class="'ln-row' + (row.r && row.r.mark === 'add' ? ' bar-add' : '') + (!row.r ? ' empty' : '')"
>
<span class="ln-gutter">{{ row.r ? row.r.no : '' }}</span>
<span class="ln-code" v-html="row.r ? rightHtml[i] : ''" />
</div>
</div>
</div>
</div>
</div>
`,
};
/* ---------- global registration ---------- */
window.HelderComponents = Object.assign(window.HelderComponents || {}, { EditorTabs, PaneView, Editor, SplitView });
window.buildLines = buildLines;
window.climbToLine = climbToLine;

View File

@@ -0,0 +1,79 @@
/* Syntax highlighting (Prism) + file-type icon metadata. */
(function () {
const EXT_LANG = {
php: "php", js: "javascript", mjs: "javascript", cjs: "javascript",
jsx: "jsx", ts: "typescript", tsx: "tsx", py: "python",
html: "markup", xml: "markup", svg: "markup", vue: "markup",
css: "css", scss: "css", json: "json", md: "markdown",
sh: "bash", bash: "bash", yml: "yaml", yaml: "yaml", env: "bash",
};
function ext(path) {
const base = path.split("/").pop() || "";
if (base === ".env" || base.startsWith(".env")) return "env";
const i = base.lastIndexOf(".");
return i >= 0 ? base.slice(i + 1).toLowerCase() : "";
}
function langFor(path) { return EXT_LANG[ext(path)] || null; }
function langLabel(path) {
const e = ext(path);
const map = {
php: "PHP", js: "JavaScript", mjs: "JavaScript", ts: "TypeScript",
tsx: "TypeScript", jsx: "JavaScript", py: "Python", html: "HTML",
css: "CSS", json: "JSON", md: "Markdown", sh: "Shell", env: "Dotenv",
yml: "YAML", yaml: "YAML",
};
return map[e] || (e ? e.toUpperCase() : "Plain Text");
}
function escapeHtml(s) {
return s.replace(/[&<>]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" }[c]));
}
// highlight a single line independently (keeps line numbering robust)
function hlLine(line, lang) {
if (line === "") return "&nbsp;";
try {
const grammar = lang && window.Prism && Prism.languages[lang];
if (grammar) return Prism.highlight(line, grammar, lang);
} catch (e) { /* fall through */ }
return escapeHtml(line);
}
// ---- file-type icon: colored monogram chip --------------------------
const ICONS = {
php: { c: "#a78bdb", t: "php" },
js: { c: "#e6c860", t: "js" },
mjs: { c: "#e6c860", t: "js" },
ts: { c: "#5a9bd6", t: "ts" },
tsx: { c: "#5a9bd6", t: "ts" },
jsx: { c: "#5a9bd6", t: "jsx" },
py: { c: "#5fa8d6", t: "py" },
html: { c: "#e08b6a", t: "<>" },
css: { c: "#5a9bd6", t: "{}" },
scss: { c: "#d6699e", t: "{}" },
json: { c: "#d8a85c", t: "{}" },
md: { c: "#9aa0a8", t: "md" },
env: { c: "#7fc6a0", t: "$" },
sh: { c: "#7fc6a0", t: "$" },
yml: { c: "#cf7a6a", t: "yml" },
yaml: { c: "#cf7a6a", t: "yml" },
lock: { c: "#8a8f98", t: "lk" },
};
const NAME_ICONS = {
"composer.json": { c: "#a78bdb", t: "co" },
"package.json": { c: "#cf7a6a", t: "pk" },
"README.md": { c: "#5a9bd6", t: "md" },
".env": { c: "#7fc6a0", t: "$" },
};
function iconFor(path) {
const base = path.split("/").pop() || "";
if (NAME_ICONS[base]) return NAME_ICONS[base];
return ICONS[ext(path)] || { c: "#7d838c", t: base.slice(0, 2) || "·" };
}
window.HL = { ext, langFor, langLabel, hlLine, iconFor, escapeHtml };
})();

View File

@@ -0,0 +1,332 @@
/* Overlays: command palette (fuzzy file finder), content search, context menu, toast */
const { ref, computed, onMounted, onUnmounted, watch, nextTick } = Vue;
// ---------------------------------------------------------------------------
// fuzzy(q, str) — verbatim port
// ---------------------------------------------------------------------------
function fuzzy(q, str) {
q = q.toLowerCase(); const s = str.toLowerCase();
let i = 0; const idx = [];
for (let j = 0; j < s.length && i < q.length; j++) {
if (s[j] === q[i]) { idx.push(j); i++; }
}
return i === q.length ? idx : null;
}
// ---------------------------------------------------------------------------
// Highlight — props: text, idx
// ---------------------------------------------------------------------------
const Highlight = {
props: ['text', 'idx'],
setup(props) {
// Build an array of { ch, bold } for rendering
const parts = computed(() => {
const set = props.idx && props.idx.length ? new Set(props.idx) : null;
return props.text.split('').map((ch, i) => ({ ch, bold: set ? set.has(i) : false }));
});
return { parts };
},
template: `
<span>
<template v-for="(p, i) in parts" :key="i">
<b v-if="p.bold">{{ p.ch }}</b>
<template v-else>{{ p.ch }}</template>
</template>
</span>
`
};
// ---------------------------------------------------------------------------
// SearchModal — props: onOpen, onOpenAt, onClose, changeSet
// ---------------------------------------------------------------------------
const SearchModal = {
props: ['onOpen', 'onOpenAt', 'onClose', 'changeSet'],
setup(props) {
const q = ref('');
const sel = ref(0);
const inputEl = ref(null);
const leftEl = ref(null);
// All paths — stable, PROJECT.files doesn't change at runtime
const allPaths = computed(() => Object.keys(PROJECT.files));
// Content hits (left panel) — substring search, min 2 chars
const content = computed(() => {
const term = q.value.trim();
if (term.length < 2) return [];
const low = term.toLowerCase();
const groups = [];
for (const [path, src] of Object.entries(PROJECT.files)) {
const lines = src.split('\n');
const hits = [];
lines.forEach((ln, i) => {
const ix = ln.toLowerCase().indexOf(low);
if (ix >= 0) hits.push({ no: i + 1, ln, ix });
});
if (hits.length) groups.push({ path, hits });
}
return groups;
});
// File-name fuzzy matches (right panel)
const files = computed(() => {
const term = q.value.trim();
if (!term) return [];
const out = [];
for (const p of allPaths.value) {
const name = p.split('/').pop();
const ni = fuzzy(term, name);
if (ni) { out.push({ path: p, idx: ni, rank: 0, pos: ni[0] }); continue; }
const pi = fuzzy(term, p);
if (pi) out.push({ path: p, idx: null, rank: 1, pos: pi[0] });
}
out.sort((a, b) => a.rank - b.rank || a.pos - b.pos || a.path.length - b.path.length);
return out;
});
// Flat list of content hits for keyboard nav + flat-index tracking
// Each entry gets its flatIndex here so the template can use it directly
const flat = computed(() => {
const arr = [];
content.value.forEach((g) => g.hits.forEach((h) => arr.push({ path: g.path, no: h.no })));
return arr;
});
// Precomputed flat-index map: "path:lineNo" -> flatIndex
// Used in template to assign .sel class without mutating during render
const flatIndexMap = computed(() => {
const map = {};
let ix = 0;
content.value.forEach((g) => {
g.hits.slice(0, 12).forEach((h) => {
map[`${g.path}:${h.no}`] = ix++;
});
});
return map;
});
const totalHits = computed(() => flat.value.length);
// Reset selection when query changes
watch(q, () => { sel.value = 0; });
// Scroll selected line into view when sel changes
watch(sel, () => {
nextTick(() => {
const el = leftEl.value && leftEl.value.querySelector('.sr-line.sel');
if (el) el.scrollIntoView({ block: 'nearest' });
});
});
onMounted(() => { inputEl.value && inputEl.value.focus(); });
function onKey(e) {
if (e.key === 'ArrowDown') {
e.preventDefault();
sel.value = Math.min(sel.value + 1, flat.value.length - 1);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
sel.value = Math.max(sel.value - 1, 0);
} else if (e.key === 'Enter') {
e.preventDefault();
if (flat.value[sel.value]) {
props.onOpenAt(flat.value[sel.value].path, flat.value[sel.value].no);
props.onClose();
} else if (files.value[0]) {
props.onOpen(files.value[0].path);
props.onClose();
}
} else if (e.key === 'Escape') {
e.preventDefault();
props.onClose();
}
}
// renderLine: returns { pre, mid, post } for a content line hit
function renderLine(ln, ix, len) {
return { pre: ln.slice(0, ix), mid: ln.slice(ix, ix + len), post: ln.slice(ix + len) };
}
return {
q, sel, inputEl, leftEl,
content, files, flat, flatIndexMap, totalHits,
onKey, renderLine,
};
},
template: `
<div class="scrim" @mousedown="onClose">
<div class="search-modal" @mousedown.stop>
<div class="pi">
<Icon name="search" :style="{ color: 'var(--fg-3)' }" />
<input ref="inputEl" :value="q" @input="q = $event.target.value" @keydown="onKey"
placeholder="Search content and file names…" :spellcheck="false" />
<span class="mode-chip">{{ totalHits }} hit{{ totalHits === 1 ? '' : 's' }} · {{ files.length }} file{{ files.length === 1 ? '' : 's' }}</span>
</div>
<div class="search-cols">
<div class="sc-left" ref="leftEl">
<div class="sc-head">Content <span v-if="totalHits > 0" class="sc-ct">{{ totalHits }}</span></div>
<div v-if="q.trim().length < 2" class="pempty">Type at least 2 characters</div>
<div v-else-if="content.length === 0" class="pempty">No content matches</div>
<template v-for="g in content" :key="g.path">
<div class="sr-file" @click="onOpenAt(g.path, g.hits[0].no)">
<FileIcon :path="g.path" />
<span class="srf-name">{{ g.path }}</span>
<span class="cnt">{{ g.hits.length }}</span>
</div>
<div v-for="h in g.hits.slice(0, 12)" :key="h.no"
:class="'sr-line' + (flatIndexMap[g.path + ':' + h.no] === sel ? ' sel' : '')"
@mouseenter="sel = flatIndexMap[g.path + ':' + h.no]"
@click="onOpenAt(g.path, h.no); onClose()">
<span class="no">{{ h.no }}</span>
<span class="tx">{{ renderLine(h.ln, h.ix, q.trim().length).pre }}<mark>{{ renderLine(h.ln, h.ix, q.trim().length).mid }}</mark>{{ renderLine(h.ln, h.ix, q.trim().length).post }}</span>
</div>
</template>
</div>
<div class="sc-right">
<div class="sc-head">Files <span v-if="files.length > 0" class="sc-ct">{{ files.length }}</span></div>
<div v-if="!q.trim()" class="pempty sm">Start typing…</div>
<div v-else-if="files.length === 0" class="pempty sm">No file names match</div>
<div v-for="r in files.slice(0, 40)" :key="r.path" class="fres"
@click="onOpen(r.path); onClose()" :title="r.path">
<FileIcon :path="r.path" />
<div class="fres-txt">
<span class="fn"><Highlight :text="r.path.split('/').pop()" :idx="r.idx" /></span>
<span v-if="r.path.split('/').slice(0,-1).join('/')" class="fd">{{ r.path.split('/').slice(0,-1).join('/') }}/</span>
</div>
<span v-if="changeSet.has(r.path)" class="tree-badge M" :style="{ fontFamily: 'var(--mono)', fontSize: '10px' }">•</span>
</div>
</div>
</div>
</div>
</div>
`
};
// ---------------------------------------------------------------------------
// ContextMenu — props: menu, onClose
// ---------------------------------------------------------------------------
const ContextMenu = {
props: ['menu', 'onClose'],
setup(props) {
const menuEl = ref(null);
function outsideClick(e) {
if (menuEl.value && !menuEl.value.contains(e.target)) props.onClose();
}
function escKey(e) {
if (e.key === 'Escape') props.onClose();
}
onMounted(() => {
document.addEventListener('mousedown', outsideClick);
document.addEventListener('keydown', escKey);
});
onUnmounted(() => {
document.removeEventListener('mousedown', outsideClick);
document.removeEventListener('keydown', escKey);
});
const pos = computed(() => {
if (!props.menu) return { x: 0, y: 0 };
return {
x: Math.min(props.menu.x, window.innerWidth - 270),
y: Math.min(props.menu.y, window.innerHeight - (props.menu.items.length * 34 + 60)),
};
});
return { menuEl, pos };
},
template: `
<div v-if="menu" class="ctx" ref="menuEl" :style="{ left: pos.x + 'px', top: pos.y + 'px' }">
<div v-if="menu.note" class="ctx-note">{{ menu.note }}</div>
<template v-for="(it, i) in menu.items" :key="i">
<div v-if="it.sep" class="ctx-sep" />
<div v-else :class="'ctx-item' + (it.primary ? ' primary' : '')"
@click="it.onClick(); onClose()">
<span class="ic"><Icon :name="it.icon" /></span>
<span>{{ it.label }}</span>
<span v-if="it.kbd" class="kc">{{ it.kbd }}</span>
</div>
</template>
</div>
`
};
// ---------------------------------------------------------------------------
// Toasts — props: toasts
// ---------------------------------------------------------------------------
const Toasts = {
props: ['toasts'],
template: `
<div class="toast-wrap">
<div v-for="t in toasts" :key="t.id" class="toast">
<Icon name="copy" :style="{ color: 'var(--accent)' }" />
<span class="tt">{{ t.title }}</span>
<span v-if="t.ref" class="tref">{{ t.ref }}</span>
</div>
</div>
`
};
// ---------------------------------------------------------------------------
// PassPopup — props: x, y, refStr, onConfirm, onCancel
// ---------------------------------------------------------------------------
const PassPopup = {
props: ['x', 'y', 'refStr', 'onConfirm', 'onCancel'],
setup(props) {
const text = ref('');
const inputEl = ref(null);
const boxEl = ref(null);
onMounted(() => { inputEl.value && inputEl.value.focus(); });
function outsideClick(e) {
if (boxEl.value && !boxEl.value.contains(e.target)) props.onCancel();
}
// Capture phase — matches original's addEventListener(…, true)
function escKey(e) {
if (e.key === 'Escape') { e.preventDefault(); props.onCancel(); }
}
onMounted(() => {
document.addEventListener('mousedown', outsideClick);
document.addEventListener('keydown', escKey, true);
});
onUnmounted(() => {
document.removeEventListener('mousedown', outsideClick);
document.removeEventListener('keydown', escKey, true);
});
const left = computed(() => Math.min(props.x, window.innerWidth - 360));
const top = computed(() => Math.min(props.y + 6, window.innerHeight - 150));
const preview = computed(() => (text.value.trim() ? text.value.trim() + ' ' : '') + props.refStr);
function onKeyDown(e) {
if (e.key === 'Enter') { e.preventDefault(); props.onConfirm(text.value); }
else if (e.key === 'Escape') { e.preventDefault(); props.onCancel(); }
}
return { text, inputEl, boxEl, left, top, preview, onKeyDown };
},
template: `
<div class="pass-pop" ref="boxEl" :style="{ left: left + 'px', top: top + 'px' }">
<div class="pass-head"><Icon name="spark" /><span>Pass on to Agent</span><span class="pass-esc">esc</span></div>
<input ref="inputEl" class="pass-input" :value="text" :spellcheck="false"
placeholder="Add a note (optional)…"
@input="text = $event.target.value"
@keydown="onKeyDown" />
<div class="pass-preview"><span class="pp-lbl">inserts</span><code>{{ preview }}</code></div>
<div class="pass-foot"><kbd>↵</kbd> insert into agent · <kbd>esc</kbd> cancel</div>
</div>
`
};
// ---------------------------------------------------------------------------
// Expose
// ---------------------------------------------------------------------------
window.HelderComponents = Object.assign(window.HelderComponents || {}, {
Highlight, SearchModal, ContextMenu, Toasts, PassPopup,
});
window.fuzzy = fuzzy;

View File

@@ -0,0 +1,261 @@
/* Terminals: a generic shell that boots an (original-styled) AI agent session via `claude`. */
const { ref, computed, onMounted, onUnmounted, watch, nextTick } = Vue;
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
let _lid = 0;
const lid = () => ++_lid;
function bootAgent() {
return [
{ kind: "welcome", text: "model: opus · cwd: ~/console · branch: " + PROJECT.branch + " · type /exit to leave" },
{ kind: "t", cls: "dim", html: " I can read, search and edit files in this project. Describe a change to get started." },
];
}
function agentSeed() {
return {
mode: "agent",
lines: [
{ id: lid(), kind: "t", html: `<span class="pfx">console</span> <span class="dim">%</span> claude` },
{ id: lid(), kind: "welcome", text: "model: opus · cwd: ~/console · branch: " + PROJECT.branch + " · type /exit to leave" },
{ id: lid(), kind: "t", html: `<span class="ip ag">&gt;</span> add currency + name to the user payload and gate the fillable fields` },
{ id: lid(), kind: "t", html: `<span class="ag">●</span> Read <span class="fp">UserController.php</span>, edited the response array and <span class="fp">onlyFillable()</span>.` },
{
id: lid(), kind: "card", ch: "edit · src/Http/Controller/UserController.php",
rows: [
{ cls: "del", t: "- 'plan' => $user->plan," },
{ cls: "add", t: "+ 'plan' => $user->plan->value," },
{ cls: "add", t: "+ 'name' => $user->name," },
],
},
{ id: lid(), kind: "t", cls: "ok", html: `<span class="ag">●</span> <span class="ok">Done</span> — updated <span class="fp">UserController.php</span>. Anything else?` },
],
};
}
function shellSeed() {
return {
mode: "shell",
lines: [
{ id: lid(), kind: "t", html: `<span class="pfx">console</span> <span class="dim">%</span> git status -s` },
...PROJECT.changes.map((c) => ({
id: lid(), kind: "t",
html: `<span style="color:var(--${c.status === 'A' ? 'add' : c.status === 'D' ? 'del' : 'mod'})">${c.status} </span> <span class="fp">${c.path}</span>`,
})),
],
};
}
const Terminal = {
props: ["kind", "seed"],
setup(props) {
const lines = ref(props.seed.lines);
const mode = ref(props.seed.mode);
const input = ref("");
const busy = ref(false);
const bodyRef = ref(null);
const inputRef = ref(null);
const hist = ref([]);
const histIx = ref(-1);
// Auto-scroll on lines/busy change
watch([lines, busy], () => nextTick(() => {
const el = bodyRef.value;
if (el) el.scrollTop = el.scrollHeight;
}));
const push = (line) => { lines.value = [...lines.value, { id: lid(), ...line }]; };
const pushMany = (arr) => { lines.value = [...lines.value, ...arr.map((l) => ({ id: lid(), ...l }))]; };
async function runAgent(prompt) {
busy.value = true;
push({ kind: "agent-think", html: `<span class="ag">●</span> <span class="dim">thinking…</span>` });
await wait(520);
lines.value = lines.value.slice(0, -1); // drop the thinking line
push({ kind: "t", cls: "", html: `<span class="ag">●</span> I'll take a look at the relevant files first.` });
await wait(380);
push({ kind: "t", cls: "dim", html: ` <span class="tool">read</span> <span class="fp">src/Http/Controller/UserController.php</span>` });
await wait(300);
push({ kind: "t", cls: "dim", html: ` <span class="tool">grep</span> <span class="dim">"balanceFor" → 2 matches</span>` });
await wait(420);
push({ kind: "t", cls: "", html: `<span class="ag">●</span> Adding the field and a fillable allow-list. Editing now.` });
await wait(360);
push({
kind: "card", ch: `edit · src/Http/Controller/UserController.php`,
rows: [
{ cls: "del", t: "- 'plan' => $user->plan," },
{ cls: "add", t: "+ 'plan' => $user->plan->value," },
{ cls: "add", t: "+ 'currency' => $user->currency," },
],
});
await wait(450);
push({ kind: "t", cls: "ok", html: `<span class="ag">●</span> <span class="ok">Done.</span> Updated <span class="fp">UserController.php</span>. Run tests with <span class="fp">composer test</span>.` });
busy.value = false;
requestAnimationFrame(() => inputRef.value && inputRef.value.focus());
}
function shell(cmd) {
const [c, ...rest] = cmd.split(/\s+/);
const arg = rest.join(" ");
switch (c) {
case "": return;
case "claude":
pushMany(bootAgent());
mode.value = "agent";
return;
case "clear": lines.value = []; return;
case "help":
pushMany([
{ kind: "t", cls: "dim", html: "commands: <span style='color:var(--fg-1)'>claude</span> ls pwd cat &lt;file&gt; git status git diff echo clear" },
]); return;
case "pwd": push({ kind: "t", html: "/Users/dev/console" }); return;
case "ls":
push({ kind: "t", html: "<span class='fp'>config</span> <span class='fp'>public</span> <span class='fp'>scripts</span> <span class='fp'>src</span> <span class='fp'>templates</span> composer.json package.json README.md" });
return;
case "echo": push({ kind: "t", html: HL.escapeHtml(arg) }); return;
case "git":
if (rest[0] === "status") {
pushMany([
{ kind: "t", cls: "dim", html: `On branch <span style='color:var(--fg-1)'>${PROJECT.branch}</span>` },
{ kind: "t", cls: "dim", html: "Changes to be committed:" },
...PROJECT.changes.map((ch) => ({
kind: "t",
html: ` <span style="color:var(--${ch.status === 'A' ? 'add' : ch.status === 'D' ? 'del' : 'mod'})">${ch.status === 'A' ? 'new file' : ch.status === 'D' ? 'deleted ' : 'modified'}</span> <span class="fp">${ch.path}</span>`,
})),
]);
} else if (rest[0] === "diff") {
pushMany([
{ kind: "t", cls: "dim", html: "diff --git a/public/assets/app.js b/public/assets/app.js" },
{ kind: "t", cls: "err", html: "- const res = await fetch('/api/session');" },
{ kind: "t", cls: "ok", html: "+ const res = await fetch('/api/session', { credentials: 'include' });" },
]);
} else push({ kind: "t", cls: "dim", html: `git: '${rest[0] || ""}' is not handled in this demo` });
return;
case "cat": {
const f = PROJECT.files[arg] || Object.entries(PROJECT.files).find(([p]) => p.endsWith(arg))?.[1];
if (!f) { push({ kind: "t", cls: "err", html: `cat: ${HL.escapeHtml(arg)}: No such file` }); return; }
pushMany(f.replace(/\n$/, "").split("\n").slice(0, 24).map((l) => ({ kind: "t", cls: "dim", html: HL.escapeHtml(l) || "&nbsp;" })));
return;
}
default: push({ kind: "t", cls: "err", html: `${HL.escapeHtml(c)}: command not found` });
}
}
function submit() {
const cmd = input.value.trim();
if (busy.value) return;
if (cmd) { hist.value.unshift(cmd); }
histIx.value = -1;
if (mode.value === "agent") {
if (cmd === "/exit" || cmd === "exit") {
push({ kind: "t", cls: "dim", html: "<span class='ag'>●</span> Session ended." });
mode.value = "shell"; input.value = ""; return;
}
if (cmd === "/clear" || cmd === "clear") { lines.value = []; input.value = ""; return; }
push({ kind: "t", html: `<span class="ip ag">&gt;</span> ${HL.escapeHtml(cmd).replace(/\n/g, "<br>&nbsp;&nbsp;") || "&nbsp;"}` });
input.value = "";
if (cmd) runAgent(cmd);
return;
}
push({ kind: "t", html: `<span class="pfx">console</span> <span class="dim">%</span> ${HL.escapeHtml(cmd) || "&nbsp;"}` });
input.value = "";
shell(cmd);
}
function onKey(e) {
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); submit(); return; }
if ((e.key === "ArrowUp" || e.key === "ArrowDown") && !input.value.includes("\n")) {
e.preventDefault();
if (e.key === "ArrowUp") {
if (hist.value.length) {
histIx.value = Math.min(histIx.value + 1, hist.value.length - 1);
input.value = hist.value[histIx.value];
}
} else {
if (histIx.value > 0) { histIx.value--; input.value = hist.value[histIx.value]; }
else { histIx.value = -1; input.value = ""; }
}
}
}
// agentPaste CustomEvent listener — agent kind only
let agentPasteHandler = null;
onMounted(() => {
if (props.kind !== "agent") return;
agentPasteHandler = (e) => {
if (mode.value !== "agent") { pushMany(bootAgent()); mode.value = "agent"; }
// bracketed-paste semantics: append as a new, UNSUBMITTED line; leave caret on a fresh line
const base = input.value.replace(/\n+$/, "");
input.value = (base ? base + "\n" : "") + e.detail + "\n";
requestAnimationFrame(() => {
const el = inputRef.value;
if (el) { el.focus(); const v = el.value.length; el.setSelectionRange(v, v); }
});
};
window.addEventListener("agentPaste", agentPasteHandler);
});
onUnmounted(() => {
if (agentPasteHandler) window.removeEventListener("agentPaste", agentPasteHandler);
});
const live = computed(() => mode.value === "agent");
const inputRows = computed(() => Math.min(8, Math.max(1, input.value.split("\n").length)));
const inputPlaceholder = computed(() =>
live.value ? "Ask the agent to change something…" : props.kind === "agent" ? "type 'claude' to start a session" : ""
);
return { lines, mode, input, busy, bodyRef, inputRef, live, inputRows, inputPlaceholder, onKey, submit };
},
template: `
<div class="term-pane" :style="{ flex: 1, minHeight: 0 }" @mousedown="inputRef && inputRef.focus()">
<div class="term-head">
<span :class="'dot' + (live ? ' live' : '')"></span>
<span class="lbl">{{ live ? 'claude' : kind === 'agent' ? 'claude' : 'zsh' }}</span>
<span class="tag">{{ live ? 'agent session' : '— bash · ~/console' }}</span>
</div>
<div class="term-body" ref="bodyRef">
<template v-for="l in lines" :key="l.id">
<div v-if="l.kind === 'card'" class="term-card">
<div class="ch"><Icon name="diff" :w="11" :h="11" /><span>{{ l.ch }}</span></div>
<div v-for="(r, i) in l.rows" :key="i" :class="r.cls">{{ r.t }}</div>
</div>
<div v-else-if="l.kind === 'welcome'" class="term-card" :style="{ borderColor: 'var(--accent-line)' }">
<div :style="{ color: 'var(--accent)', fontWeight: 600 }">◇ agent session</div>
<div class="dim" :style="{ color: 'var(--fg-2)', marginTop: '3px' }">{{ l.text }}</div>
</div>
<div v-else :class="'tline ' + (l.cls || '')" v-html="l.html"></div>
</template>
<div v-if="busy" class="tline dim"><span class="ag" :style="{ color: '#c98bdb' }">●</span> working<span class="cursor-blink" /></div>
<div v-if="!busy" class="term-input">
<span :class="'ip' + (live ? ' ag' : '')">
<template v-if="live">&gt;</template>
<template v-else><span class="pfx">console <span :style="{ color: 'var(--fg-3)' }">%</span></span></template>
</span>
<textarea
ref="inputRef"
class="term-ta"
v-model="input"
:spellcheck="false"
autocomplete="off"
:rows="inputRows"
:placeholder="inputPlaceholder"
@keydown="onKey"
></textarea>
</div>
</div>
</div>
`,
};
// Expose globals for app.js and other files
window.wait = wait;
window.lid = lid;
window.bootAgent = bootAgent;
window.agentSeed = agentSeed;
window.shellSeed = shellSeed;
window.HelderComponents = Object.assign(window.HelderComponents || {}, { Terminal });

View File

@@ -0,0 +1,372 @@
/* ============ Agentic Coding Panel — dark, charcoal-neutral ============ */
:root {
--bg-0:#16171a; /* editor surface (deepest) */
--bg-1:#1a1c1f; /* terminals */
--bg-2:#1f2226; /* sidebars */
--bg-3:#23262b; /* headers / tabs strip */
--hover:#2a2e34;
--active:#313742;
--sel:#2b323d;
--border:#2a2d33;
--border-2:#34383f;
--fg-0:#e6e8ea;
--fg-1:#b4bac2;
--fg-2:#838a94;
--fg-3:#5d636c;
--accent:#4d8dff;
--accent-soft:rgba(77,141,255,0.16);
--accent-line:rgba(77,141,255,0.55);
--add:#5cbd6b;
--del:#e0696a;
--mod:#d8a85c;
--ren:#5aa6d6;
--add-bg:rgba(92,189,107,0.10);
--del-bg:rgba(224,105,106,0.10);
/* syntax */
--t-key:#c98bdb;
--t-str:#94c980;
--t-num:#e0a06a;
--t-fn:#6aa6f0;
--t-com:#5f656e;
--t-tag:#7fc6a0;
--t-attr:#d8b15c;
--t-punc:#9aa0a8;
--t-var:#e6e8ea;
--t-const:#e08b6a;
--t-prop:#6ec0c0;
--ui:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
--mono:"JetBrains Mono",ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
}
* { box-sizing:border-box; }
html,body { margin:0; height:100%; }
body {
background:var(--bg-0); color:var(--fg-0);
font-family:var(--ui); font-size:13px;
overflow:hidden; -webkit-font-smoothing:antialiased;
}
#root { height:100vh; }
::selection { background:rgba(77,141,255,0.32); }
/* scrollbars */
::-webkit-scrollbar { width:11px; height:11px; }
::-webkit-scrollbar-thumb { background:#393e46; border-radius:6px; border:3px solid transparent; background-clip:content-box; }
::-webkit-scrollbar-thumb:hover { background:#4a505a; background-clip:content-box; }
::-webkit-scrollbar-corner { background:transparent; }
/* ============ shell ============ */
.app { display:flex; flex-direction:column; height:100vh; }
.titlebar {
height:36px; flex:0 0 36px; display:flex; align-items:center;
background:var(--bg-3); border-bottom:1px solid var(--border);
padding:0 12px; gap:14px; user-select:none;
}
.traffic { display:flex; gap:8px; }
.traffic i { width:12px; height:12px; border-radius:50%; display:block; }
.traffic .r{background:#e0696a;} .traffic .y{background:#d8a85c;} .traffic .g{background:#5cbd6b;}
.tb-title { font-size:12px; color:var(--fg-1); display:flex; align-items:center; gap:7px; }
.tb-title b { color:var(--fg-0); font-weight:600; }
.tb-crumb { color:var(--fg-3); font-size:11.5px; font-family:var(--mono); }
.tb-crumb .seg{color:var(--fg-2);}
.tb-spacer { flex:1; }
.tb-actions { display:flex; gap:6px; align-items:center; }
.tb-btn {
font-size:11.5px; color:var(--fg-2); background:transparent; border:1px solid transparent;
border-radius:6px; padding:4px 9px; cursor:pointer; display:flex; align-items:center; gap:6px;
}
.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; }
.workbench { flex:1; display:flex; min-height:0; }
.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; }
.col.right-col { background:var(--bg-1); }
.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:hover::after, .splitter.drag::after { background:var(--accent-line); }
.splitter.h { cursor:row-resize; flex:0 0 5px; width:100%; }
/* panel header */
.phead {
height:30px; flex:0 0 30px; display:flex; align-items:center; gap:8px;
padding:0 10px 0 12px; font-size:10.5px; letter-spacing:.09em; text-transform:uppercase;
color:var(--fg-2); border-bottom:1px solid var(--border); user-select:none;
}
.phead .ct { margin-left:auto; font-size:10px; color:var(--fg-3); letter-spacing:.02em; text-transform:none;
background:var(--bg-3); border:1px solid var(--border); border-radius:9px; padding:0 7px; line-height:16px; }
.phead .ico-btn { color:var(--fg-3); cursor:pointer; padding:2px; border-radius:4px; display:flex; }
.phead .ico-btn:hover { background:var(--hover); color:var(--fg-1); }
/* ============ git panel ============ */
.commit-box { padding:9px 10px; border-bottom:1px solid var(--border); display:flex; gap:7px; align-items:flex-start; }
.commit-input { flex:1; min-width:0; resize:none; background:var(--bg-0); border:1px solid var(--border-2); border-radius:7px; color:var(--fg-0); font-family:var(--ui); font-size:12px; line-height:16px; padding:7px 9px; outline:none; min-height:32px; }
.commit-input:focus { border-color:var(--accent-line); }
.commit-input::placeholder { color:var(--fg-3); }
.commit-btn { flex:0 0 auto; display:flex; align-items:center; gap:6px; background:var(--accent); color:#0c1320; border:0; border-radius:7px; font:inherit; font-size:12px; font-weight:600; padding:0 11px; height:32px; cursor:pointer; }
.commit-btn:hover:not(:disabled) { background:#5d97ff; }
.commit-btn:disabled { background:var(--bg-3); color:var(--fg-3); cursor:not-allowed; }
.git-body { overflow:auto; flex:1; padding:4px 0 10px; }
.git-group { padding:8px 12px 3px; font-size:10px; letter-spacing:.06em; text-transform:uppercase; color:var(--fg-3); display:flex; align-items:center; gap:6px; }
.git-group .gc { color:var(--fg-3); }
.git-group .grp-act { margin-left:auto; display:flex; opacity:0; background:transparent; border:0; color:var(--fg-2); padding:2px; border-radius:4px; cursor:pointer; }
.git-group:hover .grp-act { opacity:1; }
.git-group .grp-act:hover { background:var(--hover); color:var(--fg-0); }
.git-empty { display:flex; flex-direction:column; align-items:center; gap:9px; padding:30px 16px; color:var(--fg-3); font-size:12px; text-align:center; }
.git-empty svg { color:var(--add); opacity:.7; }
.git-none { padding:5px 14px 9px; font-size:11.5px; color:var(--fg-3); }
.git-none .key { font-family:var(--mono); font-size:11px; color:var(--fg-2); border:1px solid var(--border-2); border-radius:4px; padding:0 5px; }
.git-divider { height:1px; background:var(--border); margin:8px 12px 2px; }
.git-row {
display:flex; align-items:center; gap:8px; padding:3px 12px 3px 14px; cursor:pointer; position:relative;
}
.git-row:hover { background:var(--hover); }
.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-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-name { font-size:12.5px; color:var(--fg-1); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.git-row.active .git-name { color:var(--fg-0); }
.git-name.del { text-decoration:line-through; color:var(--fg-3); }
.git-dir { color:var(--fg-3); font-size:11px; margin-left:auto; padding-left:8px; white-space:nowrap; max-width:42%; overflow:hidden; text-overflow:ellipsis; direction:rtl; }
.git-act { flex:0 0 auto; display:none; align-items:center; justify-content:center; width:20px; height:20px; padding:0; background:transparent; border:0; border-radius:5px; color:var(--fg-2); cursor:pointer; margin-left:4px; }
.git-row:hover .git-act { display:flex; }
.git-act:hover { background:var(--active); color:var(--fg-0); }
.git-delta { font-family:var(--mono); font-size:10.5px; display:flex; gap:6px; flex:0 0 auto; }
.git-delta .a{color:var(--add);} .git-delta .d{color:var(--del);}
.git-foot { border-top:1px solid var(--border); padding:8px 12px; display:flex; align-items:center; gap:8px; font-size:11px; color:var(--fg-2); }
.branch-chip { display:flex; align-items:center; gap:6px; color:var(--fg-1); }
.branch-chip b { font-weight:600; color:var(--fg-0); }
/* ============ file tree ============ */
.tree-body { overflow:auto; flex:1; padding:4px 0 14px; }
.tree-row { display:flex; align-items:center; gap:6px; padding:2px 10px 2px 0; cursor:pointer; white-space:nowrap; position:relative; height:23px; }
.tree-row:hover { background:var(--hover); }
.tree-row.active { background:var(--sel); }
.tree-row.active::before { content:""; position:absolute; left:0; top:0; bottom:0; width:2px; background:var(--accent); }
.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-row.active .tree-label { color:var(--fg-0); }
.tree-row.folder .tree-label { color:var(--fg-1); }
.tree-badge { margin-left:auto; font-family:var(--mono); font-size:10px; font-weight:600; padding-left:8px; }
.tree-badge.M{color:var(--mod);} .tree-badge.A{color:var(--add);} .tree-badge.D{color:var(--del);}
/* file type monogram icon */
.ficon { width:15px; height:15px; flex:0 0 15px; border-radius:3.5px; display:inline-flex; align-items:center; justify-content:center;
font-family:var(--mono); font-size:7.5px; font-weight:700; color:#11131600; position:relative; }
.ficon span { color:#0c0d0f; font-size:7.5px; line-height:1; letter-spacing:-.3px; }
.folder-ic { width:15px; height:15px; flex:0 0 15px; display:inline-flex; align-items:center; justify-content:center; color:var(--fg-2); }
/* ============ editor ============ */
.tabs { height:35px; flex:0 0 35px; display:flex; align-items:stretch; background:var(--bg-3); border-bottom:1px solid var(--border); overflow-x:auto; overflow-y:hidden; }
.tabs::-webkit-scrollbar { height:0; }
.tab {
display:flex; align-items:center; gap:7px; padding:0 9px 0 13px; cursor:pointer;
border-right:1px solid var(--border); color:var(--fg-2); font-size:12.5px; white-space:nowrap;
background:var(--bg-3); position:relative; max-width:230px;
}
.tab:hover { background:#272b31; }
.tab.active { background:var(--bg-0); color:var(--fg-0); }
.tab.active::after { content:""; position:absolute; left:0; right:0; top:0; height:2px; background:var(--accent); }
.tab .tname { overflow:hidden; text-overflow:ellipsis; }
.tab.dirty .tname::after { content:" ●"; color:var(--mod); font-size:10px; }
.tab .tclose { width:17px; height:17px; border-radius:4px; display:flex; align-items:center; justify-content:center; color:var(--fg-3); flex:0 0 17px; }
.tab .tclose:hover { background:var(--active); color:var(--fg-0); }
.tab .tdot { display:none; width:7px; height:7px; border-radius:50%; background:var(--fg-2); }
.tab.dirtyclose .tclose { display:none; }
.tab.dirtyclose:hover .tclose { display:flex; }
.tab.dirtyclose:hover .tdot { display:none; }
.tab.dirtyclose .tdot { display:block; }
.tab-mode { margin-left:6px; font-size:9.5px; letter-spacing:.05em; text-transform:uppercase; color:var(--fg-3); border:1px solid var(--border-2); border-radius:4px; padding:0 5px; line-height:14px; }
.editor-wrap { flex:1; min-height:0; display:flex; flex-direction:column; position:relative; }
.editor { flex:1; overflow:auto; font-family:var(--mono); font-size:13px; line-height:20px; padding:6px 0 40px; }
.ln-row { display:flex; align-items:flex-start; min-height:20px; }
.ln-row.cursor { background:rgba(255,255,255,0.035); }
.ln-row.add { background:var(--add-bg); }
.ln-row.del { background:var(--del-bg); }
.ln-row.selrange { background:var(--accent-soft); }
.ln-gutter { flex:0 0 54px; width:54px; text-align:right; padding-right:14px; color:var(--fg-3); user-select:none; cursor:pointer; font-size:12px; }
.ln-row.cursor .ln-gutter { color:var(--fg-1); }
.ln-gutter:hover { color:var(--fg-1); }
.ln-sign { flex:0 0 14px; width:14px; text-align:center; user-select:none; color:var(--fg-3); }
.ln-row.add .ln-sign { color:var(--add); }
.ln-row.del .ln-sign { color:var(--del); }
.ln-code { flex:1; white-space:pre; padding:0 16px 0 6px; min-width:0; }
.editor.diff .ln-code { padding-left:6px; }
.empty-ed { flex:1; display:flex; flex-direction:column; align-items:center; justify-content:center; color:var(--fg-3); gap:14px; }
.empty-ed .big { font-size:13px; }
.empty-ed kbd { font-family:var(--mono); font-size:11px; color:var(--fg-2); border:1px solid var(--border-2); border-radius:5px; padding:2px 7px; }
.empty-ed .klist { display:flex; flex-direction:column; gap:9px; font-size:12px; }
.empty-ed .klist div { display:flex; gap:10px; align-items:center; justify-content:space-between; min-width:230px; }
.diff-bar { height:26px; flex:0 0 26px; display:flex; align-items:center; gap:12px; padding:0 14px; background:var(--bg-3); border-bottom:1px solid var(--border); font-size:11px; color:var(--fg-2); }
.diff-bar .a{color:var(--add);font-family:var(--mono);} .diff-bar .d{color:var(--del);font-family:var(--mono);}
.diff-bar .toggle { margin-left:auto; display:flex; border:1px solid var(--border-2); border-radius:6px; overflow:hidden; }
.diff-bar .toggle button { background:transparent; border:0; color:var(--fg-2); font:inherit; font-size:11px; padding:2px 10px; cursor:pointer; }
.diff-bar .toggle button.on { background:var(--accent-soft); color:var(--fg-0); }
/* four-segment view control */
.diff-bar .seg { margin-left:auto; display:flex; border:1px solid var(--border-2); border-radius:7px; overflow:hidden; }
.diff-bar .seg button { background:transparent; border:0; border-right:1px solid var(--border-2); color:var(--fg-2); font:inherit; font-size:11px; padding:3px 12px; cursor:pointer; display:flex; align-items:center; gap:6px; }
.diff-bar .seg button:last-child { border-right:0; }
.diff-bar .seg button:hover { color:var(--fg-0); background:var(--hover); }
.diff-bar .seg button.on { background:var(--accent-soft); color:var(--fg-0); }
.diff-bar .seg .split-btn svg { opacity:.85; }
/* gutter change bars (Original / Updated / Split) */
.ln-row.bar-del { box-shadow:inset 2px 0 0 var(--del); }
.ln-row.bar-add { box-shadow:inset 2px 0 0 var(--add); }
.ln-row.empty { background:repeating-linear-gradient(45deg, rgba(255,255,255,0.015) 0 7px, transparent 7px 14px); }
/* full-screen split */
.split-overlay { position:fixed; inset:0; z-index:60; background:var(--bg-0); display:flex; flex-direction:column; animation:tin .12s ease-out; }
.split-head { height:42px; flex:0 0 42px; display:flex; align-items:center; gap:11px; padding:0 16px; background:var(--bg-3); border-bottom:1px solid var(--border); }
.split-head .sh-name { font-family:var(--mono); font-size:13px; color:var(--fg-0); }
.split-head .git-stat { font-size:11px; }
.split-exit { margin-left:auto; display:flex; align-items:center; gap:7px; background:transparent; border:1px solid var(--border-2); border-radius:7px; color:var(--fg-1); font:inherit; font-size:12px; padding:5px 11px; cursor:pointer; }
.split-exit:hover { background:var(--hover); color:var(--fg-0); }
.split-exit kbd { font-family:var(--mono); font-size:10px; color:var(--fg-3); border:1px solid var(--border-2); border-radius:4px; padding:1px 5px; }
.split-body { flex:1; display:flex; min-height:0; }
.split-pane { flex:1; min-width:0; display:flex; flex-direction:column; }
.split-pane.left { border-right:1px solid var(--border-2); }
.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);}
/* ============ terminals (right column) ============ */
.term-pane { display:flex; flex-direction:column; min-height:0; background:var(--bg-1); }
.term-head { height:28px; flex:0 0 28px; display:flex; align-items:center; gap:8px; padding:0 10px; background:var(--bg-3); border-bottom:1px solid var(--border); font-size:11px; color:var(--fg-2); user-select:none; }
.term-head .dot { width:7px; height:7px; border-radius:50%; background:var(--fg-3); }
.term-head .dot.live { background:var(--add); box-shadow:0 0 0 0 rgba(92,189,107,.5); animation:pulse 2.2s infinite; }
@keyframes pulse { 0%{box-shadow:0 0 0 0 rgba(92,189,107,.45);} 70%{box-shadow:0 0 0 5px rgba(92,189,107,0);} 100%{box-shadow:0 0 0 0 rgba(92,189,107,0);} }
.term-head .lbl { color:var(--fg-1); font-family:var(--mono); }
.term-head .tag { margin-left:auto; font-size:10px; color:var(--fg-3); font-family:var(--mono); }
.term-body { flex:1; overflow:auto; padding:8px 12px 12px; font-family:var(--mono); font-size:12.5px; line-height:18px; cursor:text; }
.tline { white-space:pre-wrap; word-break:break-word; }
.tline.dim{color:var(--fg-3);} .tline.acc{color:var(--accent);} .tline.ok{color:var(--add);} .tline.warn{color:var(--mod);} .tline.err{color:var(--del);}
.tline .pfx { color:var(--accent); }
.tline .ag { color:#c98bdb; }
.tline .tool { color:var(--mod); }
.tline .fp { color:var(--t-prop); }
.term-card { border:1px solid var(--border-2); border-radius:7px; padding:7px 10px; margin:5px 0; background:rgba(255,255,255,0.02); }
.term-card .ch { color:var(--fg-2); font-size:11px; margin-bottom:4px; display:flex; gap:7px; align-items:center; min-width:0; }
.term-card .ch span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.term-card .add,.term-card .del { white-space:pre-wrap; word-break:break-word; line-height:17px; }
.term-card .add{color:var(--add);} .term-card .del{color:var(--del);}
.term-input { display:flex; align-items:center; gap:8px; }
.term-input .ip { color:var(--accent); }
.term-input .ip.ag { color:#c98bdb; }
.term-input input { flex:1; background:transparent; border:0; outline:0; color:var(--fg-0); font-family:var(--mono); font-size:12.5px; caret-color:var(--accent); }
.cursor-blink { display:inline-block; width:7px; height:14px; background:var(--accent); margin-left:1px; animation:blink 1.1s step-end infinite; vertical-align:-2px; }
@keyframes blink { 50%{opacity:0;} }
/* ============ overlays ============ */
.scrim { position:fixed; inset:0; background:rgba(8,9,11,0.5); z-index:50; display:flex; justify-content:center; align-items:flex-start; padding-top:90px; backdrop-filter:blur(1.5px); }
.palette { width:620px; max-width:92vw; background:#212429; border:1px solid var(--border-2); border-radius:11px; box-shadow:0 24px 70px rgba(0,0,0,.55); overflow:hidden; }
.palette .pi { display:flex; align-items:center; gap:10px; padding:12px 15px; border-bottom:1px solid var(--border); }
.palette .pi input { flex:1; background:transparent; border:0; outline:0; color:var(--fg-0); font-size:15px; font-family:var(--ui); }
.palette .pi .mode-chip { font-size:10px; color:var(--fg-3); border:1px solid var(--border-2); border-radius:5px; padding:2px 7px; font-family:var(--mono); }
.palette .results { max-height:380px; overflow:auto; padding:6px; }
.pres { display:flex; align-items:center; gap:10px; padding:7px 10px; border-radius:7px; cursor:pointer; }
.pres.sel { background:var(--accent-soft); }
.pres .pn { font-size:13px; color:var(--fg-0); }
.pres .pn b { color:var(--accent); font-weight:700; }
.pres .pp { font-size:11px; color:var(--fg-3); margin-left:auto; font-family:var(--mono); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:55%; direction:rtl; }
.pempty { padding:26px; text-align:center; color:var(--fg-3); font-size:12.5px; }
/* combined search modal (content + files) */
.search-modal { width:940px; max-width:94vw; background:#212429; border:1px solid var(--border-2); border-radius:11px; box-shadow:0 24px 70px rgba(0,0,0,.55); overflow:hidden; display:flex; flex-direction:column; }
.search-cols { display:flex; min-height:0; }
.sc-left { flex:1 1 auto; min-width:0; max-height:460px; overflow:auto; border-right:1px solid var(--border); padding-bottom:8px; }
.sc-right { flex:0 0 256px; min-width:0; max-height:460px; overflow:auto; padding-bottom:8px; background:rgba(0,0,0,0.12); }
.sc-head { position:sticky; top:0; z-index:1; background:#212429; padding:9px 14px 6px; font-size:10px; letter-spacing:.07em; text-transform:uppercase; color:var(--fg-3); display:flex; align-items:center; gap:7px; }
.sc-right .sc-head { background:#1e2024; }
.sc-head .sc-ct { color:var(--fg-2); background:var(--bg-3); border:1px solid var(--border); border-radius:9px; padding:0 7px; line-height:15px; font-size:10px; }
.srf-name { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.pempty.sm { padding:18px 14px; text-align:left; font-size:11.5px; }
.fres { display:flex; align-items:center; gap:9px; padding:6px 12px; cursor:pointer; }
.fres:hover { background:var(--hover); }
.fres-txt { min-width:0; display:flex; flex-direction:column; line-height:1.25; }
.fres-txt .fn { font-size:12.5px; color:var(--fg-0); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.fres-txt .fn b { color:var(--accent); font-weight:700; }
.fres-txt .fd { font-size:10.5px; color:var(--fg-3); font-family:var(--mono); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
/* content search */
.search-results { max-height:420px; overflow:auto; padding:4px 0 8px; }
.sr-file { padding:7px 14px 3px; font-size:11.5px; color:var(--fg-2); display:flex; align-items:center; gap:8px; cursor:pointer; }
.sr-file:hover { color:var(--fg-0); }
.sr-file .cnt { margin-left:auto; color:var(--fg-3); font-family:var(--mono); font-size:10.5px; }
.sr-line { display:flex; gap:12px; padding:2px 14px 2px 38px; font-family:var(--mono); font-size:12px; cursor:pointer; color:var(--fg-1); }
.sr-line:hover { background:var(--hover); }
.sr-line .no { color:var(--fg-3); min-width:34px; text-align:right; }
.sr-line .tx { white-space:pre; overflow:hidden; text-overflow:ellipsis; }
.sr-line mark { background:rgba(216,168,92,.28); color:var(--fg-0); border-radius:2px; }
/* pass-on-to-agent inline popup */
.pass-pop { position:fixed; z-index:85; width:344px; max-width:92vw; background:#23272d; border:1px solid var(--border-2); border-radius:10px; box-shadow:0 18px 48px rgba(0,0,0,.55); padding:11px; animation:popin .12s ease-out; }
@keyframes popin { from { transform:translateY(6px); } }
.pass-head { display:flex; align-items:center; gap:8px; font-size:12px; color:var(--fg-1); margin-bottom:9px; }
.pass-head svg { color:#c98bdb; }
.pass-head .pass-esc { margin-left:auto; font-family:var(--mono); font-size:10px; color:var(--fg-3); border:1px solid var(--border-2); border-radius:4px; padding:1px 6px; }
.pass-input { width:100%; box-sizing:border-box; background:var(--bg-0); border:1px solid var(--border-2); border-radius:7px; color:var(--fg-0); font-family:var(--ui); font-size:13px; padding:8px 10px; outline:none; }
.pass-input:focus { border-color:var(--accent-line); }
.pass-input::placeholder { color:var(--fg-3); }
.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 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-foot { margin-top:9px; font-size:10.5px; color:var(--fg-3); }
.pass-foot kbd { font-family:var(--mono); font-size:10px; color:var(--fg-2); border:1px solid var(--border-2); border-radius:4px; padding:0 5px; }
/* terminal multi-line input */
.term-input { align-items:flex-start; }
.term-ta { flex:1; background:transparent; border:0; outline:0; color:var(--fg-0); font-family:var(--mono); font-size:12.5px; line-height:18px; caret-color:var(--accent); resize:none; padding:0; margin:0; overflow:hidden; }
.term-ta::placeholder { color:var(--fg-3); }
/* 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-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 .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 .ic { color:var(--accent); }
.ctx-item .ic { width:15px; display:flex; justify-content:center; color:var(--fg-3); }
.ctx-sep { height:1px; background:var(--border); margin:5px 6px; }
.ctx-note { padding:4px 11px 7px; font-size:10.5px; color:var(--fg-3); font-family:var(--mono); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
/* toast */
.toast-wrap { position:fixed; bottom:34px; left:50%; transform:translateX(-50%); z-index:90; display:flex; flex-direction:column; gap:8px; align-items:center; }
.toast { background:#23272d; border:1px solid var(--border-2); border-left:3px solid var(--accent); border-radius:9px; padding:9px 14px; box-shadow:0 12px 34px rgba(0,0,0,.45); display:flex; align-items:center; gap:11px; animation:tin .18s ease-out; }
@keyframes tin { from{opacity:0; transform:translateY(8px);} }
.toast .tt { font-size:12.5px; color:var(--fg-0); }
.toast .tref { font-family:var(--mono); font-size:11.5px; color:var(--accent); background:var(--accent-soft); border-radius:5px; padding:2px 8px; }
/* ============ status bar ============ */
.statusbar { height:23px; flex:0 0 23px; display:flex; align-items:center; gap:0; background:var(--bg-3); border-top:1px solid var(--border); font-size:11px; color:var(--fg-2); user-select:none; }
.sb { display:flex; align-items:center; gap:6px; padding:0 11px; height:100%; }
.sb:hover { background:var(--hover); }
.sb.accent { background:var(--accent); color:#0c1320; }
.sb.accent:hover { background:#5d97ff; }
.sb.spacer { flex:1; }
.sb .a{color:var(--add);} .sb .d{color:var(--del);}
.sb b { font-weight:600; color:var(--fg-1); }