@zeluizr/lattice 1.1.0 → 2.1.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,7 +4,7 @@ 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
- ## [1.1.0] — 2026-06-24
7
+ ## [2.0.0] — 2026-06-24
8
8
 
9
9
  ### Added
10
10
  - **GIT panel reports the host of each repo.** A new `HOST` column tags every
@@ -13,17 +13,31 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
13
13
  - **zgit server listing.** A footer line in the GIT panel lists the bare repos on
14
14
  the self-hosted zgit Docker container (`docker exec -u git <container> ls
15
15
  /repos`). Local-only, no network; fails soft (the line is hidden) when Docker
16
- or the container is unavailable. Container name via `--zgit` / config
17
- `zgitContainer` (default `zgit`).
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.
18
20
 
19
21
  ### Changed
20
- - **The GIT panel is now cwd-independent.** It scans a fixed, configured list of
21
- paths instead of the parent of the current directory, so the report is the
22
- same in every terminal/tab. Each `--repos` entry may be a repo itself or a
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
23
25
  folder whose subdirectories are repos; entries are merged and de-duplicated.
24
- - `--repos` now accepts a comma-separated list and is persisted to
25
- `~/.config/lattice/config.json` (`repoRoots`). With no flag and no saved list,
26
- it falls back to the parent of the cwd (previous behaviour).
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`.
27
41
 
28
42
  ## [1.0.0] — 2026-06-24
29
43
 
package/dist/app.js CHANGED
@@ -6,20 +6,22 @@ import { SystemCollector } from "./collectors/system.js";
6
6
  import { DisksCollector } from "./collectors/disks.js";
7
7
  import { GitCollector } from "./collectors/git.js";
8
8
  import { ZgitCollector } from "./collectors/zgit.js";
9
+ import { HFCollector } from "./collectors/huggingface.js";
9
10
  import { readGpu } from "./collectors/gpu.js";
10
11
  import { readSensors } from "./collectors/sensors.js";
11
- import { PowerCollector } from "./collectors/power.js";
12
12
  import { TokenCollector } from "./collectors/tokens.js";
13
13
  import { readVtex } from "./collectors/vtex.js";
14
- import { coreCell, fmtTok, humanBytes, humanRate, sparkline, statusLevel } from "./format.js";
14
+ import { agoShort, coreCell, fmtTok, humanBytes, humanRate, sparkline, statusLevel } from "./format.js";
15
15
  const MAX_HIST = 60;
16
16
  const push = (h, v) => [...h, v].slice(-MAX_HIST);
17
+ const DEFAULT_INTERVAL = 1; // seconds between refreshes (adjust at runtime with +/-)
18
+ const PROCS = 12; // number of top processes to list
17
19
  function timeStr() {
18
20
  const d = new Date();
19
21
  return d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", second: "2-digit" });
20
22
  }
21
23
  export function App(props) {
22
- const { t, pal, icon, usePower, useVtex, repoRoots, zgitContainer, topN } = props;
24
+ const { t, pal, icon, repoRoots, zgitContainer, hfCachePath } = props;
23
25
  const { exit } = useApp();
24
26
  const [sys, setSys] = useState(null);
25
27
  const [disks, setDisks] = useState(null);
@@ -27,16 +29,16 @@ export function App(props) {
27
29
  const [zgit, setZgit] = useState(null);
28
30
  const [gpu, setGpu] = useState(null);
29
31
  const [sensors, setSensors] = useState(null);
30
- const [power, setPower] = useState(null);
31
32
  const [tokens, setTokens] = useState(null);
32
33
  const [vtex, setVtex] = useState(null);
34
+ const [hf, setHf] = useState(null);
33
35
  const [hCpu, setHCpu] = useState([]);
34
36
  const [hMem, setHMem] = useState([]);
35
37
  const [hNet, setHNet] = useState([]);
36
38
  const [hGpu, setHGpu] = useState([]);
37
39
  const [hTemp, setHTemp] = useState([]);
38
40
  const [paused, setPaused] = useState(false);
39
- const [interval, setIntervalState] = useState(props.interval);
41
+ const [interval, setIntervalState] = useState(DEFAULT_INTERVAL);
40
42
  const [now, setNow] = useState(timeStr());
41
43
  const [cols, setCols] = useState(process.stdout.columns || 100);
42
44
  const sysRef = useRef(null);
@@ -44,7 +46,7 @@ export function App(props) {
44
46
  const gitRef = useRef(null);
45
47
  const zgitRef = useRef(null);
46
48
  const tokRef = useRef(null);
47
- const powerRef = useRef(null);
49
+ const hfRef = useRef(null);
48
50
  const pausedRef = useRef(false);
49
51
  const mounted = useRef(true);
50
52
  useEffect(() => {
@@ -53,15 +55,12 @@ export function App(props) {
53
55
  // ----- init collectors + power subprocess + clock -------------------------
54
56
  useEffect(() => {
55
57
  mounted.current = true;
56
- sysRef.current = new SystemCollector(topN);
58
+ sysRef.current = new SystemCollector(PROCS);
57
59
  disksRef.current = new DisksCollector();
58
60
  gitRef.current = new GitCollector(repoRoots);
59
61
  zgitRef.current = new ZgitCollector(zgitContainer);
60
62
  tokRef.current = new TokenCollector();
61
- if (usePower) {
62
- powerRef.current = new PowerCollector(Math.round(props.interval * 1000));
63
- powerRef.current.start();
64
- }
63
+ hfRef.current = new HFCollector(hfCachePath);
65
64
  const clock = setInterval(() => mounted.current && setNow(timeStr()), 1000);
66
65
  const onResize = () => setCols(process.stdout.columns || 100);
67
66
  process.stdout.on("resize", onResize);
@@ -69,7 +68,6 @@ export function App(props) {
69
68
  mounted.current = false;
70
69
  clearInterval(clock);
71
70
  process.stdout.off("resize", onResize);
72
- powerRef.current?.stop();
73
71
  };
74
72
  // eslint-disable-next-line react-hooks/exhaustive-deps
75
73
  }, []);
@@ -83,14 +81,12 @@ export function App(props) {
83
81
  readGpu(),
84
82
  readSensors(),
85
83
  ]);
86
- const p = powerRef.current ? powerRef.current.read() : null;
87
84
  if (!mounted.current)
88
85
  return;
89
86
  setSys(s);
90
87
  setDisks(d);
91
88
  setGpu(g);
92
89
  setSensors(st);
93
- setPower(p);
94
90
  setHCpu((h) => push(h, s.cpuTotal));
95
91
  setHMem((h) => push(h, s.memPercent));
96
92
  setHNet((h) => push(h, (s.netRecvBps + s.netSentBps) / 1024 / 1024));
@@ -102,11 +98,12 @@ export function App(props) {
102
98
  const refreshAux = useCallback(async () => {
103
99
  if (pausedRef.current || !tokRef.current)
104
100
  return;
105
- const [tk, vx, gt, zg] = await Promise.all([
101
+ const [tk, vx, gt, zg, hfd] = await Promise.all([
106
102
  tokRef.current.read(),
107
- useVtex ? readVtex() : Promise.resolve(null),
103
+ readVtex(),
108
104
  gitRef.current ? gitRef.current.read() : Promise.resolve(null),
109
105
  zgitRef.current ? zgitRef.current.read() : Promise.resolve(null),
106
+ hfRef.current ? hfRef.current.read() : Promise.resolve(null),
110
107
  ]);
111
108
  if (!mounted.current)
112
109
  return;
@@ -114,7 +111,8 @@ export function App(props) {
114
111
  setVtex(vx);
115
112
  setGit(gt);
116
113
  setZgit(zg);
117
- }, [useVtex]);
114
+ setHf(hfd);
115
+ }, []);
118
116
  useEffect(() => {
119
117
  refreshData();
120
118
  const id = setInterval(refreshData, Math.max(250, interval * 1000));
@@ -127,7 +125,6 @@ export function App(props) {
127
125
  }, [refreshAux]);
128
126
  useInput((input) => {
129
127
  if (input === "q") {
130
- powerRef.current?.stop();
131
128
  exit();
132
129
  }
133
130
  else if (input === "p") {
@@ -148,6 +145,12 @@ export function App(props) {
148
145
  const inner = (w) => Math.max(6, w - 4); // minus border (2) + padding (2)
149
146
  const w2 = inner(col2);
150
147
  const w3 = inner(col3);
148
+ // GIT + PROCESSES share a row on wide terminals; they stack when it's too
149
+ // narrow to split. GIT's columns are fixed-width, so it takes only what it
150
+ // needs (clamped) and PROCESSES inherits the slack — handy for long names.
151
+ const sideBySide = cols >= 92;
152
+ const gitW = Math.max(48, Math.min(58, Math.floor((cols - 2 * m) * 0.46)));
153
+ const procW = cols - 2 * m - gitW;
151
154
  const statColor = (lvl) => lvl === "ok" ? pal.green : lvl === "warn" ? pal.yellow : pal.red;
152
155
  const Stat = ({ value, warn, crit, metric }) => {
153
156
  const lvl = statusLevel(value, warn, crit);
@@ -170,8 +173,12 @@ export function App(props) {
170
173
  const util = gpu?.utilPct ?? 0;
171
174
  const diskRows = disks?.disks ?? [];
172
175
  const gitRepos = git?.repos ?? [];
176
+ const gitTotal = git?.total ?? 0;
173
177
  const zgitServer = zgit?.available ? zgit : null;
174
- const showGit = gitRepos.length > 0 || !!zgitServer;
178
+ const showGit = gitTotal > 0 || !!zgitServer;
179
+ const hfModels = hf?.models ?? [];
180
+ const showHf = !!hf?.available;
181
+ const nowSec = Math.floor(Date.now() / 1000);
175
182
  const hostLabel = (r) => r.hostKind === "github"
176
183
  ? "GitHub"
177
184
  : r.hostKind === "zgit"
@@ -192,14 +199,9 @@ export function App(props) {
192
199
  const modelExtra = modelItems.length > 2 ? ` +${modelItems.length - 2}` : "";
193
200
  const web = tokens && (tokens.webSearch || tokens.webFetch) ? ` · web ${tokens.webSearch}/${tokens.webFetch}` : "";
194
201
  const models = modelParts.length ? modelParts.join(" · ") + modelExtra + web : t("tokens.none");
195
- const powerParts = [];
196
- if (power && (power.cpuW != null || power.gpuW != null))
197
- powerParts.push(`${icon("power")} ${(power.cpuW ?? 0).toFixed(1)}+${(power.gpuW ?? 0).toFixed(1)}W`);
198
- else if (usePower)
199
- powerParts.push(`${icon("power")} ${t("temp.waitingSudo")}`);
200
- if (sensors?.fans?.length)
201
- powerParts.push(`${icon("fan")} ${sensors.fans[0].rpm.toFixed(0)} rpm`);
202
- const tempLine2 = powerParts.length ? powerParts.join(" · ") : t("temp.needsSudo");
202
+ const tempLine2 = sensors?.fans?.length
203
+ ? `${icon("fan")} ${sensors.fans[0].rpm.toFixed(0)} rpm`
204
+ : "";
203
205
  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", {
204
206
  used: humanBytes(sys?.memUsed),
205
207
  total: humanBytes(sys?.memTotal),
@@ -207,18 +209,23 @@ export function App(props) {
207
209
  }) }), _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) => {
208
210
  const lvl = statusLevel(d.usePercent, 80, 92);
209
211
  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));
210
- }))] }), _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", {
212
+ }))] }), showHf && (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: pal.yellow, paddingX: 1, marginRight: 1, width: fullW, children: [_jsxs(Text, { color: pal.yellow, bold: true, children: [icon("hf"), " ", t("panel.hf")] }), _jsxs(Text, { color: pal.purple, bold: true, wrap: "truncate", children: [t("hf.model").padEnd(30), t("hf.size").padEnd(9), t("hf.type").padEnd(16), t("hf.state")] }), hfModels.map((mdl) => (_jsxs(Text, { wrap: "truncate", children: [mdl.id.slice(0, 29).padEnd(30), humanBytes(mdl.sizeBytes).padEnd(9), (mdl.modelType || "—").slice(0, 15).padEnd(16), mdl.active ? (_jsxs(Text, { color: pal.green, children: ["\u25CF ", t("hf.active"), " ", mdl.procName.slice(0, 14), " ", mdl.pid] })) : mdl.lastUsed > 0 ? (_jsx(Text, { color: pal.comment, children: t("hf.usedAgo", { ago: agoShort(mdl.lastUsed, nowSec) }) })) : (_jsx(Text, { color: pal.comment, children: t("hf.idle") }))] }, mdl.id))), _jsx(Text, { color: pal.comment, wrap: "truncate", children: t("hf.summary", {
213
+ path: hf?.cachePath ?? "",
214
+ size: humanBytes(hf?.totalBytes),
215
+ n: hfModels.length,
216
+ active: hf?.activeCount ?? 0,
217
+ }) })] })), _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", {
211
218
  total: fmtTok(tokTotal),
212
219
  input: fmtTok(tokens?.input),
213
220
  output: fmtTok(tokens?.output),
214
- }) }), _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") })] })) }))] }), showGit && (_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] }), gitRepos.length > 0 && (_jsxs(Text, { color: pal.purple, bold: true, wrap: "truncate", children: [t("git.repo").padEnd(20), t("git.branch").padEnd(18), t("git.state").padEnd(10), t("git.host")] })), gitRepos.map((r) => {
215
- const label = r.detached ? "(detached)" : r.branch;
216
- return (_jsxs(Text, { wrap: "truncate", children: [r.name.slice(0, 19).padEnd(20), label.slice(0, 17).padEnd(18), _jsx(Text, { color: r.dirty ? pal.yellow : pal.green, children: "\u25CF" }), ` ${r.dirty ? t("git.dirty") : t("git.clean")}`.padEnd(9), _jsx(Text, { color: hostColor(r), children: hostLabel(r).slice(0, 10).padEnd(10) }), 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.path));
217
- }), zgitServer && (_jsx(Text, { color: pal.comment, wrap: "truncate", children: zgitServer.repos.length
218
- ? t("git.server", {
219
- container: zgitServer.container,
220
- list: zgitServer.repos.join(", "),
221
- n: zgitServer.repos.length,
222
- })
223
- : t("git.serverEmpty", { container: zgitServer.container }) }))] })), _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"] }) })] }));
221
+ }) }), _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) => {
222
+ const label = r.detached ? "(detached)" : r.branch;
223
+ 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));
224
+ }), zgitServer && (_jsx(Text, { color: pal.comment, wrap: "truncate", children: zgitServer.repos.length
225
+ ? t("git.server", {
226
+ container: zgitServer.container,
227
+ list: zgitServer.repos.join(", "),
228
+ n: zgitServer.repos.length,
229
+ })
230
+ : 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"] }) })] }));
224
231
  }
package/dist/cli.js CHANGED
@@ -3,72 +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 comma-separated folders and/or repos the GIT panel always
23
- shows, regardless of where lattice is launched. A path
24
- that is itself a repo is shown directly; otherwise its
25
- subdirectories are scanned. Saved to config once set.
26
- (default: saved list, else the parent of the current dir)
27
- --zgit Docker container of the self-hosted zgit server to list
28
- (default: zgit; the line is hidden when unavailable)
29
- --interval, -i refresh interval in seconds (default 1)
30
- --procs, -n number of top processes to show (default 8)
31
- --icons icon style: nerd | emoji | none (default nerd)
32
- --lang language: en | es | pt-BR (asked on first run)
33
- --theme pro | blade | buffy | lincoln | morbius | van-helsing
34
- --version, -v
35
- --help
36
-
37
- Examples
38
19
  $ lattice
39
- $ lattice --no-power --icons emoji
40
- $ lattice --lang es --theme blade
41
- `, {
42
- importMeta: import.meta,
43
- description: false,
44
- flags: {
45
- power: { type: "boolean", default: true },
46
- vtex: { type: "boolean", default: true },
47
- repos: { type: "string", default: "" },
48
- zgit: { type: "string", default: "" },
49
- interval: { type: "number", shortFlag: "i", default: 1 },
50
- procs: { type: "number", shortFlag: "n", default: 8 },
51
- icons: { type: "string", default: "" },
52
- lang: { type: "string", default: "" },
53
- theme: { type: "string", default: "" },
54
- },
55
- });
56
- async function resolveLang() {
57
- const cfg = loadConfig();
58
- const flag = cli.flags.lang;
59
- if (flag && isLang(flag)) {
60
- saveConfig({ lang: flag });
61
- return flag;
62
- }
63
- if (cfg.lang)
64
- return cfg.lang;
65
- // 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;
66
29
  if (!process.stdin.isTTY) {
67
30
  const detected = detectLang();
68
31
  saveConfig({ lang: detected });
69
32
  return detected;
70
33
  }
71
- const variant = resolveTheme();
72
34
  const chosen = await new Promise((resolve) => {
73
35
  const app = render(_jsx(LanguageSelect, { pal: palette(variant), onSelect: (l) => {
74
36
  app.unmount();
@@ -78,69 +40,16 @@ async function resolveLang() {
78
40
  saveConfig({ lang: chosen });
79
41
  return chosen;
80
42
  }
81
- function resolveTheme() {
82
- const flag = cli.flags.theme;
83
- if (flag && isVariant(flag)) {
84
- saveConfig({ theme: flag });
85
- return flag;
86
- }
87
- return loadConfig().theme ?? "pro";
88
- }
89
- function resolveIcons() {
90
- const flag = cli.flags.icons;
91
- if (flag && isIconMode(flag)) {
92
- saveConfig({ icons: flag });
93
- return flag;
94
- }
95
- return loadConfig().icons ?? "nerd";
96
- }
97
- /** The fixed set of folders/repos the GIT panel scans — independent of cwd. */
98
- function resolveRepoRoots() {
99
- const flag = cli.flags.repos.trim();
100
- if (flag) {
101
- const roots = flag.split(",").map((s) => s.trim()).filter(Boolean);
102
- if (roots.length) {
103
- saveConfig({ repoRoots: roots });
104
- return roots;
105
- }
106
- }
107
- const saved = loadConfig().repoRoots;
108
- if (saved && saved.length)
109
- return saved;
110
- // First run / no config: fall back to the parent of the cwd so the tool still
111
- // works out of the box. Pass --repos once to pin a stable, cwd-independent list.
112
- return [dirname(process.cwd())];
113
- }
114
- function resolveZgitContainer() {
115
- const flag = cli.flags.zgit.trim();
116
- if (flag) {
117
- saveConfig({ zgitContainer: flag });
118
- return flag;
119
- }
120
- return loadConfig().zgitContainer ?? "zgit";
121
- }
122
43
  async function main() {
123
- const lang = await resolveLang();
124
- const variant = resolveTheme();
125
- const iconMode = resolveIcons();
126
- const repoRoots = resolveRepoRoots();
127
- const zgitContainer = resolveZgitContainer();
44
+ const cfg = loadConfig();
45
+ const variant = cfg.theme ?? "pro";
46
+ const lang = await resolveLang(variant);
128
47
  const t = makeT(lang);
129
48
  const pal = palette(variant);
130
- const icon = makeIcons(iconMode);
131
- let usePower = cli.flags.power;
132
- // Pre-authenticate sudo BEFORE the TUI takes over the terminal.
133
- if (usePower) {
134
- process.stdout.write(t("cli.sudoNeed") + "\n");
135
- try {
136
- await execa("sudo", ["-v"], { stdio: "inherit" });
137
- }
138
- catch {
139
- process.stdout.write(t("cli.sudoFail") + "\n");
140
- usePower = false;
141
- }
142
- }
143
- const { waitUntilExit } = render(_jsx(App, { t: t, pal: pal, icon: icon, lang: lang, usePower: usePower, useVtex: cli.flags.vtex, repoRoots: repoRoots, zgitContainer: zgitContainer, 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, hfCachePath: cfg.hfCachePath }));
144
53
  await waitUntilExit();
145
54
  }
146
55
  main().catch((e) => {
@@ -20,7 +20,8 @@ import { readdir } from "node:fs/promises";
20
20
  import { existsSync } from "node:fs";
21
21
  import { join, basename } from "node:path";
22
22
  const run = promisify(execFile);
23
- 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
24
25
  const CONCURRENCY = 8;
25
26
  export class GitCollector {
26
27
  roots;
@@ -28,7 +29,7 @@ export class GitCollector {
28
29
  last;
29
30
  constructor(roots) {
30
31
  this.roots = roots;
31
- this.last = { roots, repos: [], truncated: false };
32
+ this.last = { roots, repos: [], total: 0, truncated: false };
32
33
  }
33
34
  async read() {
34
35
  if (this.inflight)
@@ -36,7 +37,6 @@ export class GitCollector {
36
37
  this.inflight = true;
37
38
  try {
38
39
  const dirs = await this.discover();
39
- const truncated = dirs.length > MAX_REPOS;
40
40
  const pick = dirs.slice(0, MAX_REPOS);
41
41
  const repos = [];
42
42
  for (let i = 0; i < pick.length; i += CONCURRENCY) {
@@ -46,8 +46,19 @@ export class GitCollector {
46
46
  if (r)
47
47
  repos.push(r);
48
48
  }
49
- repos.sort((a, b) => a.name.localeCompare(b.name));
50
- this.last = { roots: this.roots, 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
+ };
51
62
  return this.last;
52
63
  }
53
64
  catch {
@@ -87,11 +98,14 @@ export class GitCollector {
87
98
  }
88
99
  }
89
100
  async function readRepo(dir) {
90
- const [statusRes, urlRes] = await Promise.all([
101
+ const [statusRes, urlRes, dateRes] = await Promise.all([
91
102
  run("git", ["-C", dir, "status", "--porcelain=v2", "--branch"], { maxBuffer: 1 << 20 }),
92
103
  run("git", ["-C", dir, "remote", "get-url", "origin"], { maxBuffer: 1 << 16 }).catch(() => ({
93
104
  stdout: "",
94
105
  })),
106
+ run("git", ["-C", dir, "log", "-1", "--format=%ct"], { maxBuffer: 1 << 16 }).catch(() => ({
107
+ stdout: "",
108
+ })),
95
109
  ]);
96
110
  let branch = "?";
97
111
  let ahead = 0;
@@ -116,7 +130,8 @@ async function readRepo(dir) {
116
130
  }
117
131
  }
118
132
  const { host, hostKind } = classifyHost(urlRes.stdout.trim());
119
- return { name: basename(dir), path: dir, branch, ahead, behind, detached, dirty, host, hostKind };
133
+ const lastCommit = Number(dateRes.stdout.trim()) || 0;
134
+ return { name: basename(dir), path: dir, branch, ahead, behind, detached, dirty, host, hostKind, lastCommit };
120
135
  }
121
136
  /** Pull the hostname out of an origin URL and bucket it into a known host. */
122
137
  export function classifyHost(url) {
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Local HuggingFace hub cache — which models are installed, and which are live.
3
+ *
4
+ * The hub cache is the standard layout `<cache>/models--<org>--<name>/` with
5
+ * `refs/main` (the checked-out revision), `snapshots/<rev>/` (symlinks) and
6
+ * `blobs/<sha>` (the real file content). Enumerating models is pure filesystem
7
+ * work — no dependency on the `hf` CLI, so it keeps working under `npx`.
8
+ *
9
+ * Two usage signals are layered on top:
10
+ * - "active" — a process currently holds one of the model's files open (a
11
+ * single `lsof` pass; the file's path always contains the model's
12
+ * `models--org--name` segment because each repo has its own `blobs/`).
13
+ * - "lastUsed" — the atime of the model's largest blob (the weights), which
14
+ * reflects the last real read. `statSync` reads metadata only, so polling it
15
+ * never updates atime; the weights blob is used (never config.json) so our
16
+ * own one-time config read can't pollute the signal.
17
+ *
18
+ * Fail-soft like every collector: any error returns the previous snapshot.
19
+ */
20
+ import { execFile } from "node:child_process";
21
+ import { promisify } from "node:util";
22
+ import { homedir } from "node:os";
23
+ import { join } from "node:path";
24
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
25
+ const run = promisify(execFile);
26
+ /** Resolve the hub cache dir: config → HF_HUB_CACHE → HF_HOME/hub → ~/.cache. */
27
+ function resolveCachePath(override) {
28
+ if (override)
29
+ return override;
30
+ if (process.env.HF_HUB_CACHE)
31
+ return process.env.HF_HUB_CACHE;
32
+ if (process.env.HF_HOME)
33
+ return join(process.env.HF_HOME, "hub");
34
+ return join(homedir(), ".cache", "huggingface", "hub");
35
+ }
36
+ export class HFCollector {
37
+ inflight = false;
38
+ last;
39
+ cachePath;
40
+ // model_type is static per revision and reading config.json bumps its atime,
41
+ // so we read it once and cache it keyed by "<dirName>@<revision>".
42
+ typeCache = new Map();
43
+ constructor(override) {
44
+ this.cachePath = resolveCachePath(override);
45
+ this.last = { available: false, cachePath: this.cachePath, totalBytes: 0, activeCount: 0, models: [] };
46
+ }
47
+ async read() {
48
+ if (this.inflight)
49
+ return this.last;
50
+ this.inflight = true;
51
+ try {
52
+ if (!existsSync(this.cachePath)) {
53
+ this.last = { available: false, cachePath: this.cachePath, totalBytes: 0, activeCount: 0, models: [] };
54
+ return this.last;
55
+ }
56
+ const dirs = readdirSync(this.cachePath, { withFileTypes: true })
57
+ .filter((e) => e.isDirectory() && e.name.startsWith("models--"))
58
+ .map((e) => e.name);
59
+ const byDir = new Map();
60
+ const models = [];
61
+ for (const dirName of dirs) {
62
+ const m = this.readModel(dirName);
63
+ if (m) {
64
+ models.push(m.model);
65
+ byDir.set(dirName, m.model);
66
+ }
67
+ }
68
+ // One lsof pass marks the models whose files a process currently holds.
69
+ await this.markActive(byDir);
70
+ const totalBytes = models.reduce((a, m) => a + m.sizeBytes, 0);
71
+ const activeCount = models.filter((m) => m.active).length;
72
+ models.sort((a, b) => Number(b.active) - Number(a.active) || b.lastUsed - a.lastUsed || b.sizeBytes - a.sizeBytes);
73
+ this.last = { available: models.length > 0, cachePath: this.cachePath, totalBytes, activeCount, models };
74
+ return this.last;
75
+ }
76
+ catch {
77
+ return this.last;
78
+ }
79
+ finally {
80
+ this.inflight = false;
81
+ }
82
+ }
83
+ /** Size (sum of blobs), weights atime, model_type and revision for one repo. */
84
+ readModel(dirName) {
85
+ try {
86
+ const id = dirName.replace(/^models--/, "").replace(/--/g, "/");
87
+ const name = id.slice(id.lastIndexOf("/") + 1);
88
+ const base = join(this.cachePath, dirName);
89
+ // Full hash locates the snapshot dir; a short prefix is what we display.
90
+ const revFull = readFileSync(join(base, "refs", "main"), "utf8").trim();
91
+ const revision = revFull.slice(0, 12);
92
+ // One pass over blobs: total size + atime of the largest blob (weights).
93
+ let sizeBytes = 0;
94
+ let biggest = -1;
95
+ let lastUsed = 0;
96
+ const blobsDir = join(base, "blobs");
97
+ if (existsSync(blobsDir)) {
98
+ for (const f of readdirSync(blobsDir)) {
99
+ try {
100
+ const st = statSync(join(blobsDir, f));
101
+ if (!st.isFile())
102
+ continue;
103
+ sizeBytes += st.size;
104
+ if (st.size > biggest) {
105
+ biggest = st.size;
106
+ lastUsed = Math.floor(st.atimeMs / 1000);
107
+ }
108
+ }
109
+ catch {
110
+ // skip unreadable blob
111
+ }
112
+ }
113
+ }
114
+ const modelType = this.readType(dirName, revFull, base);
115
+ return {
116
+ model: { id, name, sizeBytes, modelType, revision, active: false, procName: "", pid: 0, lastUsed },
117
+ };
118
+ }
119
+ catch {
120
+ return null;
121
+ }
122
+ }
123
+ /** config.json `model_type`, cached per revision (its read would bump atime). */
124
+ readType(dirName, revision, base) {
125
+ const key = `${dirName}@${revision}`;
126
+ const hit = this.typeCache.get(key);
127
+ if (hit !== undefined)
128
+ return hit;
129
+ let modelType = "";
130
+ try {
131
+ const cfg = JSON.parse(readFileSync(join(base, "snapshots", revision, "config.json"), "utf8"));
132
+ if (typeof cfg.model_type === "string")
133
+ modelType = cfg.model_type;
134
+ }
135
+ catch {
136
+ // no config.json (e.g. some MLX repos) — leave type blank
137
+ }
138
+ this.typeCache.set(key, modelType);
139
+ return modelType;
140
+ }
141
+ /** Single `lsof` pass; tag each model a process is holding files open for. */
142
+ async markActive(byDir) {
143
+ if (byDir.size === 0)
144
+ return;
145
+ // `lsof -nP` often exits non-zero with warnings about other users' procs
146
+ // while still printing useful stdout — keep the partial output from the error.
147
+ const { stdout } = await run("lsof", ["-nP", "-F", "pcn"], { maxBuffer: 1 << 24 }).catch((e) => ({ stdout: e?.stdout ?? "" }));
148
+ let pid = 0;
149
+ let cmd = "";
150
+ for (const line of stdout.split("\n")) {
151
+ const tag = line[0];
152
+ const val = line.slice(1);
153
+ if (tag === "p")
154
+ pid = Number(val) || 0;
155
+ else if (tag === "c")
156
+ cmd = val;
157
+ else if (tag === "n") {
158
+ const seg = val.match(/\/(models--[^/]+)\//);
159
+ if (!seg)
160
+ continue;
161
+ const model = byDir.get(seg[1]);
162
+ if (model && !model.active) {
163
+ model.active = true;
164
+ model.pid = pid;
165
+ model.procName = cmd;
166
+ }
167
+ }
168
+ }
169
+ }
170
+ }
package/dist/config.js CHANGED
@@ -27,6 +27,8 @@ export function loadConfig() {
27
27
  }
28
28
  if (typeof raw.zgitContainer === "string" && raw.zgitContainer)
29
29
  cfg.zgitContainer = raw.zgitContainer;
30
+ if (typeof raw.hfCachePath === "string" && raw.hfCachePath)
31
+ cfg.hfCachePath = raw.hfCachePath;
30
32
  return cfg;
31
33
  }
32
34
  catch {
package/dist/format.js CHANGED
@@ -45,6 +45,20 @@ export function sparkline(history, width = 0) {
45
45
  })
46
46
  .join("");
47
47
  }
48
+ /** Short relative time ("now" / "3m" / "2h" / "5d") from a unix-seconds stamp. */
49
+ export function agoShort(unixSeconds, nowSeconds) {
50
+ const ts = Number(unixSeconds || 0);
51
+ if (!ts)
52
+ return "—";
53
+ const s = Math.max(0, Math.floor(nowSeconds - ts));
54
+ if (s < 60)
55
+ return "now";
56
+ if (s < 3600)
57
+ return `${Math.floor(s / 60)}m`;
58
+ if (s < 86400)
59
+ return `${Math.floor(s / 3600)}h`;
60
+ return `${Math.floor(s / 86400)}d`;
61
+ }
48
62
  /** Pick a status level by thresholds (mirrors the Python status() helper). */
49
63
  export function statusLevel(value, warn, crit) {
50
64
  const v = Number(value || 0);
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",
@@ -11,6 +11,7 @@ export const en = {
11
11
  "panel.gpu": "GPU",
12
12
  "panel.tokens": "AI · TOKENS TODAY",
13
13
  "panel.vtex": "VTEX",
14
+ "panel.hf": "HUGGINGFACE",
14
15
  "panel.procs": "PROCESSES",
15
16
  "status.cpu.ok": "calm",
16
17
  "status.cpu.warn": "busy",
@@ -40,16 +41,14 @@ export const en = {
40
41
  "git.branch": "BRANCH",
41
42
  "git.state": "STATE",
42
43
  "git.host": "HOST",
43
- "git.clean": "clean",
44
- "git.dirty": "dirty",
44
+ "git.uncommitted": "uncommitted",
45
+ "git.allClear": "✓ all {n} repos up to date",
45
46
  "git.local": "local",
46
47
  "git.server": "— zgit server ({container}): {list} ({n})",
47
48
  "git.serverEmpty": "— zgit server ({container}): no repos",
48
49
  "gpu.usage": "Usage",
49
50
  "gpu.mem": "mem {used}/{alloc}",
50
51
  "temp.unavailable": "sensors unavailable",
51
- "temp.waitingSudo": "waiting for sudo",
52
- "temp.needsSudo": "power: requires sudo",
53
52
  "spark.collecting": "collecting…",
54
53
  "spark.lastMin": "last min: {range}",
55
54
  "tokens.spent": "Spent today: ${cost} · {messages} messages",
@@ -66,6 +65,15 @@ export const en = {
66
65
  "vtex.workspace": "Workspace",
67
66
  "vtex.notConnected": "not connected",
68
67
  "vtex.signin": "Sign in with: vtex login <account>",
68
+ "hf.model": "MODEL",
69
+ "hf.size": "SIZE",
70
+ "hf.type": "TYPE",
71
+ "hf.state": "STATE",
72
+ "hf.active": "active",
73
+ "hf.idle": "idle",
74
+ "hf.usedAgo": "used {ago} ago",
75
+ "hf.empty": "no models cached",
76
+ "hf.summary": "{path} · {size} · {n} models · {active} active",
69
77
  "proc.cpu": "CPU%",
70
78
  "proc.mem": "MEM",
71
79
  "proc.pid": "PID",
@@ -74,7 +82,4 @@ export const en = {
74
82
  "key.pause": "Pause",
75
83
  "key.faster": "Faster",
76
84
  "key.slower": "Slower",
77
- "cli.sudoNeed": "lattice needs sudo to read power (powermetrics).",
78
- "cli.sudoFail": "sudo unavailable — continuing without power data.",
79
- "cli.sudoCancel": "cancelled.",
80
85
  };
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",
@@ -10,6 +10,7 @@ export const es = {
10
10
  "panel.gpu": "GPU",
11
11
  "panel.tokens": "IA · TOKENS HOY",
12
12
  "panel.vtex": "VTEX",
13
+ "panel.hf": "HUGGINGFACE",
13
14
  "panel.procs": "PROCESOS",
14
15
  "status.cpu.ok": "tranquilo",
15
16
  "status.cpu.warn": "ocupado",
@@ -39,16 +40,14 @@ export const es = {
39
40
  "git.branch": "BRANCH",
40
41
  "git.state": "ESTADO",
41
42
  "git.host": "HOST",
42
- "git.clean": "limpio",
43
- "git.dirty": "sucio",
43
+ "git.uncommitted": "sin commit",
44
+ "git.allClear": "✓ {n} repos al día",
44
45
  "git.local": "local",
45
46
  "git.server": "— servidor zgit ({container}): {list} ({n})",
46
47
  "git.serverEmpty": "— servidor zgit ({container}): sin repos",
47
48
  "gpu.usage": "Uso",
48
49
  "gpu.mem": "mem {used}/{alloc}",
49
50
  "temp.unavailable": "sensores no disponibles",
50
- "temp.waitingSudo": "esperando sudo",
51
- "temp.needsSudo": "energía: requiere sudo",
52
51
  "spark.collecting": "recolectando…",
53
52
  "spark.lastMin": "último min: {range}",
54
53
  "tokens.spent": "Gastado hoy: ${cost} · {messages} mensajes",
@@ -65,6 +64,15 @@ export const es = {
65
64
  "vtex.workspace": "Workspace",
66
65
  "vtex.notConnected": "no conectado",
67
66
  "vtex.signin": "Entra con: vtex login <cuenta>",
67
+ "hf.model": "MODELO",
68
+ "hf.size": "TAMAÑO",
69
+ "hf.type": "TIPO",
70
+ "hf.state": "ESTADO",
71
+ "hf.active": "activo",
72
+ "hf.idle": "inactivo",
73
+ "hf.usedAgo": "usado hace {ago}",
74
+ "hf.empty": "sin modelos en caché",
75
+ "hf.summary": "{path} · {size} · {n} modelos · {active} activos",
68
76
  "proc.cpu": "CPU%",
69
77
  "proc.mem": "MEM",
70
78
  "proc.pid": "PID",
@@ -73,7 +81,4 @@ export const es = {
73
81
  "key.pause": "Pausar",
74
82
  "key.faster": "Más rápido",
75
83
  "key.slower": "Más lento",
76
- "cli.sudoNeed": "lattice necesita sudo para leer la energía (powermetrics).",
77
- "cli.sudoFail": "sudo no disponible — siguiendo sin datos de energía.",
78
- "cli.sudoCancel": "cancelado.",
79
84
  };
@@ -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",
@@ -10,6 +10,7 @@ export const ptBR = {
10
10
  "panel.gpu": "GPU",
11
11
  "panel.tokens": "IA · TOKENS HOJE",
12
12
  "panel.vtex": "VTEX",
13
+ "panel.hf": "HUGGINGFACE",
13
14
  "panel.procs": "PROCESSOS",
14
15
  "status.cpu.ok": "tranquilo",
15
16
  "status.cpu.warn": "ocupado",
@@ -39,16 +40,14 @@ export const ptBR = {
39
40
  "git.branch": "BRANCH",
40
41
  "git.state": "ESTADO",
41
42
  "git.host": "HOST",
42
- "git.clean": "limpo",
43
- "git.dirty": "sujo",
43
+ "git.uncommitted": "sem commit",
44
+ "git.allClear": "✓ todos os {n} repos em dia",
44
45
  "git.local": "local",
45
46
  "git.server": "— servidor zgit ({container}): {list} ({n})",
46
47
  "git.serverEmpty": "— servidor zgit ({container}): sem repos",
47
48
  "gpu.usage": "Uso",
48
49
  "gpu.mem": "mem {used}/{alloc}",
49
50
  "temp.unavailable": "sensores indisponíveis",
50
- "temp.waitingSudo": "aguardando sudo",
51
- "temp.needsSudo": "energia: requer sudo",
52
51
  "spark.collecting": "coletando…",
53
52
  "spark.lastMin": "último min: {range}",
54
53
  "tokens.spent": "Gasto hoje: ${cost} · {messages} mensagens",
@@ -65,6 +64,15 @@ export const ptBR = {
65
64
  "vtex.workspace": "Workspace",
66
65
  "vtex.notConnected": "não conectado",
67
66
  "vtex.signin": "Entre com: vtex login <conta>",
67
+ "hf.model": "MODELO",
68
+ "hf.size": "TAMANHO",
69
+ "hf.type": "TIPO",
70
+ "hf.state": "ESTADO",
71
+ "hf.active": "ativo",
72
+ "hf.idle": "ocioso",
73
+ "hf.usedAgo": "usado há {ago}",
74
+ "hf.empty": "nenhum modelo em cache",
75
+ "hf.summary": "{path} · {size} · {n} modelos · {active} ativos",
68
76
  "proc.cpu": "CPU%",
69
77
  "proc.mem": "MEM",
70
78
  "proc.pid": "PID",
@@ -73,7 +81,4 @@ export const ptBR = {
73
81
  "key.pause": "Pausar",
74
82
  "key.faster": "Mais rápido",
75
83
  "key.slower": "Mais lento",
76
- "cli.sudoNeed": "lattice precisa de sudo para ler energia (powermetrics).",
77
- "cli.sudoFail": "sudo indisponível — seguindo sem dados de energia.",
78
- "cli.sudoCancel": "cancelado.",
79
84
  };
package/dist/icons.js CHANGED
@@ -19,6 +19,7 @@ const NERD = {
19
19
  tokens: "", // money
20
20
  vtex: "", // shopping-cart
21
21
  git: "\uF126", // code-fork
22
+ hf: "\uF1B3", // cubes (model artifacts)
22
23
  proc: "", // tasks
23
24
  clock: "", // clock
24
25
  };
@@ -36,6 +37,7 @@ const EMOJI = {
36
37
  tokens: "🪙",
37
38
  vtex: "🛒",
38
39
  git: "🌿",
40
+ hf: "🤗",
39
41
  proc: "📋",
40
42
  clock: "🕐",
41
43
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeluizr/lattice",
3
- "version": "1.1.0",
3
+ "version": "2.1.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"
@@ -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
- }