@zeluizr/lattice 1.0.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,41 @@ All notable changes to this project are documented here. The format is based on
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres
5
5
  to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [2.0.0] — 2026-06-24
8
+
9
+ ### Added
10
+ - **GIT panel reports the host of each repo.** A new `HOST` column tags every
11
+ repo by where its `origin` lives — **GitHub**, the self-hosted **zgit** server,
12
+ another host, or `local` (no remote) — parsed from the remote URL.
13
+ - **zgit server listing.** A footer line in the GIT panel lists the bare repos on
14
+ the self-hosted zgit Docker container (`docker exec -u git <container> ls
15
+ /repos`). Local-only, no network; fails soft (the line is hidden) when Docker
16
+ or the container is unavailable. Container name via config `zgitContainer`
17
+ (default `zgit`).
18
+ - **GIT report and PROCESSES sit side by side** on wide terminals (the GIT
19
+ report takes the larger share); they stack when the terminal is too narrow.
20
+
21
+ ### Changed
22
+ - **The GIT panel is now cwd-independent.** It scans a fixed list of paths from
23
+ config (`repoRoots`) instead of the parent of the current directory, so the
24
+ report is identical in every terminal/tab. Each entry may be a repo itself or a
25
+ folder whose subdirectories are repos; entries are merged and de-duplicated.
26
+ - **The GIT panel lists only repos that need attention** — uncommitted changes,
27
+ or commits to push (ahead) / pull (behind) — most recently committed first,
28
+ capped at the top 10. Clean, in-sync repos are not listed; when none are
29
+ pending it shows `✓ all N repos up to date`. Uncommitted changes are now
30
+ labelled **uncommitted** (was "dirty"), with `↑`/`↓` for push/pull.
31
+
32
+ ### Removed
33
+ - **Power monitoring and the sudo prompt.** lattice no longer runs
34
+ `powermetrics`, so it never asks for a password on launch. Temperatures and fan
35
+ speed are unaffected — they still come from the native SMC helper (no sudo).
36
+ - **All CLI flags.** lattice now takes no options: it always runs the full
37
+ dashboard using the settings saved in `~/.config/lattice/config.json` (language
38
+ is chosen on first run; theme, icons, repo list and zgit container are read
39
+ from the file). Removed `--no-power`, `--no-vtex`, `--repos`, `--zgit`,
40
+ `--interval`, `--procs`, `--icons`, `--lang` and `--theme`.
41
+
7
42
  ## [1.0.0] — 2026-06-24
8
43
 
9
44
  First open-source release as **lattice** — a full rewrite of the project
package/README.md CHANGED
@@ -21,7 +21,14 @@ your Mac.
21
21
 
22
22
  ## Install & run
23
23
 
24
- No install needed run it straight from npm:
24
+ `@zeluizr/lattice` is a **private** npm package. Log in to an npm account that
25
+ has access to it first:
26
+
27
+ ```bash
28
+ npm login
29
+ ```
30
+
31
+ Then run it straight from npm:
25
32
 
26
33
  ```bash
27
34
  npx @zeluizr/lattice
@@ -34,6 +41,10 @@ npm i -g @zeluizr/lattice
34
41
  lattice
35
42
  ```
36
43
 
44
+ > **Access:** the package is published with `access: restricted`. The owner
45
+ > grants teammates access with `npm access grant read-only <user> @zeluizr/lattice`
46
+ > (or by publishing under an npm **org** scope and adding them to a team).
47
+
37
48
  On first run, lattice asks for your language (English · Español · Português) and
38
49
  remembers it. Watts need `sudo` (see below); everything else works without it.
39
50
 
package/dist/app.js CHANGED
@@ -5,27 +5,29 @@ import { Panel } from "./components/Panel.js";
5
5
  import { SystemCollector } from "./collectors/system.js";
6
6
  import { DisksCollector } from "./collectors/disks.js";
7
7
  import { GitCollector } from "./collectors/git.js";
8
+ import { ZgitCollector } from "./collectors/zgit.js";
8
9
  import { readGpu } from "./collectors/gpu.js";
9
10
  import { readSensors } from "./collectors/sensors.js";
10
- import { PowerCollector } from "./collectors/power.js";
11
11
  import { TokenCollector } from "./collectors/tokens.js";
12
12
  import { readVtex } from "./collectors/vtex.js";
13
13
  import { coreCell, fmtTok, humanBytes, humanRate, sparkline, statusLevel } from "./format.js";
14
14
  const MAX_HIST = 60;
15
15
  const push = (h, v) => [...h, v].slice(-MAX_HIST);
16
+ const DEFAULT_INTERVAL = 1; // seconds between refreshes (adjust at runtime with +/-)
17
+ const PROCS = 12; // number of top processes to list
16
18
  function timeStr() {
17
19
  const d = new Date();
18
20
  return d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", second: "2-digit" });
19
21
  }
20
22
  export function App(props) {
21
- const { t, pal, icon, usePower, useVtex, gitDir, topN } = props;
23
+ const { t, pal, icon, repoRoots, zgitContainer } = props;
22
24
  const { exit } = useApp();
23
25
  const [sys, setSys] = useState(null);
24
26
  const [disks, setDisks] = useState(null);
25
27
  const [git, setGit] = useState(null);
28
+ const [zgit, setZgit] = useState(null);
26
29
  const [gpu, setGpu] = useState(null);
27
30
  const [sensors, setSensors] = useState(null);
28
- const [power, setPower] = useState(null);
29
31
  const [tokens, setTokens] = useState(null);
30
32
  const [vtex, setVtex] = useState(null);
31
33
  const [hCpu, setHCpu] = useState([]);
@@ -34,14 +36,14 @@ export function App(props) {
34
36
  const [hGpu, setHGpu] = useState([]);
35
37
  const [hTemp, setHTemp] = useState([]);
36
38
  const [paused, setPaused] = useState(false);
37
- const [interval, setIntervalState] = useState(props.interval);
39
+ const [interval, setIntervalState] = useState(DEFAULT_INTERVAL);
38
40
  const [now, setNow] = useState(timeStr());
39
41
  const [cols, setCols] = useState(process.stdout.columns || 100);
40
42
  const sysRef = useRef(null);
41
43
  const disksRef = useRef(null);
42
44
  const gitRef = useRef(null);
45
+ const zgitRef = useRef(null);
43
46
  const tokRef = useRef(null);
44
- const powerRef = useRef(null);
45
47
  const pausedRef = useRef(false);
46
48
  const mounted = useRef(true);
47
49
  useEffect(() => {
@@ -50,14 +52,11 @@ export function App(props) {
50
52
  // ----- init collectors + power subprocess + clock -------------------------
51
53
  useEffect(() => {
52
54
  mounted.current = true;
53
- sysRef.current = new SystemCollector(topN);
55
+ sysRef.current = new SystemCollector(PROCS);
54
56
  disksRef.current = new DisksCollector();
55
- gitRef.current = new GitCollector(gitDir);
57
+ gitRef.current = new GitCollector(repoRoots);
58
+ zgitRef.current = new ZgitCollector(zgitContainer);
56
59
  tokRef.current = new TokenCollector();
57
- if (usePower) {
58
- powerRef.current = new PowerCollector(Math.round(props.interval * 1000));
59
- powerRef.current.start();
60
- }
61
60
  const clock = setInterval(() => mounted.current && setNow(timeStr()), 1000);
62
61
  const onResize = () => setCols(process.stdout.columns || 100);
63
62
  process.stdout.on("resize", onResize);
@@ -65,7 +64,6 @@ export function App(props) {
65
64
  mounted.current = false;
66
65
  clearInterval(clock);
67
66
  process.stdout.off("resize", onResize);
68
- powerRef.current?.stop();
69
67
  };
70
68
  // eslint-disable-next-line react-hooks/exhaustive-deps
71
69
  }, []);
@@ -79,14 +77,12 @@ export function App(props) {
79
77
  readGpu(),
80
78
  readSensors(),
81
79
  ]);
82
- const p = powerRef.current ? powerRef.current.read() : null;
83
80
  if (!mounted.current)
84
81
  return;
85
82
  setSys(s);
86
83
  setDisks(d);
87
84
  setGpu(g);
88
85
  setSensors(st);
89
- setPower(p);
90
86
  setHCpu((h) => push(h, s.cpuTotal));
91
87
  setHMem((h) => push(h, s.memPercent));
92
88
  setHNet((h) => push(h, (s.netRecvBps + s.netSentBps) / 1024 / 1024));
@@ -98,17 +94,19 @@ export function App(props) {
98
94
  const refreshAux = useCallback(async () => {
99
95
  if (pausedRef.current || !tokRef.current)
100
96
  return;
101
- const [tk, vx, gt] = await Promise.all([
97
+ const [tk, vx, gt, zg] = await Promise.all([
102
98
  tokRef.current.read(),
103
- useVtex ? readVtex() : Promise.resolve(null),
99
+ readVtex(),
104
100
  gitRef.current ? gitRef.current.read() : Promise.resolve(null),
101
+ zgitRef.current ? zgitRef.current.read() : Promise.resolve(null),
105
102
  ]);
106
103
  if (!mounted.current)
107
104
  return;
108
105
  setTokens(tk);
109
106
  setVtex(vx);
110
107
  setGit(gt);
111
- }, [useVtex]);
108
+ setZgit(zg);
109
+ }, []);
112
110
  useEffect(() => {
113
111
  refreshData();
114
112
  const id = setInterval(refreshData, Math.max(250, interval * 1000));
@@ -121,7 +119,6 @@ export function App(props) {
121
119
  }, [refreshAux]);
122
120
  useInput((input) => {
123
121
  if (input === "q") {
124
- powerRef.current?.stop();
125
122
  exit();
126
123
  }
127
124
  else if (input === "p") {
@@ -142,6 +139,12 @@ export function App(props) {
142
139
  const inner = (w) => Math.max(6, w - 4); // minus border (2) + padding (2)
143
140
  const w2 = inner(col2);
144
141
  const w3 = inner(col3);
142
+ // GIT + PROCESSES share a row on wide terminals; they stack when it's too
143
+ // narrow to split. GIT's columns are fixed-width, so it takes only what it
144
+ // needs (clamped) and PROCESSES inherits the slack — handy for long names.
145
+ const sideBySide = cols >= 92;
146
+ const gitW = Math.max(48, Math.min(58, Math.floor((cols - 2 * m) * 0.46)));
147
+ const procW = cols - 2 * m - gitW;
145
148
  const statColor = (lvl) => lvl === "ok" ? pal.green : lvl === "warn" ? pal.yellow : pal.red;
146
149
  const Stat = ({ value, warn, crit, metric }) => {
147
150
  const lvl = statusLevel(value, warn, crit);
@@ -164,20 +167,32 @@ export function App(props) {
164
167
  const util = gpu?.utilPct ?? 0;
165
168
  const diskRows = disks?.disks ?? [];
166
169
  const gitRepos = git?.repos ?? [];
170
+ const gitTotal = git?.total ?? 0;
171
+ const zgitServer = zgit?.available ? zgit : null;
172
+ const showGit = gitTotal > 0 || !!zgitServer;
173
+ const hostLabel = (r) => r.hostKind === "github"
174
+ ? "GitHub"
175
+ : r.hostKind === "zgit"
176
+ ? "zgit"
177
+ : r.hostKind === "none"
178
+ ? t("git.local")
179
+ : r.host || "—";
180
+ const hostColor = (r) => r.hostKind === "github"
181
+ ? pal.purple
182
+ : r.hostKind === "zgit"
183
+ ? pal.orange
184
+ : r.hostKind === "none"
185
+ ? pal.comment
186
+ : pal.cyan;
167
187
  const tokTotal = tokens ? tokens.input + tokens.output + tokens.cacheW + tokens.cacheR : 0;
168
188
  const modelItems = Object.entries(tokens?.byModel ?? {}).sort((a, b) => b[1].cost - a[1].cost);
169
189
  const modelParts = modelItems.slice(0, 2).map(([k, v]) => `${k} $${v.cost.toFixed(2)}`);
170
190
  const modelExtra = modelItems.length > 2 ? ` +${modelItems.length - 2}` : "";
171
191
  const web = tokens && (tokens.webSearch || tokens.webFetch) ? ` · web ${tokens.webSearch}/${tokens.webFetch}` : "";
172
192
  const models = modelParts.length ? modelParts.join(" · ") + modelExtra + web : t("tokens.none");
173
- const powerParts = [];
174
- if (power && (power.cpuW != null || power.gpuW != null))
175
- powerParts.push(`${icon("power")} ${(power.cpuW ?? 0).toFixed(1)}+${(power.gpuW ?? 0).toFixed(1)}W`);
176
- else if (usePower)
177
- powerParts.push(`${icon("power")} ${t("temp.waitingSudo")}`);
178
- if (sensors?.fans?.length)
179
- powerParts.push(`${icon("fan")} ${sensors.fans[0].rpm.toFixed(0)} rpm`);
180
- const tempLine2 = powerParts.length ? powerParts.join(" · ") : t("temp.needsSudo");
193
+ const tempLine2 = sensors?.fans?.length
194
+ ? `${icon("fan")} ${sensors.fans[0].rpm.toFixed(0)} rpm`
195
+ : "";
181
196
  return (_jsxs(Box, { flexDirection: "column", width: cols, children: [_jsxs(Box, { justifyContent: "space-between", paddingX: 1, children: [_jsx(Text, { color: pal.purple, bold: true, children: "\u25C7 lattice" }), _jsx(Text, { color: pal.comment, children: paused ? t("paused") : t("subtitle") }), _jsxs(Text, { color: pal.comment, children: [icon("clock"), " ", now] })] }), _jsxs(Box, { flexDirection: "row", children: [_jsxs(Panel, { title: `${icon("cpu")} ${t("panel.cpu")}`, color: pal.cyan, width: col2, children: [_jsxs(Text, { wrap: "truncate", children: [t("cpu.usage"), ": ", _jsxs(Text, { bold: true, children: [cpu.toFixed(0), "%"] }), " ", _jsx(Stat, { value: cpu, warn: 60, crit: 85, metric: "cpu" })] }), _jsxs(Text, { wrap: "truncate", children: [t("cpu.cores"), ": ", cores.length, " ", t("cpu.perCore"), ": ", cores.map(coreCell).join("")] }), _jsx(Text, { color: pal.cyan, wrap: "truncate", children: sparkline(hCpu, w2) }), _jsx(Text, { color: pal.comment, wrap: "truncate", children: caption(hCpu, (v) => `${v.toFixed(0)}%`) })] }), _jsxs(Panel, { title: `${icon("mem")} ${t("panel.mem")}`, color: pal.green, width: col2, children: [_jsxs(Text, { wrap: "truncate", children: [t("mem.ram"), ": ", _jsxs(Text, { bold: true, children: [memPct.toFixed(0), "%"] }), " ", _jsx(Stat, { value: memPct, warn: 75, crit: 90, metric: "mem" })] }), _jsx(Text, { wrap: "truncate", children: t("mem.used", {
182
197
  used: humanBytes(sys?.memUsed),
183
198
  total: humanBytes(sys?.memTotal),
@@ -185,12 +200,18 @@ export function App(props) {
185
200
  }) }), _jsx(Text, { color: pal.green, wrap: "truncate", children: sparkline(hMem, w2) }), _jsx(Text, { color: pal.comment, wrap: "truncate", children: caption(hMem, (v) => `${v.toFixed(0)}%`) })] })] }), _jsxs(Box, { flexDirection: "row", children: [_jsxs(Panel, { title: `${icon("temp")} ${t("panel.temp")}`, color: pal.orange, width: col3, children: [_jsx(Text, { wrap: "truncate", children: ct != null && gt != null ? (_jsxs(_Fragment, { children: [ct.toFixed(0), "\u00B0 / ", gt.toFixed(0), "\u00B0", " ", _jsx(Stat, { value: Math.max(ct, gt), warn: 65, crit: 80, metric: "temp" })] })) : ct != null || gt != null ? (_jsxs(_Fragment, { children: [(ct ?? gt ?? 0).toFixed(0), "\u00B0C", " ", _jsx(Stat, { value: ct ?? gt ?? 0, warn: 65, crit: 80, metric: "temp" })] })) : (_jsx(Text, { color: pal.comment, children: t("temp.unavailable") })) }), _jsx(Text, { wrap: "truncate", children: tempLine2 }), _jsx(Text, { color: pal.orange, wrap: "truncate", children: sparkline(hTemp, w3) }), _jsx(Text, { color: pal.comment, wrap: "truncate", children: caption(hTemp, (v) => `${v.toFixed(0)}°`) })] }), _jsxs(Panel, { title: `${icon("net")} ${t("panel.net")}`, color: pal.cyan, width: col3, children: [_jsxs(Text, { wrap: "truncate", children: ["\u2193 ", humanRate(sys?.netRecvBps)] }), _jsxs(Text, { wrap: "truncate", children: ["\u2191 ", humanRate(sys?.netSentBps)] }), _jsx(Text, { color: pal.cyan, wrap: "truncate", children: sparkline(hNet, w3) }), _jsx(Text, { color: pal.comment, wrap: "truncate", children: caption(hNet, (v) => humanRate(v * 1024 * 1024)) })] }), _jsxs(Panel, { title: `${icon("gpu")} ${t("panel.gpu")}`, color: pal.purple, width: col3, children: [_jsxs(Text, { wrap: "truncate", children: [t("gpu.usage"), ": ", _jsxs(Text, { bold: true, children: [util, "%"] }), " ", _jsx(Stat, { value: util, warn: 60, crit: 85, metric: "gpu" })] }), _jsx(Text, { wrap: "truncate", children: t("gpu.mem", { used: humanBytes(gpu?.memUsedBytes), alloc: humanBytes(gpu?.memAllocBytes) }) }), _jsx(Text, { color: pal.purple, wrap: "truncate", children: sparkline(hGpu, w3) }), _jsx(Text, { color: pal.comment, wrap: "truncate", children: caption(hGpu, (v) => `${v.toFixed(0)}%`) })] })] }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: pal.cyan, paddingX: 1, marginRight: 1, width: fullW, children: [_jsxs(Text, { color: pal.cyan, bold: true, children: [icon("disk"), " ", t("panel.disks")] }), _jsxs(Text, { color: pal.purple, bold: true, wrap: "truncate", children: [t("disks.mount").padEnd(22), t("disks.read").padEnd(12), t("disks.write").padEnd(12), t("disks.usage")] }), diskRows.length === 0 ? (_jsx(Text, { color: pal.comment, children: t("spark.collecting") })) : (diskRows.map((d) => {
186
201
  const lvl = statusLevel(d.usePercent, 80, 92);
187
202
  return (_jsxs(Text, { wrap: "truncate", children: [d.mount.slice(0, 21).padEnd(22), humanRate(d.readBps).padEnd(12), humanRate(d.writeBps).padEnd(12), _jsxs(Text, { color: statColor(lvl), children: ["\u25CF ", d.usePercent.toFixed(0), "%"] }), " ", _jsxs(Text, { color: pal.comment, children: ["(", humanBytes(d.usedBytes), "/", humanBytes(d.sizeBytes), ")"] })] }, d.mount));
188
- }))] }), _jsxs(Box, { flexDirection: "row", children: [_jsxs(Panel, { title: `${icon("tokens")} ${t("panel.tokens")}`, color: pal.pink, width: useVtex ? col2 : fullW, children: [_jsx(Text, { wrap: "truncate", children: t("tokens.spent", { cost: (tokens?.cost ?? 0).toFixed(2), messages: tokens?.messages ?? 0 }) }), _jsx(Text, { wrap: "truncate", children: t("tokens.tokens", {
203
+ }))] }), _jsxs(Box, { flexDirection: "row", children: [_jsxs(Panel, { title: `${icon("tokens")} ${t("panel.tokens")}`, color: pal.pink, width: col2, children: [_jsx(Text, { wrap: "truncate", children: t("tokens.spent", { cost: (tokens?.cost ?? 0).toFixed(2), messages: tokens?.messages ?? 0 }) }), _jsx(Text, { wrap: "truncate", children: t("tokens.tokens", {
189
204
  total: fmtTok(tokTotal),
190
205
  input: fmtTok(tokens?.input),
191
206
  output: fmtTok(tokens?.output),
192
- }) }), _jsx(Text, { wrap: "truncate", children: t("tokens.cache", { cw: fmtTok(tokens?.cacheW), cr: fmtTok(tokens?.cacheR) }) }), _jsx(Text, { wrap: "truncate", color: pal.comment, children: t("tokens.byModel", { models }) })] }), useVtex && (_jsx(Panel, { title: `${icon("vtex")} ${t("panel.vtex")}`, color: pal.purple, width: col2, children: !vtex ? (_jsx(Text, { color: pal.comment, children: t("spark.collecting") })) : !vtex.installed ? (_jsxs(_Fragment, { children: [_jsxs(Text, { children: [t("vtex.status"), ": ", _jsx(Text, { color: pal.red, children: t("vtex.notInstalled") })] }), _jsx(Text, { wrap: "truncate", color: pal.comment, children: t("vtex.install") })] })) : vtex.loggedIn ? (_jsxs(_Fragment, { children: [_jsxs(Text, { children: [t("vtex.status"), ": ", _jsx(Text, { color: pal.green, children: t("vtex.connected") })] }), _jsxs(Text, { wrap: "truncate", children: [t("vtex.account"), ": ", _jsx(Text, { bold: true, children: vtex.account })] }), _jsxs(Text, { wrap: "truncate", children: [t("vtex.user"), ": ", vtex.login || "—"] }), _jsxs(Text, { wrap: "truncate", children: [t("vtex.workspace"), ": ", vtex.workspace || "master"] })] })) : (_jsxs(_Fragment, { children: [_jsxs(Text, { children: [t("vtex.status"), ": ", _jsx(Text, { color: pal.yellow, children: t("vtex.notConnected") })] }), _jsx(Text, { wrap: "truncate", color: pal.comment, children: t("vtex.signin") })] })) }))] }), gitRepos.length > 0 && (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: pal.green, paddingX: 1, marginRight: 1, width: fullW, children: [_jsxs(Text, { color: pal.green, bold: true, wrap: "truncate", children: [icon("git"), " ", t("panel.git"), git?.truncated ? _jsx(Text, { color: pal.comment, children: " (+)" }) : null] }), _jsxs(Text, { color: pal.purple, bold: true, wrap: "truncate", children: [t("git.repo").padEnd(20), t("git.branch").padEnd(22), t("git.state")] }), gitRepos.map((r) => {
193
- const label = r.detached ? "(detached)" : r.branch;
194
- return (_jsxs(Text, { wrap: "truncate", children: [r.name.slice(0, 19).padEnd(20), label.slice(0, 21).padEnd(22), _jsxs(Text, { color: r.dirty ? pal.yellow : pal.green, children: ["\u25CF ", r.dirty ? t("git.dirty") : t("git.clean")] }), r.ahead > 0 ? _jsxs(Text, { color: pal.comment, children: [" ", "\u2191", r.ahead] }) : null, r.behind > 0 ? _jsxs(Text, { color: pal.comment, children: [" ", "\u2193", r.behind] }) : null] }, r.name));
195
- })] })), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: pal.comment, paddingX: 1, marginRight: 1, width: fullW, children: [_jsxs(Text, { color: pal.comment, bold: true, children: [icon("proc"), " ", t("panel.procs")] }), _jsxs(Text, { color: pal.purple, bold: true, wrap: "truncate", children: [t("proc.cpu").padEnd(6), t("proc.mem").padEnd(9), t("proc.pid").padEnd(8), t("proc.name")] }), (sys?.procs ?? []).map((p) => (_jsxs(Text, { wrap: "truncate", children: [`${p.cpu.toFixed(0)}%`.padEnd(6), humanBytes(p.rss).padEnd(9), String(p.pid).padEnd(8), p.name.slice(0, 30)] }, p.pid)))] }), _jsx(Box, { paddingX: 1, children: _jsxs(Text, { color: pal.comment, children: [_jsx(Text, { color: pal.pink, children: "q" }), " ", t("key.quit"), " \u00B7 ", _jsx(Text, { color: pal.pink, children: "p" }), " ", t("key.pause"), " \u00B7", " ", _jsx(Text, { color: pal.pink, children: "+/-" }), " ", t("key.faster"), "/", t("key.slower"), " \u00B7 ", interval.toFixed(2), "s"] }) })] }));
207
+ }) }), _jsx(Text, { wrap: "truncate", children: t("tokens.cache", { cw: fmtTok(tokens?.cacheW), cr: fmtTok(tokens?.cacheR) }) }), _jsx(Text, { wrap: "truncate", color: pal.comment, children: t("tokens.byModel", { models }) })] }), _jsx(Panel, { title: `${icon("vtex")} ${t("panel.vtex")}`, color: pal.purple, width: col2, children: !vtex ? (_jsx(Text, { color: pal.comment, children: t("spark.collecting") })) : !vtex.installed ? (_jsxs(_Fragment, { children: [_jsxs(Text, { children: [t("vtex.status"), ": ", _jsx(Text, { color: pal.red, children: t("vtex.notInstalled") })] }), _jsx(Text, { wrap: "truncate", color: pal.comment, children: t("vtex.install") })] })) : vtex.loggedIn ? (_jsxs(_Fragment, { children: [_jsxs(Text, { children: [t("vtex.status"), ": ", _jsx(Text, { color: pal.green, children: t("vtex.connected") })] }), _jsxs(Text, { wrap: "truncate", children: [t("vtex.account"), ": ", _jsx(Text, { bold: true, children: vtex.account })] }), _jsxs(Text, { wrap: "truncate", children: [t("vtex.user"), ": ", vtex.login || "—"] }), _jsxs(Text, { wrap: "truncate", children: [t("vtex.workspace"), ": ", vtex.workspace || "master"] })] })) : (_jsxs(_Fragment, { children: [_jsxs(Text, { children: [t("vtex.status"), ": ", _jsx(Text, { color: pal.yellow, children: t("vtex.notConnected") })] }), _jsx(Text, { wrap: "truncate", color: pal.comment, children: t("vtex.signin") })] })) })] }), _jsxs(Box, { flexDirection: sideBySide ? "row" : "column", alignItems: "flex-start", children: [showGit && (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: pal.green, paddingX: 1, marginRight: 1, width: sideBySide ? gitW : fullW, children: [_jsxs(Text, { color: pal.green, bold: true, wrap: "truncate", children: [icon("git"), " ", t("panel.git"), git?.truncated ? _jsx(Text, { color: pal.comment, children: " (+)" }) : null] }), gitRepos.length > 0 ? (_jsxs(Text, { color: pal.purple, bold: true, wrap: "truncate", children: [t("git.repo").padEnd(18), t("git.branch").padEnd(12), t("git.host").padEnd(8), t("git.state")] })) : gitTotal > 0 ? (_jsx(Text, { color: pal.green, wrap: "truncate", children: t("git.allClear", { n: gitTotal }) })) : null, gitRepos.map((r) => {
208
+ const label = r.detached ? "(detached)" : r.branch;
209
+ return (_jsxs(Text, { wrap: "truncate", children: [r.name.slice(0, 17).padEnd(18), label.slice(0, 11).padEnd(12), _jsx(Text, { color: hostColor(r), children: hostLabel(r).slice(0, 7).padEnd(8) }), _jsx(Text, { color: r.dirty ? pal.yellow : pal.orange, children: "\u25CF" }), r.dirty ? _jsxs(Text, { color: pal.yellow, children: [" ", t("git.uncommitted")] }) : null, r.ahead > 0 ? _jsxs(Text, { color: pal.cyan, children: [" \u2191", r.ahead] }) : null, r.behind > 0 ? _jsxs(Text, { color: pal.orange, children: [" \u2193", r.behind] }) : null] }, r.path));
210
+ }), zgitServer && (_jsx(Text, { color: pal.comment, wrap: "truncate", children: zgitServer.repos.length
211
+ ? t("git.server", {
212
+ container: zgitServer.container,
213
+ list: zgitServer.repos.join(", "),
214
+ n: zgitServer.repos.length,
215
+ })
216
+ : t("git.serverEmpty", { container: zgitServer.container }) }))] })), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: pal.comment, paddingX: 1, marginRight: 1, width: sideBySide && showGit ? procW : fullW, children: [_jsxs(Text, { color: pal.comment, bold: true, children: [icon("proc"), " ", t("panel.procs")] }), _jsxs(Text, { color: pal.purple, bold: true, wrap: "truncate", children: [t("proc.cpu").padEnd(6), t("proc.mem").padEnd(9), t("proc.pid").padEnd(7), t("proc.name")] }), (sys?.procs ?? []).map((p) => (_jsxs(Text, { wrap: "truncate", children: [`${p.cpu.toFixed(0)}%`.padEnd(6), humanBytes(p.rss).padEnd(9), String(p.pid).padEnd(7), p.name.slice(0, 40)] }, p.pid)))] })] }), _jsx(Box, { paddingX: 1, children: _jsxs(Text, { color: pal.comment, children: [_jsx(Text, { color: pal.pink, children: "q" }), " ", t("key.quit"), " \u00B7 ", _jsx(Text, { color: pal.pink, children: "p" }), " ", t("key.pause"), " \u00B7", " ", _jsx(Text, { color: pal.pink, children: "+/-" }), " ", t("key.faster"), "/", t("key.slower"), " \u00B7 ", interval.toFixed(2), "s"] }) })] }));
196
217
  }
package/dist/cli.js CHANGED
@@ -3,66 +3,34 @@ import { jsx as _jsx } from "react/jsx-runtime";
3
3
  import { dirname } from "node:path";
4
4
  import { render } from "ink";
5
5
  import meow from "meow";
6
- import { execa } from "execa";
7
6
  import { App } from "./app.js";
8
7
  import { LanguageSelect } from "./components/LanguageSelect.js";
9
8
  import { loadConfig, saveConfig } from "./config.js";
10
- import { detectLang, isLang, makeT } from "./i18n/index.js";
11
- import { isVariant, palette } from "./theme.js";
12
- import { isIconMode, makeIcons } from "./icons.js";
13
- const cli = meow(`
14
- ${"lattice"} real-time terminal dashboard for macOS Apple Silicon
9
+ import { detectLang, makeT } from "./i18n/index.js";
10
+ import { palette } from "./theme.js";
11
+ import { makeIcons } from "./icons.js";
12
+ // lattice takes no options and needs no sudo: it runs the full dashboard using
13
+ // the settings saved in ~/.config/lattice/config.json. Only --help / --version
14
+ // are handled here; language is chosen on first run, the rest lives in config.
15
+ meow(`
16
+ lattice — real-time terminal dashboard for macOS Apple Silicon
15
17
 
16
18
  Usage
17
- $ lattice [options]
18
-
19
- Options
20
- --no-power skip powermetrics/sudo (CPU/GPU/RAM/disk/net only)
21
- --no-vtex hide the VTEX panel (for non-VTEX users)
22
- --repos folder of git repos to show branches for
23
- (default: the parent of the current directory)
24
- --interval, -i refresh interval in seconds (default 1)
25
- --procs, -n number of top processes to show (default 8)
26
- --icons icon style: nerd | emoji | none (default nerd)
27
- --lang language: en | es | pt-BR (asked on first run)
28
- --theme pro | blade | buffy | lincoln | morbius | van-helsing
29
- --version, -v
30
- --help
31
-
32
- Examples
33
19
  $ lattice
34
- $ lattice --no-power --icons emoji
35
- $ lattice --lang es --theme blade
36
- `, {
37
- importMeta: import.meta,
38
- description: false,
39
- flags: {
40
- power: { type: "boolean", default: true },
41
- vtex: { type: "boolean", default: true },
42
- repos: { type: "string", default: "" },
43
- interval: { type: "number", shortFlag: "i", default: 1 },
44
- procs: { type: "number", shortFlag: "n", default: 8 },
45
- icons: { type: "string", default: "" },
46
- lang: { type: "string", default: "" },
47
- theme: { type: "string", default: "" },
48
- },
49
- });
50
- async function resolveLang() {
51
- const cfg = loadConfig();
52
- const flag = cli.flags.lang;
53
- if (flag && isLang(flag)) {
54
- saveConfig({ lang: flag });
55
- return flag;
56
- }
57
- if (cfg.lang)
58
- return cfg.lang;
59
- // First run: ask, unless stdin isn't interactive.
20
+
21
+ lattice reads its language, theme, icons, repo list and zgit container from
22
+ ~/.config/lattice/config.json (language is chosen on first run).
23
+ `, { importMeta: import.meta, flags: {} });
24
+ /** Saved language, or the first-run picker (env-detected when non-interactive). */
25
+ async function resolveLang(variant) {
26
+ const saved = loadConfig().lang;
27
+ if (saved)
28
+ return saved;
60
29
  if (!process.stdin.isTTY) {
61
30
  const detected = detectLang();
62
31
  saveConfig({ lang: detected });
63
32
  return detected;
64
33
  }
65
- const variant = resolveTheme();
66
34
  const chosen = await new Promise((resolve) => {
67
35
  const app = render(_jsx(LanguageSelect, { pal: palette(variant), onSelect: (l) => {
68
36
  app.unmount();
@@ -72,42 +40,16 @@ async function resolveLang() {
72
40
  saveConfig({ lang: chosen });
73
41
  return chosen;
74
42
  }
75
- function resolveTheme() {
76
- const flag = cli.flags.theme;
77
- if (flag && isVariant(flag)) {
78
- saveConfig({ theme: flag });
79
- return flag;
80
- }
81
- return loadConfig().theme ?? "pro";
82
- }
83
- function resolveIcons() {
84
- const flag = cli.flags.icons;
85
- if (flag && isIconMode(flag)) {
86
- saveConfig({ icons: flag });
87
- return flag;
88
- }
89
- return loadConfig().icons ?? "nerd";
90
- }
91
43
  async function main() {
92
- const lang = await resolveLang();
93
- const variant = resolveTheme();
94
- const iconMode = resolveIcons();
44
+ const cfg = loadConfig();
45
+ const variant = cfg.theme ?? "pro";
46
+ const lang = await resolveLang(variant);
95
47
  const t = makeT(lang);
96
48
  const pal = palette(variant);
97
- const icon = makeIcons(iconMode);
98
- let usePower = cli.flags.power;
99
- // Pre-authenticate sudo BEFORE the TUI takes over the terminal.
100
- if (usePower) {
101
- process.stdout.write(t("cli.sudoNeed") + "\n");
102
- try {
103
- await execa("sudo", ["-v"], { stdio: "inherit" });
104
- }
105
- catch {
106
- process.stdout.write(t("cli.sudoFail") + "\n");
107
- usePower = false;
108
- }
109
- }
110
- const { waitUntilExit } = render(_jsx(App, { t: t, pal: pal, icon: icon, lang: lang, usePower: usePower, useVtex: cli.flags.vtex, gitDir: cli.flags.repos || dirname(process.cwd()), interval: cli.flags.interval, topN: cli.flags.procs }));
49
+ const icon = makeIcons(cfg.icons ?? "nerd");
50
+ const repoRoots = cfg.repoRoots?.length ? cfg.repoRoots : [dirname(process.cwd())];
51
+ const zgitContainer = cfg.zgitContainer ?? "zgit";
52
+ const { waitUntilExit } = render(_jsx(App, { t: t, pal: pal, icon: icon, repoRoots: repoRoots, zgitContainer: zgitContainer }));
111
53
  await waitUntilExit();
112
54
  }
113
55
  main().catch((e) => {
@@ -1,10 +1,14 @@
1
1
  /**
2
- * Active git branches for the repos in a folder (no sudo).
2
+ * Active git branches for a fixed, configured set of repos (no sudo).
3
3
  *
4
- * Scans the immediate subdirectories of `root`, keeps the ones that are git
5
- * repositories, and reports each repo's current branch plus its state —
6
- * dirty/clean and ahead/behind its upstream from a single
7
- * `git status --porcelain=v2 --branch` per repo.
4
+ * Each configured root is either a repo itself (it has a `.git`) or a folder
5
+ * whose immediate subdirectories are repos. Both shapes are merged and
6
+ * de-duplicated by absolute path, so the report is the SAME no matter which
7
+ * directory lattice was launched from — it does not depend on the cwd.
8
+ *
9
+ * Each repo reports its branch, dirty/ahead/behind state (from a single
10
+ * `git status --porcelain=v2 --branch`) and where its `origin` is hosted —
11
+ * GitHub vs the self-hosted zgit server vs another host — from its remote URL.
8
12
  *
9
13
  * Cheap to discover (one readdir + a stat per child) but each repo costs a git
10
14
  * invocation, so we cap the count, bound concurrency, and skip overlapping
@@ -16,28 +20,23 @@ import { readdir } from "node:fs/promises";
16
20
  import { existsSync } from "node:fs";
17
21
  import { join, basename } from "node:path";
18
22
  const run = promisify(execFile);
19
- const MAX_REPOS = 24;
23
+ const MAX_REPOS = 48; // hard cap on how many repos we'll stat per refresh
24
+ const SHOW_REPOS = 10; // of those, only the most-recently-committed are reported
20
25
  const CONCURRENCY = 8;
21
26
  export class GitCollector {
22
- root;
27
+ roots;
23
28
  inflight = false;
24
29
  last;
25
- constructor(root) {
26
- this.root = root;
27
- this.last = { root, repos: [], truncated: false };
30
+ constructor(roots) {
31
+ this.roots = roots;
32
+ this.last = { roots, repos: [], total: 0, truncated: false };
28
33
  }
29
34
  async read() {
30
35
  if (this.inflight)
31
36
  return this.last;
32
37
  this.inflight = true;
33
38
  try {
34
- const entries = await readdir(this.root, { withFileTypes: true }).catch(() => []);
35
- const dirs = entries
36
- .filter((e) => e.isDirectory() && !e.name.startsWith("."))
37
- .map((e) => join(this.root, e.name))
38
- .filter((p) => existsSync(join(p, ".git")))
39
- .sort();
40
- const truncated = dirs.length > MAX_REPOS;
39
+ const dirs = await this.discover();
41
40
  const pick = dirs.slice(0, MAX_REPOS);
42
41
  const repos = [];
43
42
  for (let i = 0; i < pick.length; i += CONCURRENCY) {
@@ -47,8 +46,19 @@ export class GitCollector {
47
46
  if (r)
48
47
  repos.push(r);
49
48
  }
50
- repos.sort((a, b) => a.name.localeCompare(b.name));
51
- this.last = { root: this.root, repos, truncated };
49
+ // Only repos that need attention — uncommitted changes, or commits to
50
+ // push/pull most recently committed first, capped at SHOW_REPOS. Repos
51
+ // that are clean and in sync are counted but not listed.
52
+ const pending = repos
53
+ .filter((r) => r.dirty || r.ahead > 0 || r.behind > 0)
54
+ .sort((a, b) => b.lastCommit - a.lastCommit || a.name.localeCompare(b.name));
55
+ const shown = pending.slice(0, SHOW_REPOS);
56
+ this.last = {
57
+ roots: this.roots,
58
+ repos: shown,
59
+ total: repos.length,
60
+ truncated: pending.length > shown.length,
61
+ };
52
62
  return this.last;
53
63
  }
54
64
  catch {
@@ -58,17 +68,51 @@ export class GitCollector {
58
68
  this.inflight = false;
59
69
  }
60
70
  }
71
+ /** Resolve every configured root to a de-duplicated, sorted list of repo dirs. */
72
+ async discover() {
73
+ const seen = new Set();
74
+ const out = [];
75
+ const add = (p) => {
76
+ if (!seen.has(p)) {
77
+ seen.add(p);
78
+ out.push(p);
79
+ }
80
+ };
81
+ for (const root of this.roots) {
82
+ // A configured path can be a repo itself…
83
+ if (existsSync(join(root, ".git"))) {
84
+ add(root);
85
+ continue;
86
+ }
87
+ // …or a folder whose immediate children are repos.
88
+ const entries = await readdir(root, { withFileTypes: true }).catch(() => []);
89
+ for (const e of entries) {
90
+ if (!e.isDirectory() || e.name.startsWith("."))
91
+ continue;
92
+ const p = join(root, e.name);
93
+ if (existsSync(join(p, ".git")))
94
+ add(p);
95
+ }
96
+ }
97
+ return out.sort();
98
+ }
61
99
  }
62
100
  async function readRepo(dir) {
63
- const { stdout } = await run("git", ["-C", dir, "status", "--porcelain=v2", "--branch"], {
64
- maxBuffer: 1 << 20,
65
- });
101
+ const [statusRes, urlRes, dateRes] = await Promise.all([
102
+ run("git", ["-C", dir, "status", "--porcelain=v2", "--branch"], { maxBuffer: 1 << 20 }),
103
+ run("git", ["-C", dir, "remote", "get-url", "origin"], { maxBuffer: 1 << 16 }).catch(() => ({
104
+ stdout: "",
105
+ })),
106
+ run("git", ["-C", dir, "log", "-1", "--format=%ct"], { maxBuffer: 1 << 16 }).catch(() => ({
107
+ stdout: "",
108
+ })),
109
+ ]);
66
110
  let branch = "?";
67
111
  let ahead = 0;
68
112
  let behind = 0;
69
113
  let detached = false;
70
114
  let dirty = false;
71
- for (const ln of stdout.split("\n")) {
115
+ for (const ln of statusRes.stdout.split("\n")) {
72
116
  if (ln.startsWith("# branch.head ")) {
73
117
  branch = ln.slice("# branch.head ".length).trim();
74
118
  if (branch === "(detached)")
@@ -85,5 +129,22 @@ async function readRepo(dir) {
85
129
  dirty = true;
86
130
  }
87
131
  }
88
- return { name: basename(dir), branch, ahead, behind, detached, dirty };
132
+ const { host, hostKind } = classifyHost(urlRes.stdout.trim());
133
+ const lastCommit = Number(dateRes.stdout.trim()) || 0;
134
+ return { name: basename(dir), path: dir, branch, ahead, behind, detached, dirty, host, hostKind, lastCommit };
135
+ }
136
+ /** Pull the hostname out of an origin URL and bucket it into a known host. */
137
+ export function classifyHost(url) {
138
+ if (!url)
139
+ return { host: "", hostKind: "none" };
140
+ // https://host/…, ssh://git@host/… or scp-like git@host:path
141
+ const scheme = url.match(/^[a-z][a-z0-9+.-]*:\/\/(?:[^@/]+@)?([^/:]+)/i);
142
+ const scp = url.match(/^[^/@]+@([^:/]+):/);
143
+ const host = (scheme?.[1] || scp?.[1] || "").toLowerCase();
144
+ let hostKind = "other";
145
+ if (host.endsWith("github.com"))
146
+ hostKind = "github";
147
+ else if (host.includes("zgit"))
148
+ hostKind = "zgit";
149
+ return { host, hostKind };
89
150
  }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Bare repos living on the self-hosted zgit server (a Docker container).
3
+ *
4
+ * Lists `/repos/*.git` from inside the container with a single
5
+ * `docker exec -u git <container> ls -1 /repos`. This is a local call (no
6
+ * network) and fails soft: if Docker is missing or the container is stopped,
7
+ * it reports `available: false` and the panel simply omits the server line.
8
+ */
9
+ import { execFile } from "node:child_process";
10
+ import { promisify } from "node:util";
11
+ const run = promisify(execFile);
12
+ export class ZgitCollector {
13
+ container;
14
+ inflight = false;
15
+ last;
16
+ constructor(container) {
17
+ this.container = container;
18
+ this.last = { available: false, container, repos: [] };
19
+ }
20
+ async read() {
21
+ if (!this.container || this.inflight)
22
+ return this.last;
23
+ this.inflight = true;
24
+ try {
25
+ const { stdout } = await run("docker", ["exec", "-u", "git", this.container, "ls", "-1", "/repos"], { maxBuffer: 1 << 16, timeout: 4000 });
26
+ const repos = stdout
27
+ .split("\n")
28
+ .map((s) => s.trim())
29
+ .filter((s) => s.endsWith(".git"))
30
+ .map((s) => s.replace(/\.git$/, ""))
31
+ .sort();
32
+ this.last = { available: true, container: this.container, repos };
33
+ }
34
+ catch {
35
+ this.last = { available: false, container: this.container, repos: [] };
36
+ }
37
+ finally {
38
+ this.inflight = false;
39
+ }
40
+ return this.last;
41
+ }
42
+ }
package/dist/config.js CHANGED
@@ -20,6 +20,13 @@ export function loadConfig() {
20
20
  cfg.theme = raw.theme;
21
21
  if (raw.icons === "nerd" || raw.icons === "emoji" || raw.icons === "none")
22
22
  cfg.icons = raw.icons;
23
+ if (Array.isArray(raw.repoRoots)) {
24
+ const roots = raw.repoRoots.filter((r) => typeof r === "string" && r.length > 0);
25
+ if (roots.length)
26
+ cfg.repoRoots = roots;
27
+ }
28
+ if (typeof raw.zgitContainer === "string" && raw.zgitContainer)
29
+ cfg.zgitContainer = raw.zgitContainer;
23
30
  return cfg;
24
31
  }
25
32
  catch {
package/dist/i18n/en.js CHANGED
@@ -1,6 +1,6 @@
1
1
  /** English (base locale). Every other locale must implement these exact keys. */
2
2
  export const en = {
3
- "subtitle": "system · gpu · power",
3
+ "subtitle": "system · gpu · git",
4
4
  "paused": "PAUSED",
5
5
  "panel.cpu": "CPU",
6
6
  "panel.mem": "MEMORY",
@@ -39,13 +39,15 @@ export const en = {
39
39
  "git.repo": "REPO",
40
40
  "git.branch": "BRANCH",
41
41
  "git.state": "STATE",
42
- "git.clean": "clean",
43
- "git.dirty": "dirty",
42
+ "git.host": "HOST",
43
+ "git.uncommitted": "uncommitted",
44
+ "git.allClear": "✓ all {n} repos up to date",
45
+ "git.local": "local",
46
+ "git.server": "— zgit server ({container}): {list} ({n})",
47
+ "git.serverEmpty": "— zgit server ({container}): no repos",
44
48
  "gpu.usage": "Usage",
45
49
  "gpu.mem": "mem {used}/{alloc}",
46
50
  "temp.unavailable": "sensors unavailable",
47
- "temp.waitingSudo": "waiting for sudo",
48
- "temp.needsSudo": "power: requires sudo",
49
51
  "spark.collecting": "collecting…",
50
52
  "spark.lastMin": "last min: {range}",
51
53
  "tokens.spent": "Spent today: ${cost} · {messages} messages",
@@ -70,7 +72,4 @@ export const en = {
70
72
  "key.pause": "Pause",
71
73
  "key.faster": "Faster",
72
74
  "key.slower": "Slower",
73
- "cli.sudoNeed": "lattice needs sudo to read power (powermetrics).",
74
- "cli.sudoFail": "sudo unavailable — continuing without power data.",
75
- "cli.sudoCancel": "cancelled.",
76
75
  };
package/dist/i18n/es.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export const es = {
2
- "subtitle": "sistema · gpu · energía",
2
+ "subtitle": "sistema · gpu · git",
3
3
  "paused": "PAUSADO",
4
4
  "panel.cpu": "CPU",
5
5
  "panel.mem": "MEMORIA",
@@ -38,13 +38,15 @@ export const es = {
38
38
  "git.repo": "REPO",
39
39
  "git.branch": "BRANCH",
40
40
  "git.state": "ESTADO",
41
- "git.clean": "limpio",
42
- "git.dirty": "sucio",
41
+ "git.host": "HOST",
42
+ "git.uncommitted": "sin commit",
43
+ "git.allClear": "✓ {n} repos al día",
44
+ "git.local": "local",
45
+ "git.server": "— servidor zgit ({container}): {list} ({n})",
46
+ "git.serverEmpty": "— servidor zgit ({container}): sin repos",
43
47
  "gpu.usage": "Uso",
44
48
  "gpu.mem": "mem {used}/{alloc}",
45
49
  "temp.unavailable": "sensores no disponibles",
46
- "temp.waitingSudo": "esperando sudo",
47
- "temp.needsSudo": "energía: requiere sudo",
48
50
  "spark.collecting": "recolectando…",
49
51
  "spark.lastMin": "último min: {range}",
50
52
  "tokens.spent": "Gastado hoy: ${cost} · {messages} mensajes",
@@ -69,7 +71,4 @@ export const es = {
69
71
  "key.pause": "Pausar",
70
72
  "key.faster": "Más rápido",
71
73
  "key.slower": "Más lento",
72
- "cli.sudoNeed": "lattice necesita sudo para leer la energía (powermetrics).",
73
- "cli.sudoFail": "sudo no disponible — siguiendo sin datos de energía.",
74
- "cli.sudoCancel": "cancelado.",
75
74
  };
@@ -1,5 +1,5 @@
1
1
  export const ptBR = {
2
- "subtitle": "sistema · gpu · energia",
2
+ "subtitle": "sistema · gpu · git",
3
3
  "paused": "PAUSADO",
4
4
  "panel.cpu": "CPU",
5
5
  "panel.mem": "MEMÓRIA",
@@ -38,13 +38,15 @@ export const ptBR = {
38
38
  "git.repo": "REPO",
39
39
  "git.branch": "BRANCH",
40
40
  "git.state": "ESTADO",
41
- "git.clean": "limpo",
42
- "git.dirty": "sujo",
41
+ "git.host": "HOST",
42
+ "git.uncommitted": "sem commit",
43
+ "git.allClear": "✓ todos os {n} repos em dia",
44
+ "git.local": "local",
45
+ "git.server": "— servidor zgit ({container}): {list} ({n})",
46
+ "git.serverEmpty": "— servidor zgit ({container}): sem repos",
43
47
  "gpu.usage": "Uso",
44
48
  "gpu.mem": "mem {used}/{alloc}",
45
49
  "temp.unavailable": "sensores indisponíveis",
46
- "temp.waitingSudo": "aguardando sudo",
47
- "temp.needsSudo": "energia: requer sudo",
48
50
  "spark.collecting": "coletando…",
49
51
  "spark.lastMin": "último min: {range}",
50
52
  "tokens.spent": "Gasto hoje: ${cost} · {messages} mensagens",
@@ -69,7 +71,4 @@ export const ptBR = {
69
71
  "key.pause": "Pausar",
70
72
  "key.faster": "Mais rápido",
71
73
  "key.slower": "Mais lento",
72
- "cli.sudoNeed": "lattice precisa de sudo para ler energia (powermetrics).",
73
- "cli.sudoFail": "sudo indisponível — seguindo sem dados de energia.",
74
- "cli.sudoCancel": "cancelado.",
75
74
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeluizr/lattice",
3
- "version": "1.0.0",
3
+ "version": "2.0.0",
4
4
  "description": "Real-time terminal dashboard for macOS Apple Silicon — GPU, power, temperatures/fans, per-disk I/O (incl. /Volumes), network, memory, processes, and AI token cost.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -34,7 +34,7 @@
34
34
  "terminal",
35
35
  "dashboard",
36
36
  "gpu",
37
- "powermetrics",
37
+ "git",
38
38
  "htop",
39
39
  "ink",
40
40
  "cli"
@@ -49,6 +49,9 @@
49
49
  "bugs": {
50
50
  "url": "https://github.com/zeluizr/lattice/issues"
51
51
  },
52
+ "publishConfig": {
53
+ "access": "restricted"
54
+ },
52
55
  "scripts": {
53
56
  "build": "tsc && node scripts/postbuild.mjs",
54
57
  "build:native": "bash native/build.sh",
@@ -1,105 +0,0 @@
1
- /**
2
- * Power / frequency via `powermetrics` (REQUIRES sudo).
3
- *
4
- * powermetrics streams plist samples on stdout separated by a NUL byte (\x00).
5
- * We run it as a long-lived subprocess and keep the latest parsed sample. Power
6
- * fields arrive in milliwatts; we convert to watts.
7
- */
8
- import { spawn } from "node:child_process";
9
- import * as plist from "plist";
10
- import bplist from "bplist-parser";
11
- function parseSample(raw) {
12
- if (raw.length === 0)
13
- return null;
14
- try {
15
- // binary plist?
16
- if (raw.length >= 6 && raw.subarray(0, 6).toString("latin1") === "bplist") {
17
- const arr = bplist.parseBuffer(raw);
18
- return arr?.[0] ?? null;
19
- }
20
- // XML plist
21
- const text = raw.toString("utf8");
22
- const start = text.indexOf("<?xml");
23
- if (start === -1)
24
- return null;
25
- return plist.parse(text.slice(start));
26
- }
27
- catch {
28
- return null;
29
- }
30
- }
31
- function mwToW(d, key) {
32
- const v = d?.[key];
33
- return typeof v === "number" ? v / 1000 : null;
34
- }
35
- export class PowerCollector {
36
- intervalMs;
37
- latest = null;
38
- error = null;
39
- proc = null;
40
- buf = Buffer.alloc(0);
41
- stopped = false;
42
- constructor(intervalMs = 1000) {
43
- this.intervalMs = intervalMs;
44
- }
45
- start() {
46
- try {
47
- this.proc = spawn("sudo", [
48
- "powermetrics",
49
- "--samplers",
50
- "cpu_power,gpu_power,thermal",
51
- "-i",
52
- String(this.intervalMs),
53
- "-f",
54
- "plist",
55
- ], { stdio: ["ignore", "pipe", "ignore"] });
56
- }
57
- catch (e) {
58
- this.error = `failed to start powermetrics: ${e}`;
59
- return;
60
- }
61
- this.proc.stdout?.on("data", (chunk) => {
62
- if (this.stopped)
63
- return;
64
- this.buf = Buffer.concat([this.buf, chunk]);
65
- let nul;
66
- while ((nul = this.buf.indexOf(0x00)) !== -1) {
67
- const raw = this.buf.subarray(0, nul);
68
- this.buf = this.buf.subarray(nul + 1);
69
- const sample = parseSample(raw);
70
- if (sample)
71
- this.latest = sample;
72
- }
73
- });
74
- this.proc.on("error", (e) => {
75
- this.error = String(e);
76
- });
77
- }
78
- read() {
79
- const d = this.latest;
80
- if (!d)
81
- return null;
82
- const proc = d.processor ?? {};
83
- const gpu = d.gpu ?? {};
84
- const freqHz = gpu.freq_hz;
85
- return {
86
- cpuW: mwToW(proc, "cpu_power"),
87
- gpuW: mwToW(proc, "gpu_power"),
88
- aneW: mwToW(proc, "ane_power"),
89
- packageW: mwToW(proc, "combined_power") ?? mwToW(proc, "package_power"),
90
- gpuFreqMhz: typeof freqHz === "number" ? freqHz / 1e6 : null,
91
- thermal: d.thermal_pressure ?? null,
92
- };
93
- }
94
- stop() {
95
- this.stopped = true;
96
- if (this.proc) {
97
- try {
98
- this.proc.kill("SIGTERM");
99
- }
100
- catch {
101
- // ignore
102
- }
103
- }
104
- }
105
- }