From c7665e4b6f76696a574de940c2841dad713450d7 Mon Sep 17 00:00:00 2001 From: Juan Barbat Date: Fri, 30 Oct 2026 10:10:40 -0300 Subject: [PATCH 1/5] chore(npm): install global packages under ~/.local Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01A9D7ph123Jn8iB1eGbr8Nz --- npm/npmrc | 2 ++ 1 file changed, 2 insertions(+) diff --git a/npm/npmrc b/npm/npmrc index 97b895e..19e1396 100644 --- a/npm/npmrc +++ b/npm/npmrc @@ -1 +1,3 @@ ignore-scripts=true + +prefix=${HOME}/.local From 42714168d9461d3d5e3ca2e6e030b8a5f55aaba2 Mon Sep 17 00:00:00 2001 From: Juan Barbat Date: Fri, 30 Oct 2026 10:36:35 -0300 Subject: [PATCH 2/5] feat(herdr): install Herdr Desktop settings, theme link and Inter UI font Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01A9D7ph123Jn8iB1eGbr8Nz --- herdr/desktop/settings.json | 12 ++++++++++++ install.sh | 14 ++++++++++++++ 2 files changed, 26 insertions(+) create mode 100644 herdr/desktop/settings.json diff --git a/herdr/desktop/settings.json b/herdr/desktop/settings.json new file mode 100644 index 0000000..a551d24 --- /dev/null +++ b/herdr/desktop/settings.json @@ -0,0 +1,12 @@ +{ + "theme": "refactoria", + "font": "FiraCode Nerd Font Mono Beard", + "font_size": 14, + "ui_font": "Inter", + "telemetry": false, + "minimum_contrast": 1.0, + "sounds": { + "enabled": true + }, + "zoom": 1.0 +} diff --git a/install.sh b/install.sh index 32bdff5..6fefec6 100755 --- a/install.sh +++ b/install.sh @@ -31,6 +31,7 @@ MANAGED_LINKS=( "pi/themes/barbatdev.json|$HOME/.pi/agent/themes/barbatdev.json|all" "pi/themes/refactoria.json|$HOME/.pi/agent/themes/refactoria.json|all" "ghostty/config|$HOME/.config/ghostty/config|all" + "herdr/desktop/themes/refactoria.json|$HOME/.config/herdr-app/themes/refactoria.json|darwin" "ghostty/shaders|$HOME/.config/ghostty/shaders|all" "ghostty/profiles/barbatdev.conf|$HOME/.config/ghostty/profiles/barbatdev.conf|darwin" "ghostty/profiles/refactoria.conf|$HOME/.config/ghostty/profiles/refactoria.conf|darwin" @@ -429,6 +430,15 @@ if [[ "$PLATFORM" == "Darwin" ]]; then install_formula_if_missing sketchybar sketchybar "barra macOS" install_cask_if_missing "/Applications/AeroSpace.app" aerospace nikitabobko/tap + # Inter: the RefactorIA UI sans, used by Herdr Desktop panels (ui_font). + if ls "$HOME/Library/Fonts"/Inter-Regular.* &>/dev/null; then + echo " ✓ font-inter ya instalado" + elif [[ "$DRY_RUN" == true ]]; then + echo " → Would install font-inter via Homebrew cask" + else + brew install --cask font-inter + fi + if ! karabiner_cli_available && ! karabiner_app_exists; then if [[ "$DRY_RUN" == true ]]; then echo " → Would install karabiner-elements via Homebrew cask" @@ -547,6 +557,10 @@ done < <(manifest_entries) # shellcheck source=scripts/seed-file.sh source "$DOTFILES/scripts/seed-file.sh" seed_file "$DOTFILES/opencode/tui.json" "$HOME/.config/opencode/tui.json" +# Herdr Desktop rewrites settings.json (theme picker, zoom keys): seed a copy. +if [[ "$PLATFORM" == "Darwin" ]]; then + seed_file "$DOTFILES/herdr/desktop/settings.json" "$HOME/.config/herdr-app/settings.json" +fi run_or_plan "chmod +x $DOTFILES/claude/statusline.sh" chmod +x "$DOTFILES/claude/statusline.sh" run_or_plan "chmod +x agent-state CLI" chmod +x "$DOTFILES/zsh/scripts/agent-state.sh" if [[ "$PLATFORM" == "Darwin" ]]; then From 3880ed9cef9bacc6a7a1891e52176c2e9969555d Mon Sep 17 00:00:00 2001 From: Juan Barbat Date: Fri, 30 Oct 2026 10:40:00 -0300 Subject: [PATCH 3/5] feat(herdr): add RefactorIA layout with machine group cards and unified text sizes Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01A9D7ph123Jn8iB1eGbr8Nz --- herdr/desktop/layouts/refactoria.json | 187 ++++++++ herdr/desktop/mods/refactoria-agents/main.js | 448 ++++++++++++++++++ herdr/desktop/mods/refactoria-agents/mod.json | 8 + herdr/desktop/mods/refactoria-spaces/main.js | 364 ++++++++++++++ herdr/desktop/mods/refactoria-spaces/mod.json | 8 + install.sh | 5 + 6 files changed, 1020 insertions(+) create mode 100644 herdr/desktop/layouts/refactoria.json create mode 100644 herdr/desktop/mods/refactoria-agents/main.js create mode 100644 herdr/desktop/mods/refactoria-agents/mod.json create mode 100644 herdr/desktop/mods/refactoria-spaces/main.js create mode 100644 herdr/desktop/mods/refactoria-spaces/mod.json diff --git a/herdr/desktop/layouts/refactoria.json b/herdr/desktop/layouts/refactoria.json new file mode 100644 index 0000000..39a3514 --- /dev/null +++ b/herdr/desktop/layouts/refactoria.json @@ -0,0 +1,187 @@ +{ + "id": "root", + "cloned_from": { + "name": "main", + "version": "0.1.9" + }, + "type": "row", + "children": [ + { + "id": "sidebar", + "type": "column", + "children": [ + { + "id": "brand", + "type": "block", + "content": { + "mod": "workspace-chrome" + }, + "size": { + "px": 46 + }, + "window_drag": true + }, + { + "id": "sidebar-lists", + "type": "column", + "children": [ + { + "id": "spaces", + "type": "block", + "content": { + "mod": "refactoria-spaces" + }, + "size": { + "weight": 1 + }, + "min": 90 + }, + { + "id": "agents", + "type": "block", + "content": { + "mod": "refactoria-agents" + }, + "size": { + "weight": 1 + }, + "min": 90 + } + ] + }, + { + "id": "sidebar-footer", + "type": "block", + "content": { + "mod": "workspace-chrome", + "props": { + "footer": true + } + }, + "size": { + "px": 69 + } + } + ], + "size": { + "px": 268 + }, + "min": 44, + "max": 480, + "background": "chrome", + "resizable": false + }, + { + "id": "work", + "type": "column", + "children": [ + { + "id": "tabs", + "type": "block", + "content": { + "mod": "tabs" + }, + "size": { + "px": 42 + }, + "window_drag": true + }, + { + "id": "work-body", + "type": "row", + "children": [ + { + "id": "main", + "type": "block", + "content": { + "tab": "active", + "pane": { + "header": { + "content": { + "mod": "pane" + }, + "reveal": "always", + "height": 30, + "position": "top" + }, + "background": "background", + "border": { + "width": 1, + "color": "border" + }, + "focus": { + "width": 1, + "color": "accent" + }, + "radius": 10, + "gap": 8, + "lone": true + } + } + }, + { + "id": "panel", + "type": "column", + "children": [ + { + "id": "panel-body", + "type": "stack", + "children": [ + { + "id": "files", + "type": "block", + "content": { + "mod": "files" + } + }, + { + "id": "changes", + "type": "block", + "content": { + "mod": "changes" + } + } + ], + "active": "files" + } + ], + "size": { + "px": 368 + }, + "min": 280, + "max": 900, + "hidden": true + } + ], + "padding": 8, + "gap": 8 + }, + { + "id": "status", + "type": "block", + "content": { + "mod": "workspace-status" + }, + "size": { + "px": 28 + } + } + ], + "resizable": false + } + ], + "background": "canvas", + "terminal_padding": [ + 14, + 12 + ], + "divider": { + "width": 1, + "color": "border" + }, + "titlebar": "none", + "traffic_lights": [ + 12, + 16 + ] +} diff --git a/herdr/desktop/mods/refactoria-agents/main.js b/herdr/desktop/mods/refactoria-agents/main.js new file mode 100644 index 0000000..5fd06e7 --- /dev/null +++ b/herdr/desktop/mods/refactoria-agents/main.js @@ -0,0 +1,448 @@ +// Agents in grouped or priority views; parents take their most urgent sub-agent's place. +// A viewed done agent stays in Done until left. + +import { View, div, image, svg } from "gpui-kit"; +import { v_flex, h_flex } from "gpui-base"; +import { agents, terminals, panes, focused, spaces, run, theme, shared, setShared, setAgentOrder, openOverlay, closeOverlay, props, window, layout } from "herdr"; + +function find(node, id) { + if (node?.id === id) return node; + for (const child of node?.children ?? []) { + const found = find(child, id); + if (found) return found; + } + return null; +} + +// The sidebar's rail goes by its width, not a flag: dragging its edge narrows it too. +const narrow = () => (find(layout(), "sidebar")?.size?.px ?? Infinity) < 160; + +// To the rail (on macOS wide enough for the traffic lights), or back to the width it had. +function toggleSidebar() { + const width = find(layout(), "sidebar")?.size?.px; + if (!narrow()) setShared("sidebar.width", width); + // Mods edited before the rail went by width still read the flag. + setShared("sidebar.collapsed", !narrow()); + run("layout.resize", { id: "sidebar", px: narrow() ? (shared("sidebar.width") ?? 268) : window()?.platform === "macos" ? 76 : 44 }); +} + +const clean = (s) => (s ?? "").replace(/^[^\p{L}\p{N}]+/u, "").trim(); + +function lead(tab, all = panes()) { + const mine = all.filter(p => p.tab === tab.id && p.terminal); + return mine.find(p => p.program?.agent) ?? mine.find(p => p.focused) ?? mine[0]; +} + +function title(tab, l) { + const prog = l?.program?.name; + const live = clean(l?.title); + const telling = live && live !== prog && !/^[~/]|^[A-Za-z]:\\/.test(live) && !live.includes("@") && live.toLowerCase() !== l?.program?.key; + return tab.name === prog && telling ? live : tab.name; +} +const RANK = { blocked: 4, done: 3, working: 2, idle: 1 }; +// A sub-agent without a pane isn't in agents(): its raw word, error counting as +// blocked. It's never done: it reports to its parent, and the parent's done is the news. +const kidStatus = (k, listed) => { + const s = listed.get(k.address) ?? k.status; + return s === "error" ? "blocked" : s === "done" ? "idle" : s; +}; +const SORTS = [ + { id: "priority", label: "Priority", icon: "clock" }, + { id: "grouped", label: "Grouped by space", icon: "layers" }, +]; +const sortOf = () => SORTS.find((s) => s.id === shared("agents.sort")) ?? SORTS[0]; +const SECTION = { 4: "Needs you", 3: "Done", 2: "Working", 1: "Idle" }; +const SECTION_STATUS = { 4: "blocked", 3: "done", 2: "working" }; + +function duration(ms) { + const m = Math.floor(ms / 60000); + if (m < 1) return "<1m"; + if (m < 60) return `${m}m`; + const h = Math.floor(m / 60); + return h < 24 ? `${h}h` : `${Math.floor(h / 24)}d`; +} + +// The agent's icon from the app's program table: a one-color glyph or a picture. +function icon(p, size, color) { + if (!p?.icon) return null; + return p.tint ? svg(p.icon).size(size).flex_none().text_color(color) : image(p.icon).w(size).h(size).flex_none(); +} + +export default class Agents extends View { + init(_props, cx) { + if (props()?.sortMenu) return; + this.folded = new Set(); + // Each agent's last state and when it changed (ms), kept across restarts. + this.changed = JSON.parse(localStorage.getItem("changed") ?? "{}"); + this.pins = JSON.parse(localStorage.getItem("pins") ?? "[]"); + cx.timer.every(30000, (cx) => cx.notify()); + } + + // Stamps agents whose state changed. Done and idle count as one: looking at a + // finished agent turns done into idle, and looking isn't the agent doing anything. + track(list, now) { + let dirty = false; + for (const a of list) { + const s = a.status === "done" ? "idle" : a.status; + if (this.changed[a.address]?.s === s) continue; + this.changed[a.address] = { s, at: now }; + dirty = true; + } + if (!dirty) return; + // Gone and old: the list alone is empty right after a start. + const listed = new Set(list.map((a) => a.address)); + for (const [k, c] of Object.entries(this.changed)) if (!listed.has(k) && now - c.at > 30 * 86400000) delete this.changed[k]; + localStorage.setItem("changed", JSON.stringify(this.changed)); + } + + setPins(pins) { + this.pins = pins; + localStorage.setItem("pins", JSON.stringify(pins)); + } + + // Drops pins of agents that ended. Only for machines the list has heard from: + // right after a start, or while a machine connects, its agents aren't listed yet. + prunePins(all) { + const machine = (addr) => (addr.includes("/") ? addr.split("/")[0] : ""); + const heard = new Set(all.map((k) => machine(k.address))); + const live = new Set(all.filter((k) => k.state === "live").map((k) => k.address)); + const kept = this.pins.filter((p) => !heard.has(machine(p)) || live.has(p)); + if (kept.length !== this.pins.length) this.setPins(kept); + } + + since(a) { + return this.changed[a.address]?.at ?? 0; + } + + // Looking at a done agent makes it idle; it stays with Done until you leave + // it rather than drop away under you. Any other change moves it at once. + hold(list) { + const cur = list.find((a) => a.focused); + const same = cur && cur.address === this.held?.address; + // Arriving, or any change but done turning idle, sets what it was. + if (!same || cur.status !== "idle") this.held = cur ? { address: cur.address, rank: RANK[cur.status] ?? 0 } : null; + } + + rank(a) { + const sawDone = a.address === this.held?.address && this.held.rank === RANK.done && a.status === "idle"; + return sawDone ? RANK.done : RANK[a.status] ?? 0; + } + + sortMenu(t) { + const on = sortOf().id; + return v_flex() + .w_full() + .p(5) + .bg(t.popover) + .text_size(13) + .child(div().px(8).pt(4).pb(6).text_size(12).text_color(t.dim).child("Sort agents")) + .children(SORTS.map((s) => h_flex() + .id(`sort-${s.id}`) + .h(31) + .px(8) + .gap(9) + .rounded(7) + .items_center() + .text_color(t.foreground) + .hover((el) => el.bg(t.hover)) + .on_click(() => { + setShared("agents.sort", s.id); + closeOverlay(props().overlay); + }) + .child(svg(`mods/workspace-chrome/${s.icon}.svg`).size(14).flex_none().text_color(t.muted)) + .child(div().flex_1().child(s.label)) + .when(s.id === on, (el) => el.child(svg("mods/workspace-chrome/check.svg").size(14).flex_none().text_color(t.foreground))))); + } + + peek(addr) { + run("terminal.peek", { terminal: addr }); + } + + // Live sub-agents of `addr`, depth first, each with the tree lines in front of it. + tree(all, addr, lead = []) { + const kids = all.filter((k) => k.parent === addr && k.state === "live"); + return kids.flatMap((k, i) => { + const last = i === kids.length - 1; + return [ + { t: k, lines: [...lead, last ? "└" : "├"] }, + ...this.tree(all, k.address, [...lead, last ? "" : "│"]), + ]; + }); + } + + subRow(t, s, shown, compact) { + const k = s.t; + const st = kidStatus(k, this.listed); + const pic = icon(k.program, 14, t.foreground); + const on = k.address === shown; + return h_flex() + .id(`sub-${k.address}`) + .pl(39) + .pr(8) + .rounded(8) + .h(26) + .flex_none() + .gap(0) + .items_center() + .text_size(13) + .when(on, (el) => el.bg(t.raised)) + .hover((s) => s.bg(t.hover)) + .on_click(() => this.peek(k.address)) + .children(s.lines.map(l => div().self_start().mt(-4).w(9).h(17).mr(4).flex_none() + .when(Boolean(l), el => el.border_l(1.5).border_color(t.input)) + .when(l === "└" || l === "├", el => el.border_b(1.5).rounded_bl(6)))) + .when(Boolean(pic), el => el.child(div().mr(8).child(pic))) + .child(div().flex_1().truncate().text_color(on ? t.foreground : t.muted).child(k.name || k.template || k.program?.name || k.address)) + .child(this.status(t, { status: st }, compact)); + } + + // compact: a ! for blocked where a header already says "Needs you". + status(t, a, compact = false) { + const color = t[a.status] ?? t.dim; + if (a.status === "working") return svg("mods/workspace-chrome/spinner.svg").size(12).mx(1).flex_none().text_color(t.working).spin(900); + if (a.status === "blocked" && compact) return div().size(14).flex_none().rounded(4).bg(t.blocked + "26").text_size(10.5).font_bold().text_color(t.blocked).flex().items_center().justify_center().child("!"); + if (a.status === "blocked") return div().flex_none().px(6).h(18).rounded(5).bg(t.blocked + "26").text_size(10.5).font_semibold().text_color(t.blocked).flex().items_center().child("Needs you"); + // Idle: a ring; done: a dot. + if (a.status === "idle") return div().size(7).mx(3).flex_none().rounded(4).border(1.5).border_color(t.idle ?? t.dim); + return div().size(7).mx(3).flex_none().rounded(4).bg(a.status === "done" ? color : t.dim + "00"); + } + + render(cx) { + const t = theme(); + if (props()?.sortMenu) return this.sortMenu(t); + const collapsed = narrow(); + const sort = sortOf(); + let list = agents(); + this.listed = new Map(list.map((a) => [a.address, a.status])); + const all = terminals(); + const shown = focused()?.agent; + const now = Date.now(); + this.track(list, now); + this.hold(list); + const byPriority = sort.id === "priority"; + const urgency = new Map(list.map((a) => [a.address, Math.max(this.rank(a), ...this.tree(all, a.address).map((s) => RANK[kidStatus(s.t, this.listed)] ?? 0))])); + const rankOf = (a) => urgency.get(a.address); + if (byPriority) list = [...list].sort((x, y) => rankOf(y) - rankOf(x) || this.since(y) - this.since(x) || y.seq - x.seq); + this.prunePins(all); + const pinned = this.pins.map((p) => list.find((a) => a.address === p)).filter(Boolean); + const rest = list.filter((a) => !this.pins.includes(a.address)); + list = [...pinned, ...rest]; + setAgentOrder(list.map((a) => a.address)); + + const withKids = (a, i) => { + const subs = collapsed ? [] : this.tree(all, a.address); + const r = row(a, i, subs); + if (this.folded.has(a.address)) return [r]; + return [r, ...subs.map((s) => this.subRow(t, s, shown, byPriority))]; + }; + + // The fold chevron; folded, the count too, colored by the most urgent one. + const fold = (a, subs) => { + const sts = subs.map((s) => kidStatus(s.t, this.listed)); + const top = sts.reduce((m, s) => ((RANK[s] ?? 0) > (RANK[m] ?? 0) ? s : m), null); + const open = !this.folded.has(a.address); + return div() + .id(`fold-${a.address}`) + .h(18) + .px(3) + .gap(3) + .flex() + .items_center() + .rounded(4) + .text_size(12) + .text_color(open ? t.dim : (t[top] ?? t.dim)) + .cursor_pointer() + .hover((s) => s.bg(t.hover).text_color(t.foreground)) + .on_click((_e, cx) => { + cx.stop_propagation(); + open ? this.folded.add(a.address) : this.folded.delete(a.address); + cx.notify(); + }) + .child(svg(`mods/workspace-chrome/chevron-${open ? "down" : "right"}.svg`).size(11).flex_none()) + .when(!open, (el) => el.child(String(subs.length))); + }; + + // A row reads "space \u00b7 detail": the detail is a tab someone named or the + // agent's own title, never an automatic tab number unless two rows would + // otherwise read the same. + const tabs = spaces().flatMap(s => s.tabs); + const label = (a) => { + const tab = tabs.find(tab => tab.id === a.tab); + return tab ? title(tab, lead(tab)) : a.title || a.program?.name || ""; + }; + const detail = new Map(list.map(a => [a.address, /^\d+$/.test(label(a)) ? null : label(a)])); + const seen = {}; + for (const a of list) { const k = `${a.space_name}\u0000${detail.get(a.address) ?? ""}`; seen[k] = (seen[k] ?? 0) + 1; } + for (const a of list) { + if (detail.get(a.address) == null && seen[`${a.space_name}\u0000`] > 1) detail.set(a.address, label(a)); + } + + const row = (a, i, subs = []) => { + const pic = icon(a.program, 16, t.foreground); + const more = detail.get(a.address); + // An agent on another machine (`vps/t7`) says which. + const machine = a.address.includes("/") ? a.address.split("/")[0] : null; + if (collapsed) { + return h_flex() + .id(`ag-${a.address}`) + .h(26) + .px(8) + .flex_none() + .items_center() + .gap(4) + .hover((s) => s.bg(t.hover)) + .on_click(() => run("pane.focus", { pane: a.pane })) + .child(div().flex_1().min_w(0).whitespace_nowrap().text_right().text_color(t.muted).child(String(i + 1))) + .child(h_flex().w(14).flex_none().items_center().justify_center().child(this.status(t, a, true))); + } + const when = this.since(a); + const idle = a.status === "idle" && !a.focused; + const isPinned = this.pins.includes(a.address); + const pin = div() + .id(`pin-${a.address}`) + .flex_none() + .p(2) + .rounded(4) + .text_color(t.dim) + .cursor_pointer() + .hover((s) => s.bg(t.hover).text_color(t.foreground)) + .tooltip(isPinned ? "Unpin" : "Pin to the top") + .on_click((_e, cx) => { + cx.stop_propagation(); + this.setPins(isPinned ? this.pins.filter((p) => p !== a.address) : [...this.pins, a.address]); + // The row moves away from under the pointer, and no hover-out comes. + this.hovered = null; + cx.notify(); + }) + .child(svg(`mods/workspace-chrome/${isPinned ? "pin-off" : "pin"}.svg`).size(13)); + // Under a space header the space goes without saying; an automatic tab number says nothing. + // Without a space header above (priority, pinned) the row names its space. + const full = byPriority || isPinned; + const name = full ? a.space_name || label(a) : more ?? (/^\d+$/.test(label(a)) ? a.program?.name || label(a) : label(a)); + return h_flex() + .id(`ag-${a.address}`) + .px(8) + .h(30) + .flex_none() + .rounded(8) + .items_center() + .gap(9) + .when(a.focused, (el) => el.bg(t.selected)) + .cursor_pointer() + .hover((s) => s.bg(t.hover)) + .on_hover((on, cx) => { + if (on) this.hovered = a.address; + else if (this.hovered === a.address) this.hovered = null; + cx.notify(); + }) + .on_click(() => run("pane.focus", { pane: a.pane })) + .when(Boolean(pic), el => el.child(div().id(`agent-fold-${a.address}`) + .when(subs.length > 0, el => el.on_click((_e, cx) => { + cx.stop_propagation(); + this.folded.has(a.address) ? this.folded.delete(a.address) : this.folded.add(a.address); + cx.notify(); + })).child(pic))) + .child(h_flex().flex_1().min_w(0).gap(5).items_center() + .child(div().flex_none().max_w(full && more ? 120 : 200).truncate().text_color(idle ? t.muted : t.foreground).child(name)) + .when(full && Boolean(more), el => el.child(div().flex_1().min_w(0).truncate().text_color(t.dim).child(`\u00b7 ${more}`)))) + .when(Boolean(machine), el => el.child(div().text_size(12).text_color(t.dim).child(machine))) + .when(subs.length > 0, el => el.child(fold(a, subs))) + .when(this.hovered === a.address, el => el.child(pin)) + .when(this.hovered !== a.address && Boolean(when), el => el.child(div().flex_none().text_size(12).text_color(t.dim).child(duration(now - when)))) + .child(this.status(t, a, byPriority && !isPinned)); + }; + + // Groups in list order: spaces (grouped) or priority sections. Agents of a + // space are next to each other in agents(), so neighbours make a group. + const groups = pinned.length ? [{ id: "pinned", label: "Pinned", status: null, members: pinned }] : []; + for (const a of rest) { + const r = rankOf(a) || RANK.idle; + const id = byPriority ? `sec-${r}` : `sp-${a.space}`; + if (groups.at(-1)?.id === id) groups.at(-1).members.push(a); + else groups.push({ id, label: byPriority ? SECTION[r] : a.space_name || "", status: byPriority ? SECTION_STATUS[r] : null, members: [a] }); + } + + // A group's header; it folds the group. Folded, it says how many and the most urgent state. + const header = (g) => { + const open = !this.folded.has(g.id); + const top = g.members.reduce((m, a) => ((RANK[a.status] ?? 0) > (RANK[m] ?? 0) ? a.status : m), null); + return h_flex() + .id(g.id) + .px(8) + .pt(8) + .h(30) + .flex_none() + .gap(6) + .items_center() + .text_size(12) + .font_medium() + .cursor_pointer() + .hover((s) => s.text_color(t.foreground)) + .on_click((_e, cx) => { + open ? this.folded.add(g.id) : this.folded.delete(g.id); + cx.notify(); + }) + .child(div().truncate().text_color(t[g.status] ?? t.dim).child(g.label)) + .when(byPriority || !open, (el) => el.child(div().text_color(t.dim).child(String(g.members.length)))) + .child(div().flex_1()) + .when(!open, (el) => el + .when(!byPriority && top && top !== "idle", (el) => el.child(this.status(t, { status: top }, true))) + .child(svg("mods/workspace-chrome/chevron-right.svg").size(11).flex_none().text_color(t.dim))); + }; + const rows = collapsed + ? list.flatMap(withKids) + : groups.flatMap((g) => [header(g), ...(this.folded.has(g.id) ? [] : g.members.flatMap(withKids))]); + + return v_flex() + .size_full() + .min_h(0) + .bg(t.chrome) + .border_t(1) + .border_color(t.border) + .text_size(13) + .when(!collapsed, (el) => + el.child( + h_flex() + .px(16) + .h(41) + .pt(14) + .pb(5) + .flex_none() + .items_center() + .justify_between() + .child(div().text_size(12).font_medium().text_color(t.dim).child("Agents")) + .child( + div() + .id("ag-sort") + .text_size(12) + .text_color(t.dim) + .hover((s) => s.text_color(t.foreground)) + .on_click(() => openOverlay({ mod: "refactoria-agents", kind: "popup", width: 200, height: 140, props: { sortMenu: true } })) + .tooltip(`Sort agents: ${sort.label}`) + .child(svg(`mods/workspace-chrome/${sort.icon}.svg`).size(14)), + ), + ), + ) + .when(list.length === 0 && !collapsed, (el) => + el.child(div().px(12).text_size(12).text_color(t.dim).child("No agent status yet. Agents show up here when they report their status. Older versions and agents inside tmux may not.")), + ) + .child(v_flex().id("ag-list").flex_1().min_h(0).px(collapsed ? 0 : 8).overflow_y_scrollbar().mode("always").children(rows)) + .child( + h_flex() + .h(28) + .px(12) + .items_center() + .justify_end() + .when(collapsed, (el) => el.justify_center().px(0)) + .child( + div() + .id("collapse") + .text_size(13) + .text_color(t.dim) + .hover((s) => s.text_color(t.foreground)) + .on_click(toggleSidebar) + .child(collapsed ? "»" : ""), + ), + ); + } +} diff --git a/herdr/desktop/mods/refactoria-agents/mod.json b/herdr/desktop/mods/refactoria-agents/mod.json new file mode 100644 index 0000000..9de24f5 --- /dev/null +++ b/herdr/desktop/mods/refactoria-agents/mod.json @@ -0,0 +1,8 @@ +{ + "place": "none", + "title": "Agents", + "cloned_from": { + "name": "agents", + "version": "0.1.9" + } +} \ No newline at end of file diff --git a/herdr/desktop/mods/refactoria-spaces/main.js b/herdr/desktop/mods/refactoria-spaces/main.js new file mode 100644 index 0000000..b7e2158 --- /dev/null +++ b/herdr/desktop/mods/refactoria-spaces/main.js @@ -0,0 +1,364 @@ +// Workspace spaces: stable colored avatars, worktrees under their checkout, +// grouped by machine when more than one is known. After a space's name, the repo +// its first terminal is in now, only where unusual: branch, ahead/behind. Agent state lives in the agents list, not here. Click to +// switch, right-click for its menu, drag to reorder. + +import { View, div, svg } from "gpui-kit"; +import { v_flex, h_flex } from "gpui-base"; +import { spaces, machines, panes, run, openOverlay, exec, hc, theme, shared, setShared, watchFolder, window, layout } from "herdr"; + +function find(node, id) { + if (node?.id === id) return node; + for (const child of node?.children ?? []) { + const found = find(child, id); + if (found) return found; + } + return null; +} + +// The sidebar's rail goes by its width, not a flag: dragging its edge narrows it too. +const narrow = () => (find(layout(), "sidebar")?.size?.px ?? Infinity) < 160; + +// Where a space is now: its first terminal's live folder, else where it was made. +// `ps`: panes(), read once by the caller (each read copies every pane). +function place(s, ps) { + const tabs = new Set((s.tabs ?? []).map((c) => c.id)); + return ps.find((p) => tabs.has(p.tab) && p.cwd)?.cwd || s.folder; +} +const rowHeight = ({ tree }) => (tree ? 28 : 30); +const where = (s, ps) => JSON.stringify([s.machine ?? null, place(s, ps)]); +const base = (path) => path.replace(/\/+$/, "").split("/").pop(); +// Changes made outside Herdr (an editor's save, a commit in another app): looked +// for this often, in repos the list shows, while the window is in front. +const FALLBACK = 60000; + +// What a stored color name looks like on an avatar (the same in every mod +// that draws space avatars): one strong color per hue, light and dark alike. +const MARK = { ansi6: "#22a99a", ansi3: "#e3814f", ansi4: "#5b8def", ansi5: "#8b7cf6", ansi2: "#3fae6a", ansi1: "#d9649a" }; + +// A space speaks up only when something in it needs you. +export function glyph(status) { + return status === "blocked" || status === "done" ? "●" : "·"; +} + +export function tone(t, status) { + return t[status] ?? t.unknown; +} + +export default class Spaces extends View { + init(_props, cx) { + this.git = {}; // where() -> { name, branch, ahead, behind, repo, linked }, null: not a repo + this.checked = {}; // where() -> when it was last looked at + this.unsure = new Set(); // where()s git couldn't answer for (a machine away) + this.pending = new Set(); // where()s to look at next + this.busy = {}; // pane -> an agent working or a program in front of its shell + this.ready = {}; // machine -> its layout was readable at the last look + this.at = {}; // space -> its where() at the last look + this.watching = new Set(); // local repos' git folders + // Spaces made while this runs wait (at most a moment) for their git, so a + // worktree appears under its repo instead of jumping there. + this.shown = new Set(spaces().map((s) => s.id)); + this.born = {}; + this.pressed = null; + this.over = null; + this.machines = []; + this.folded = new Set(JSON.parse(localStorage.getItem("machines.folded") ?? "[]")); + this.refreshMachines(cx); + cx.timer.every(15000, cx => this.refreshMachines(cx)); + // `git status` walks the whole tree, on another machine a process there: + // only when its answer could have changed (see `due`). + cx.timer.every(250, (cx) => this.checkNew(cx)); + } + + checkNew(cx) { + const now = Date.now(); + const sp = spaces(); + for (const s of sp) if (!this.shown.has(s.id)) this.born[s.id] ??= now; + const due = this.due(now, sp, panes()); + if (due.size) this.refreshGit(due, cx); + if (sp.some((s) => !this.shown.has(s.id))) cx.notify(); + } + + // Places whose git could have changed: new ones (a new space, a cd), where a + // command or an agent's turn just ended (a checkout, a commit, edits), those on + // a machine that is back, and repos in view whose fallback is due. + due(now, sp, ps) { + // Moved there: what it knew of that place may be old. + const at = {}; + for (const s of sp) at[s.id] = where(s, ps); + const keys = new Set(Object.values(at).filter((k) => JSON.parse(k)[1])); + const due = new Set([...keys].filter((k) => !(k in this.git))); + for (const [id, k] of Object.entries(at)) if (this.at[id] && this.at[id] !== k && keys.has(k)) due.add(k); + this.at = at; + const spaceOf = {}; + for (const s of sp) for (const t of s.tabs ?? []) spaceOf[t.id] = s; + const busy = {}; + for (const p of ps) { + if (!p.terminal || !spaceOf[p.tab]) continue; + busy[p.pane] = p.status === "working" || !(p.program?.shell || p.program?.agent); + if (this.busy[p.pane] && !busy[p.pane]) this.sameRepo(at[spaceOf[p.tab].id], keys).forEach((k) => due.add(k)); + } + this.busy = busy; + for (const m of machines()) { + const ready = m.layout === "ready"; + if (ready && this.ready[m.name] === false) for (const k of keys) if (JSON.parse(k)[0] === m.name) due.add(k); + this.ready[m.name] = ready; + } + if (window().active) { + for (const k of keys) { + const asks = this.git[k] || this.unsure.has(k); + if (asks && !this.folded.has(JSON.parse(k)[0]) && now - (this.checked[k] ?? 0) > FALLBACK) due.add(k); + } + } + return due; + } + + // `key` and the other places in its repo (a commit there moves their ahead/behind). + sameRepo(key, keys) { + const repo = this.git[key]?.repo; + return [key, ...(repo ? [...keys].filter((k) => this.git[k]?.repo === repo) : [])]; + } + + refreshMachines(cx) { + cx.spawn(async cx => { + try { const r = await hc(["machine", "ls", "--json"]); if (r.code === 0) this.machines = (JSON.parse(r.stdout).machines ?? []).filter((m) => !m.temporary); } catch {} + cx.notify(); + }); + } + + // One look at a time, each place once however often it was asked for meanwhile. + refreshGit(keys, cx) { + for (const k of keys) this.pending.add(k); + if (this.refreshing) return; + this.refreshing = true; + cx.spawn(async (cx) => { + while (this.pending.size) { + const keys = [...this.pending]; + this.pending.clear(); + for (const key of keys) await this.check(key, cx); + } + const used = new Set(Object.values(this.git).filter(Boolean).map((g) => JSON.parse(g.repo)).filter(([on]) => !on).map(([, repo]) => repo)); + for (const repo of this.watching) if (!used.has(repo)) this.watching.delete(repo); + this.refreshing = false; + cx.notify(); + }); + } + + // One place's git: branch, ahead/behind, its repo. + async check(key, cx) { + const [on, cwd] = JSON.parse(key); + this.checked[key] = Date.now(); + this.unsure.delete(key); + try { + // No optional locks: a status that touched the index would wake the watch again. + // Writes nothing under .git (index lock, fsmonitor cookie): watch() would wake on it. + const r = await exec("git", ["--no-optional-locks", "-c", "core.fsmonitor=false", "status", "--porcelain=v2", "--branch"], { cwd, on }); + if (r.code !== 0) { + // Only git's own "no" is remembered as no repo; anything else is asked again. + if (!/not a git repository/i.test(r.stderr)) this.unsure.add(key); + this.git[key] = null; + return; + } + // The repo's own .git folder names it; a worktree's top differs from it. + const top = await exec("git", ["rev-parse", "--show-toplevel"], { cwd, on }); + const common = await exec("git", ["rev-parse", "--path-format=absolute", "--git-common-dir"], { cwd, on }); + const repo = common.stdout.trim(); + const name = repo.endsWith("/.git") ? base(repo.slice(0, -5)) : base(repo).replace(/\.git$/, ""); + const info = { name, branch: null, ahead: 0, behind: 0, repo: JSON.stringify([on, repo]), linked: repo !== `${top.stdout.trim()}/.git` }; + for (const line of r.stdout.split("\n")) { + if (line.startsWith("# branch.head ")) info.branch = line.slice(14); + else if (line.startsWith("# branch.ab ")) { + const [a, b] = line.slice(12).split(" "); + info.ahead = Math.abs(parseInt(a, 10)) || 0; + info.behind = Math.abs(parseInt(b, 10)) || 0; + } + } + this.git[key] = info; + if (!on) this.watch(repo, cx); + } catch { + // Couldn't ask (the machine is away): keep what it showed, look again later. + this.git[key] ??= null; + this.unsure.add(key); + } + } + + watch(repo, cx) { + if (this.watching.has(repo)) return; + this.watching.add(repo); + cx.spawn(async (cx) => { + try { + while (this.watching.has(repo)) { + await watchFolder(repo); + const mine = JSON.stringify([null, repo]); + if (this.watching.has(repo)) this.refreshGit(Object.keys(this.git).filter((k) => this.git[k]?.repo === mine), cx); + } + } catch { this.watching.delete(repo); } + }); + } + + menu(w) { + // A popup is as tall as its items; height is only the most. + openOverlay({ mod: "menu", kind: "popup", width: 200, height: 260, props: { tab: "space", target: { id: w.id } } }); + } + + // Spaces in order, each worktree space moved under its main checkout's. + ordered() { + const sp = spaces(), ps = panes(); + const ready = (w) => this.shown.has(w.id) || where(w, ps) in this.git || Date.now() - (this.born[w.id] ??= Date.now()) > 1500; + for (const w of sp) if (!this.shown.has(w.id) && ready(w)) this.shown.add(w.id); + const list = sp.map((w, i) => ({ w, i, g: this.git[where(w, ps)] ?? null })).filter((r) => this.shown.has(r.w.id)); + const mains = new Set(list.filter((r) => r.g && !r.g.linked).map((r) => r.g.repo)); + const nested = (r) => r.g?.linked && mains.has(r.g.repo); + const out = []; + const placed = new Set(); // repos whose worktrees are listed (under the first of its spaces) + for (const r of list) { + if (nested(r)) continue; + out.push({ ...r, tree: null }); + if (!r.g || r.g.linked || placed.has(r.g.repo)) continue; + placed.add(r.g.repo); + const kids = list.filter((k) => nested(k) && k.g.repo === r.g.repo); + kids.forEach((k, n) => out.push({ ...k, tree: n === kids.length - 1 ? "└─" : "├─" })); + } + return out; + } + + // The rows, under a header per machine once one isn't this computer. + grouped(t, collapsed) { + const rows = this.ordered(); + const draw = (r) => (collapsed ? this.compact(r, t) : this.row(r, t)); + const remote = [...new Set([...rows.map(r => r.w.machine).filter(Boolean), ...this.machines.map(m => m.name)])].sort(); + const loading = m => { + const layout = machines().find(k => k.name === m)?.layout; + return layout === "loading" || (!layout && this.machines.some(k => k.name === m && k.state === "connected")) + || spaces().some(s => (s.machine ?? null) === m && !this.shown.has(s.id)); + }; + const loadingRow = m => div().id(`machine-loading-${m ?? "local"}`).h(28).flex_none().px(8).text_size(12).text_color(t.dim).child(collapsed ? "…" : "Loading…"); + if (!remote.length) return loading(null) && !rows.length ? [loadingRow(null)] : rows.map(draw); + const machineNames = [null, ...remote]; + return machineNames.flatMap((m) => { + const mine = rows.filter((r) => (r.w.machine ?? null) === m); + const machine = this.machines.find(k => k.name === m); + const connected = !m || machine?.state === "connected"; + const folded = this.folded.has(m); + const head = h_flex().id(`machine-${m ?? "local"}`).h(30).mt(6).mb(2).flex_none().px(collapsed ? 2 : 8).gap(7).items_center().text_size(12).text_color(t.foreground) + .rounded(8).bg(t.raised).border(1).border_color(t.raised) + .cursor_pointer().hover(s => s.border_color(t.input)) + .on_click((_e, cx) => { + folded ? this.folded.delete(m) : this.folded.add(m); + localStorage.setItem("machines.folded", JSON.stringify([...this.folded])); + cx.notify(); + }) + .child(svg(`mods/workspace-chrome/${m ? "server" : "monitor"}.svg`).size(13).flex_none()) + .when(!collapsed, el => el.child(div().flex_1().truncate().child(m ?? "This computer")) + .when(!connected, el => el.child(div().flex_none().child((machine?.state ?? "asleep").replace(/_/g, " ")))) + .when(connected, el => el.child(div().id(`machine-add-${m ?? "local"}`).flex_none().px(4).rounded(4).hover(s => s.bg(t.hover).text_color(t.foreground)) + .on_click((_e, cx) => { cx.stop_propagation(); run("space.new", m ? { machine: m } : {}); }).child("+"))) + .child(div().size(6).rounded(3).flex_none().bg(connected ? t.ansi2 : t.dim)) + .child(svg(`mods/workspace-chrome/chevron-${folded ? "right" : "down"}.svg`).size(11).flex_none())); + const empty = h_flex().id(`machine-new-${m ?? "local"}`).h(28).flex_none().px(8).gap(7).items_center().rounded(7) + .text_size(12).text_color(t.dim).hover(s => s.bg(t.hover).text_color(t.foreground)) + .on_click(() => run("space.new", m ? { machine: m } : {})) + .child(svg("mods/workspace-chrome/plus.svg").size(12)).when(!collapsed, el => el.child("New space there")); + return [head, ...(folded ? [] : mine.length ? mine.map(draw) : [loading(m) ? loadingRow(m) : empty])]; + }); + } + + // Only what's unusual, after the name: "herdr \u00b7 spike \u21912 \u21931". The repo or + // branch goes when the name already says it (a worktree is named after its branch), + // and master/main go: nearly every space is on one. + gitTail(g, tree, name, t) { + const raw = g.branch ?? "detached"; + const same = (a) => a.toLowerCase() === name.toLowerCase(); + const branch = same(raw) || raw === "master" || raw === "main" ? "" : raw; + const repo = tree || same(g.name) ? "" : g.name; + const where = [repo, branch].filter(Boolean).join(" \u00b7 "); + return h_flex() + .flex_1() + .min_w(0) + .gap(5) + .justify_end() + .items_center() + .text_size(12) + .text_color(t.dim) + .when(Boolean(where), (el) => el.child(div().min_w(0).truncate().child(where))) + .when(g.ahead > 0, (el) => el.child(div().flex_none().text_color(t.ansi2).child(`\u2191${g.ahead}`))) + .when(g.behind > 0, (el) => el.child(div().flex_none().text_color(t.ansi1).child(`\u2193${g.behind}`))); + } + + row({ w, i, g, tree }, t) { + const on = w.active; + const colors = shared("space.colors") ?? {}; + const color = colors[w.id] ?? "ansi6"; + const fill = color.startsWith("#") ? color : (MARK[color] ?? t[color]); + return v_flex() + .relative() + .id(`space-${w.id}`) + .px(8) + .h(rowHeight({ tree })) + .flex_none() + .justify_center() + .rounded(8) + .when(on, (el) => el.bg(t.selected)) + .when(!on, (el) => el.hover((s) => s.bg(t.hover))) + .when(this.pressed !== null && this.over === i && this.pressed !== i, (el) => el.border_t(2).border_color(t.accent)) + .on_mouse_down("left", () => { + this.pressed = i; + this.over = i; + }) + .on_mouse_up("left", (_e, cx) => { + const from = this.pressed; + this.pressed = null; + if (from !== null && from !== i) run("space.move", { id: spaces()[from].id, index: i + 1 }); + else run("space.select", { id: w.id }); + cx.notify(); + }) + .on_mouse_down("right", () => this.menu(w)) + .child( + h_flex() + .gap(9) + .items_center() + .when(Boolean(tree), el => el.pl(22).child(svg("mods/workspace-chrome/branch.svg").size(13).text_color(t.dim))) + .when(!tree, el => el.child(h_flex().size(18).flex_none().rounded(5).bg(fill).items_center().justify_center().text_size(10.5).font_bold().text_color("#ffffff").child([...w.name][0]?.toUpperCase() ?? "?"))) + .child(div().flex_shrink(0).max_w(150).truncate().text_color(t.foreground).child(w.name)) + .child(g ? this.gitTail(g, tree, w.name, t) : div().flex_1()), + ); + } + + compact({ w, i }, t) { + return h_flex() + .id(`space-${w.id}`) + .h(26) + .flex_none() + .justify_center() + .gap(4) + .when(w.active, (el) => el.bg(t.raised)) + .hover((s) => s.bg(t.hover)) + .on_click(() => run("space.select", { id: w.id })) + .child(div().text_color(w.active ? t.foreground : t.muted).child(String(i + 1))) + .child(div().text_color(tone(t, w.status)).child(glyph(w.status))); + } + + render() { + const t = theme(); + const collapsed = narrow(); + const palette = ["ansi6", "ansi3", "ansi4", "ansi5", "ansi2", "ansi1"]; + const sp = spaces(); + const ids = new Set(sp.map(p => p.id)); + const kept = Object.fromEntries(Object.entries(shared("space.colors") ?? {}).filter(([id]) => ids.has(id))); + let changed = Object.keys(kept).length !== Object.keys(shared("space.colors") ?? {}).length; + for (const p of sp) { + if (kept[p.id] || p.name === "~") continue; + const uses = c => Object.values(kept).filter(v => v === c).length; + kept[p.id] = palette.reduce((best, c) => uses(c) < uses(best) ? c : best); + changed = true; + } + if (changed) setShared("space.colors", kept); + return v_flex().size_full().min_h(0).bg(t.chrome).text_size(13) + .child(h_flex().h(31).pl(16).pr(10).items_center().justify_between().text_size(12).text_color(t.dim) + .when(!collapsed, el => el.child("Spaces")) + .child(div().id("space-new").p(4).rounded(5).hover(s => s.bg(t.hover).text_color(t.foreground)).on_click(() => run("space.new")).child("+"))) + .child(v_flex().flex_1().min_h(0).px(8).pb(14).overflow_y_scrollbar().mode("always") + .on_mouse_up("left", (_e, cx) => { this.pressed = null; cx.notify(); }) + .children(this.grouped(t, collapsed))); + } +} diff --git a/herdr/desktop/mods/refactoria-spaces/mod.json b/herdr/desktop/mods/refactoria-spaces/mod.json new file mode 100644 index 0000000..0b13591 --- /dev/null +++ b/herdr/desktop/mods/refactoria-spaces/mod.json @@ -0,0 +1,8 @@ +{ + "place": "none", + "title": "Spaces", + "cloned_from": { + "name": "spaces", + "version": "0.1.9" + } +} \ No newline at end of file diff --git a/install.sh b/install.sh index 6fefec6..23583c5 100755 --- a/install.sh +++ b/install.sh @@ -32,6 +32,11 @@ MANAGED_LINKS=( "pi/themes/refactoria.json|$HOME/.pi/agent/themes/refactoria.json|all" "ghostty/config|$HOME/.config/ghostty/config|all" "herdr/desktop/themes/refactoria.json|$HOME/.config/herdr-app/themes/refactoria.json|darwin" + "herdr/desktop/layouts/refactoria.json|$HOME/.config/herdr-app/layouts/refactoria.json|darwin" + "herdr/desktop/mods/refactoria-spaces/main.js|$HOME/.config/herdr-app/mods/refactoria-spaces/main.js|darwin" + "herdr/desktop/mods/refactoria-spaces/mod.json|$HOME/.config/herdr-app/mods/refactoria-spaces/mod.json|darwin" + "herdr/desktop/mods/refactoria-agents/main.js|$HOME/.config/herdr-app/mods/refactoria-agents/main.js|darwin" + "herdr/desktop/mods/refactoria-agents/mod.json|$HOME/.config/herdr-app/mods/refactoria-agents/mod.json|darwin" "ghostty/shaders|$HOME/.config/ghostty/shaders|all" "ghostty/profiles/barbatdev.conf|$HOME/.config/ghostty/profiles/barbatdev.conf|darwin" "ghostty/profiles/refactoria.conf|$HOME/.config/ghostty/profiles/refactoria.conf|darwin" From 356d4173cc38133a9bd34a09c12f204d59d000ee Mon Sep 17 00:00:00 2001 From: Juan Barbat Date: Fri, 30 Oct 2026 11:08:55 -0300 Subject: [PATCH 4/5] feat(herdr): add herdr-screens to open one Herdr Desktop screen per monitor Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01A9D7ph123Jn8iB1eGbr8Nz --- fish/functions/herdr-screens.fish | 53 +++++++++++++++++++++++++++++++ install.sh | 2 ++ 2 files changed, 55 insertions(+) create mode 100644 fish/functions/herdr-screens.fish diff --git a/fish/functions/herdr-screens.fish b/fish/functions/herdr-screens.fish new file mode 100644 index 0000000..8a85cfe --- /dev/null +++ b/fish/functions/herdr-screens.fish @@ -0,0 +1,53 @@ +# Open the extra Herdr Desktop screens (one per monitor). Usage: herdr-screens [count] +# Screen 1 is the default home (~/.config/herdr-app); screens 2..count use +# ~/.config/herdr-app-N. Every screen shares the same herdr-core terminals. +# Theme, layout and mods are symlinked to the repo, so one edit reaches all +# screens; settings.json is copied once, because Herdr rewrites it (zoom, +# theme picker). Screens that already run are left alone. +function herdr-screens --description 'Open extra Herdr Desktop screens for multiple monitors' + set -l count 4 + if test (count $argv) -gt 0 + set count $argv[1] + end + if not string match -qr '^[1-9][0-9]*$' -- $count + printf 'herdr-screens: count must be a positive integer\n' >&2 + return 1 + end + if not test -d /Applications/Herdr.app + printf 'herdr-screens: /Applications/Herdr.app not found\n' >&2 + return 127 + end + + # The repo root, from this function's real location (fish/functions/). + set -l repo (path resolve (status filename) | path dirname | path dirname | path dirname) + set -l desktop "$repo/herdr/desktop" + set -l shared themes/refactoria.json layouts/refactoria.json \ + mods/refactoria-spaces/main.js mods/refactoria-spaces/mod.json \ + mods/refactoria-agents/main.js mods/refactoria-agents/mod.json + + for n in (seq 2 $count) + set -l home "$HOME/.config/herdr-app-$n" + for f in $shared + mkdir -p (path dirname "$home/$f") + ln -sfn "$desktop/$f" "$home/$f" + end + if not test -e "$home/settings.json" + cp "$desktop/settings.json" "$home/settings.json" + end + + if env HERDR_APP_HOME="$home" herdr-app status >/dev/null 2>&1 + printf 'herdr-screens: screen %s already running\n' $n + else + open -n -a Herdr --env HERDR_APP_HOME="$home" + printf 'herdr-screens: opened screen %s (%s)\n' $n "$home" + # A new home starts on the main layout: switch it once it answers. + for attempt in (seq 20) + if env HERDR_APP_HOME="$home" herdr-app status >/dev/null 2>&1 + env HERDR_APP_HOME="$home" herdr-app do layout.switch '{"name":"refactoria"}' >/dev/null 2>&1 + break + end + sleep 0.5 + end + end + end +end diff --git a/install.sh b/install.sh index 23583c5..7e9f5b0 100755 --- a/install.sh +++ b/install.sh @@ -28,6 +28,7 @@ MANAGED_LINKS=( "fish/functions/hbarbatdev.fish|$HOME/.config/fish/functions/hbarbatdev.fish|all" "fish/functions/hrefactoria.fish|$HOME/.config/fish/functions/hrefactoria.fish|all" "fish/functions/__herdr_tab_autoname.fish|$HOME/.config/fish/functions/__herdr_tab_autoname.fish|all" + "fish/functions/herdr-screens.fish|$HOME/.config/fish/functions/herdr-screens.fish|darwin" "pi/themes/barbatdev.json|$HOME/.pi/agent/themes/barbatdev.json|all" "pi/themes/refactoria.json|$HOME/.pi/agent/themes/refactoria.json|all" "ghostty/config|$HOME/.config/ghostty/config|all" @@ -302,6 +303,7 @@ PY "$DOTFILES/fish/functions/hbarbatdev.fish" \ "$DOTFILES/fish/functions/hrefactoria.fish" \ "$DOTFILES/fish/functions/__herdr_tab_autoname.fish" \ + "$DOTFILES/fish/functions/herdr-screens.fish" \ "$DOTFILES/fish/functions/gbarbatdev.fish" \ "$DOTFILES/fish/functions/grefactoria.fish"; do check_shell "$path" fish From dbdf3bbc27c72244fabff6907e425b3b0a2c73dd Mon Sep 17 00:00:00 2001 From: Juan Barbat Date: Fri, 30 Oct 2026 11:56:20 -0300 Subject: [PATCH 5/5] fix(pi): map RefactorIA theme to brand tokens, semantic roles and amber spark Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01A9D7ph123Jn8iB1eGbr8Nz --- pi/themes/refactoria.json | 41 ++++++++++++++++++++------------------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/pi/themes/refactoria.json b/pi/themes/refactoria.json index 21add69..5b6a663 100644 --- a/pi/themes/refactoria.json +++ b/pi/themes/refactoria.json @@ -7,28 +7,29 @@ "bgElement": "#2C2144", "bgSubtle": "#17102A", "border": "#2C2144", - "borderSubtle": "#6D627C", + "borderSubtle": "#3C3943", "text": "#FFFFFF", - "muted": "#B6B2C0", - "mutedBright": "#D9D6E1", - "dim": "#918C9C", + "muted": "#9D9CA1", + "mutedBright": "#EBEBEB", + "dim": "#8F839D", "brandViolet": "#7127FF", - "brandBright": "#9B7BFF", + "brandBright": "#A477FF", "textAccent": "#A477FF", - "lavender": "#C4B5FD", - "brightLavender": "#E9D5FF", - "deepViolet": "#5A32A3", - "blue": "#7DD3FC", - "brightBlue": "#BAE6FD", - "warning": "#FFB547", - "brightAmber": "#FFE08A", - "error": "#EF4444", + "lavender": "#C7A6FF", + "brightLavender": "#E1CFFF", + "blue": "#8CC8FF", + "green": "#58E6A8", + "brightBlue": "#B8DDFF", + "warning": "#FFC857", + "spark": "#FFB547", + "brightAmber": "#FFD98A", + "error": "#FF6B8A", "selection": "#7127FF", "userBg": "#2C2144", "userText": "#FFFFFF", - "toolPendingBg": "#121019", - "toolSuccessBg": "#0F1D26", - "toolErrorBg": "#2A1014", + "toolPendingBg": "#17102A", + "toolSuccessBg": "#142226", + "toolErrorBg": "#281322", "infoBg": "#17102A" }, "colors": { @@ -36,7 +37,7 @@ "border": "border", "borderAccent": "brandBright", "borderMuted": "borderSubtle", - "success": "blue", + "success": "green", "error": "error", "warning": "warning", "muted": "muted", @@ -51,7 +52,7 @@ "userMessageText": "userText", "customMessageBg": "bgSubtle", "customMessageText": "text", - "customMessageLabel": "lavender", + "customMessageLabel": "spark", "toolPendingBg": "toolPendingBg", "toolSuccessBg": "toolSuccessBg", "toolErrorBg": "toolErrorBg", @@ -67,7 +68,7 @@ "mdQuoteBorder": "lavender", "mdHr": "muted", "mdListBullet": "brightLavender", - "toolDiffAdded": "blue", + "toolDiffAdded": "green", "toolDiffRemoved": "error", "toolDiffContext": "muted", "syntaxComment": "mutedBright", @@ -86,7 +87,7 @@ "thinkingHigh": "warning", "thinkingXhigh": "brandBright", "thinkingMax": "brightLavender", - "bashMode": "warning" + "bashMode": "spark" }, "export": { "pageBg": "bg",