agenthud 0.10.0 → 0.11.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/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  An observability layer for [Claude Code](https://github.com/anthropics/claude-code). **See** your live sessions, **export** structured activity logs, and **summarize** a day or a week into an LLM digest — all from one CLI.
8
8
 
9
- ![demo](./output960.gif)
9
+ ![demo](./demo/live.gif)
10
10
 
11
11
  AgentHUD reads Claude Code's session files from `~/.claude/projects/` and gives you three things:
12
12
 
@@ -134,7 +134,8 @@ When tracking is on, the tree panel's title shows `[LIVE ⠧]` and the status ba
134
134
 
135
135
  | Key | Action |
136
136
  |-----|--------|
137
- | `↑` / `k` / `↓` / `j` | Scroll |
137
+ | `↑` / `k` / `↓` / `j` | Scroll one line |
138
+ | `Ctrl+U` / `Ctrl+D` | Half page up / down |
138
139
  | `↵` / `Esc` / `q` | Close |
139
140
 
140
141
  Detail view colors the content based on activity type:
package/dist/index.js CHANGED
@@ -15,4 +15,4 @@ Error: Node.js ${MIN_NODE_VERSION}+ is required (current: ${process.version})
15
15
  process.exit(1);
16
16
  }
17
17
  if (!process.env.NODE_ENV) process.env.NODE_ENV = "production";
18
- import("./main-HPL3AG6B.js");
18
+ import("./main-KTFHI6KH.js");
@@ -1,24 +1,14 @@
1
1
  // src/main.ts
2
- import { existsSync as existsSync6, rmSync } from "fs";
2
+ import { existsSync as existsSync6, readdirSync as readdirSync2, realpathSync, rmSync } from "fs";
3
3
  import { homedir as homedir5 } from "os";
4
4
  import { join as join6 } from "path";
5
5
  import { createInterface as createInterface2 } from "readline";
6
-
7
- // src/utils/legacyConfig.ts
8
- import { join, resolve } from "path";
9
- function isLegacyProjectConfig(cwd, home) {
10
- const legacy = resolve(join(cwd, ".agenthud", "config.yaml"));
11
- const global = resolve(join(home, ".agenthud", "config.yaml"));
12
- return legacy !== global;
13
- }
14
-
15
- // src/main.ts
16
6
  import { render } from "ink";
17
7
  import React from "react";
18
8
 
19
9
  // src/cli.ts
20
10
  import { readFileSync } from "fs";
21
- import { dirname, join as join2 } from "path";
11
+ import { dirname, join } from "path";
22
12
  import { fileURLToPath } from "url";
23
13
  var ALL_TYPES = [
24
14
  "response",
@@ -37,7 +27,8 @@ var KNOWN_WATCH_FLAGS = /* @__PURE__ */ new Set([
37
27
  "-V",
38
28
  "--version",
39
29
  "-h",
40
- "--help"
30
+ "--help",
31
+ "--cwd"
41
32
  ]);
42
33
  var KNOWN_REPORT_FLAGS = /* @__PURE__ */ new Set([
43
34
  "--date",
@@ -66,6 +57,9 @@ Monitors all running Claude Code sessions in real-time.
66
57
  Options:
67
58
  -w, --watch Watch mode (default) \u2014 live updates
68
59
  --once Print once and exit
60
+ --cwd Scope the view to the Claude project
61
+ containing the current directory.
62
+ Exits 1 if no such project is found.
69
63
  -V, --version Show version number
70
64
  -h, --help Show this help message
71
65
 
@@ -103,7 +97,7 @@ Config: ~/.agenthud/config.yaml
103
97
  function getVersion() {
104
98
  const __dirname2 = dirname(fileURLToPath(import.meta.url));
105
99
  const packageJson = JSON.parse(
106
- readFileSync(join2(__dirname2, "..", "package.json"), "utf-8")
100
+ readFileSync(join(__dirname2, "..", "package.json"), "utf-8")
107
101
  );
108
102
  return packageJson.version;
109
103
  }
@@ -144,7 +138,7 @@ function parseArgs(args) {
144
138
  return { mode: "watch", command: "version" };
145
139
  }
146
140
  if (args.includes("--once")) {
147
- return { mode: "once" };
141
+ return args.includes("--cwd") ? { mode: "once", scopeToCwd: true } : { mode: "once" };
148
142
  }
149
143
  if (args[0] === "report") {
150
144
  const rest = args.slice(1);
@@ -367,54 +361,16 @@ function parseArgs(args) {
367
361
  };
368
362
  }
369
363
  }
370
- return { mode: "watch" };
371
- }
372
-
373
- // src/utils/altScreen.ts
374
- var ENTER = "\x1B[?1049h";
375
- var LEAVE = "\x1B[?1049l";
376
- var entered = false;
377
- var left = false;
378
- function enterAltScreen() {
379
- if (entered) return;
380
- entered = true;
381
- process.stdout.write(ENTER);
382
- }
383
- function leaveAltScreen() {
384
- if (left || !entered) return;
385
- left = true;
386
- process.stdout.write(LEAVE);
387
- }
388
- var hooksInstalled = false;
389
- function installAltScreenCleanup() {
390
- if (hooksInstalled) return;
391
- hooksInstalled = true;
392
- process.on("exit", () => {
393
- leaveAltScreen();
394
- });
395
- process.on("SIGINT", () => {
396
- leaveAltScreen();
397
- process.exit(130);
398
- });
399
- process.on("SIGTERM", () => {
400
- leaveAltScreen();
401
- process.exit(143);
402
- });
403
- process.on("uncaughtException", (err) => {
404
- leaveAltScreen();
405
- setImmediate(() => {
406
- throw err;
407
- });
408
- });
364
+ return args.includes("--cwd") ? { mode: "watch", scopeToCwd: true } : { mode: "watch" };
409
365
  }
410
366
 
411
367
  // src/config/globalConfig.ts
412
368
  import { existsSync, mkdirSync, readFileSync as readFileSync2, writeFileSync } from "fs";
413
369
  import { homedir } from "os";
414
- import { join as join3 } from "path";
370
+ import { join as join2 } from "path";
415
371
  import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
416
- var CONFIG_PATH = join3(homedir(), ".agenthud", "config.yaml");
417
- var STATE_PATH = join3(homedir(), ".agenthud", "state.yaml");
372
+ var CONFIG_PATH = join2(homedir(), ".agenthud", "config.yaml");
373
+ var STATE_PATH = join2(homedir(), ".agenthud", "state.yaml");
418
374
  var DEFAULT_GLOBAL_CONFIG = {
419
375
  refreshIntervalMs: 2e3,
420
376
  hiddenSessions: [],
@@ -436,7 +392,7 @@ function parseInterval(value) {
436
392
  return match[2] === "m" ? n * 60 * 1e3 : n * 1e3;
437
393
  }
438
394
  function ensureAgenthudDir() {
439
- const dir = join3(homedir(), ".agenthud");
395
+ const dir = join2(homedir(), ".agenthud");
440
396
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
441
397
  }
442
398
  function writeDefaultConfig() {
@@ -604,8 +560,8 @@ function hideProject(name) {
604
560
  updateState({ hiddenProjects: [...config.hiddenProjects, name] });
605
561
  }
606
562
  function hasProjectLevelConfig() {
607
- const candidate = join3(process.cwd(), ".agenthud", "config.yaml");
608
- if (candidate === join3(homedir(), ".agenthud", "config.yaml")) return false;
563
+ const candidate = join2(process.cwd(), ".agenthud", "config.yaml");
564
+ if (candidate === join2(homedir(), ".agenthud", "config.yaml")) return false;
609
565
  return existsSync(candidate);
610
566
  }
611
567
 
@@ -643,7 +599,7 @@ function getCommitDetail(projectPath, hash) {
643
599
  if (!projectPath) return null;
644
600
  try {
645
601
  return execSync(
646
- `git --git-dir="${projectPath}/.git" show --stat --patch --no-color ${hash}`,
602
+ `git -C "${projectPath}" show --stat --patch --no-color ${hash}`,
647
603
  { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }
648
604
  ).trim();
649
605
  } catch {
@@ -657,7 +613,7 @@ function parseGitCommits(projectPath, startDate, endDate) {
657
613
  let raw;
658
614
  try {
659
615
  raw = execSync(
660
- `git --git-dir="${projectPath}/.git" log --format="%ct|%h|%s" --after="${start} 00:00:00" --before="${end} 23:59:59"`,
616
+ `git -C "${projectPath}" log --format="%ct|%h|%s" --after="${start} 00:00:00" --before="${end} 23:59:59"`,
661
617
  { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }
662
618
  ).trim();
663
619
  } catch {
@@ -769,11 +725,24 @@ function summarizeToolDetail(name, input, result) {
769
725
  }
770
726
  return getToolDetail(name, input);
771
727
  }
728
+ function numberLines(content, start) {
729
+ const lines = content.split("\n");
730
+ if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
731
+ const width = String(start + lines.length - 1).length;
732
+ return lines.map((line, i) => `${String(start + i).padStart(width)}: ${line}`).join("\n");
733
+ }
772
734
  function buildToolDetailBody(name, input, result) {
773
735
  if (name === "Write") {
774
736
  const content = result?.content ?? input?.content;
775
737
  if (content) return { text: content, kind: "code" };
776
738
  }
739
+ if (name === "Read") {
740
+ const content = result?.file?.content;
741
+ if (content) {
742
+ const start = result?.file?.startLine ?? input?.offset ?? 1;
743
+ return { text: numberLines(content, start), kind: "code", numbered: true };
744
+ }
745
+ }
777
746
  if (name === "Edit" || name === "Write") {
778
747
  const hunks = result?.structuredPatch;
779
748
  if (hunks && hunks.length > 0) {
@@ -893,6 +862,7 @@ function parseActivitiesFromLines(lines) {
893
862
  if (body) {
894
863
  entry2.detailBody = body.text;
895
864
  entry2.detailKind = body.kind;
865
+ if (body.numbered) entry2.detailNumbered = true;
896
866
  }
897
867
  activities.push(entry2);
898
868
  }
@@ -1062,25 +1032,10 @@ function generateReport(sessions, options2) {
1062
1032
  return lines.join("\n").trimEnd();
1063
1033
  }
1064
1034
 
1065
- // src/data/summaryRunner.ts
1066
- import { spawn } from "child_process";
1067
- import {
1068
- copyFileSync,
1069
- createWriteStream,
1070
- existsSync as existsSync4,
1071
- mkdirSync as mkdirSync2,
1072
- readFileSync as readFileSync5,
1073
- unlinkSync
1074
- } from "fs";
1075
- import { homedir as homedir3 } from "os";
1076
- import { dirname as dirname2, join as join5 } from "path";
1077
- import { createInterface } from "readline";
1078
- import { fileURLToPath as fileURLToPath2 } from "url";
1079
-
1080
1035
  // src/data/sessions.ts
1081
1036
  import { existsSync as existsSync3, readdirSync, readFileSync as readFileSync4, statSync as statSync2 } from "fs";
1082
1037
  import { homedir as homedir2 } from "os";
1083
- import { basename as basename2, join as join4 } from "path";
1038
+ import { basename as basename2, join as join3 } from "path";
1084
1039
 
1085
1040
  // src/ui/constants.ts
1086
1041
  import stringWidth from "string-width";
@@ -1170,7 +1125,7 @@ function detectLiveState(tailLines, mtimeMs, now) {
1170
1125
 
1171
1126
  // src/data/sessions.ts
1172
1127
  function getProjectsDir() {
1173
- return process.env.CLAUDE_PROJECTS_DIR ?? join4(homedir2(), ".claude", "projects");
1128
+ return process.env.CLAUDE_PROJECTS_DIR ?? join3(homedir2(), ".claude", "projects");
1174
1129
  }
1175
1130
  function decodeProjectPath(encoded) {
1176
1131
  const windowsDriveMatch = encoded.match(/^([A-Za-z])--(.*)$/);
@@ -1307,7 +1262,7 @@ function readEntrypoint(filePath) {
1307
1262
  }
1308
1263
  }
1309
1264
  function buildSubAgents(parentId, projectDir, config, projectName) {
1310
- const subagentsDir = join4(projectDir, parentId, "subagents");
1265
+ const subagentsDir = join3(projectDir, parentId, "subagents");
1311
1266
  if (!existsSync3(subagentsDir)) return [];
1312
1267
  let files;
1313
1268
  try {
@@ -1320,7 +1275,7 @@ function buildSubAgents(parentId, projectDir, config, projectName) {
1320
1275
  return files.map((file) => {
1321
1276
  const id = file.replace(/\.jsonl$/, "");
1322
1277
  const hideKey = `${projectName}/${id}`;
1323
- const filePath = join4(subagentsDir, file);
1278
+ const filePath = join3(subagentsDir, file);
1324
1279
  try {
1325
1280
  const stat = statSync2(filePath);
1326
1281
  const { agentId, taskDescription } = readSubAgentInfo(filePath);
@@ -1352,7 +1307,36 @@ function buildSubAgents(parentId, projectDir, config, projectName) {
1352
1307
  (n) => n !== null && !config.hiddenSubAgents.includes(n.hideKey)
1353
1308
  ).sort((a, b) => b.lastModifiedMs - a.lastModifiedMs);
1354
1309
  }
1355
- function discoverSessions(config) {
1310
+ function findContainingProject(cwd, projectPaths, options2) {
1311
+ const resolve2 = options2?.realpath ?? ((p) => p);
1312
+ const cwdR = resolve2(cwd);
1313
+ let best = null;
1314
+ let bestLen = -1;
1315
+ for (const raw of projectPaths) {
1316
+ let pR;
1317
+ try {
1318
+ pR = resolve2(raw);
1319
+ } catch {
1320
+ continue;
1321
+ }
1322
+ if (cwdR === pR) {
1323
+ if (pR.length > bestLen) {
1324
+ best = raw;
1325
+ bestLen = pR.length;
1326
+ }
1327
+ continue;
1328
+ }
1329
+ const boundary = cwdR[pR.length];
1330
+ if ((boundary === "/" || boundary === "\\") && cwdR.startsWith(pR)) {
1331
+ if (pR.length > bestLen) {
1332
+ best = raw;
1333
+ bestLen = pR.length;
1334
+ }
1335
+ }
1336
+ }
1337
+ return best;
1338
+ }
1339
+ function discoverSessions(config, options2) {
1356
1340
  const projectsDir = getProjectsDir();
1357
1341
  if (!existsSync3(projectsDir)) {
1358
1342
  return {
@@ -1366,7 +1350,7 @@ function discoverSessions(config) {
1366
1350
  try {
1367
1351
  projectDirs = readdirSync(projectsDir).filter((entry) => {
1368
1352
  try {
1369
- return statSync2(join4(projectsDir, entry)).isDirectory();
1353
+ return statSync2(join3(projectsDir, entry)).isDirectory();
1370
1354
  } catch {
1371
1355
  return false;
1372
1356
  }
@@ -1380,9 +1364,11 @@ function discoverSessions(config) {
1380
1364
  };
1381
1365
  }
1382
1366
  const allSessions = [];
1367
+ const scope = options2?.scopeToProject ?? null;
1383
1368
  for (const encodedDir of projectDirs) {
1384
- const projectDir = join4(projectsDir, encodedDir);
1369
+ const projectDir = join3(projectsDir, encodedDir);
1385
1370
  const decodedPath = decodeProjectPath(encodedDir);
1371
+ if (scope !== null && decodedPath !== scope) continue;
1386
1372
  const projectName = basename2(decodedPath);
1387
1373
  let files;
1388
1374
  try {
@@ -1395,7 +1381,7 @@ function discoverSessions(config) {
1395
1381
  for (const file of files) {
1396
1382
  const id = file.replace(/\.jsonl$/, "");
1397
1383
  const hideKey = `${projectName}/${id}`;
1398
- const filePath = join4(projectDir, file);
1384
+ const filePath = join3(projectDir, file);
1399
1385
  try {
1400
1386
  const stat = statSync2(filePath);
1401
1387
  const subAgents = buildSubAgents(id, projectDir, config, projectName);
@@ -1473,13 +1459,26 @@ function discoverSessions(config) {
1473
1459
  }
1474
1460
 
1475
1461
  // src/data/summaryRunner.ts
1462
+ import { spawn } from "child_process";
1463
+ import {
1464
+ copyFileSync,
1465
+ createWriteStream,
1466
+ existsSync as existsSync4,
1467
+ mkdirSync as mkdirSync2,
1468
+ readFileSync as readFileSync5,
1469
+ unlinkSync
1470
+ } from "fs";
1471
+ import { homedir as homedir3 } from "os";
1472
+ import { dirname as dirname2, join as join4 } from "path";
1473
+ import { createInterface } from "readline";
1474
+ import { fileURLToPath as fileURLToPath2 } from "url";
1476
1475
  function agenthudHomeDir() {
1477
- const dir = join5(homedir3(), ".agenthud");
1476
+ const dir = join4(homedir3(), ".agenthud");
1478
1477
  if (!existsSync4(dir)) mkdirSync2(dir, { recursive: true });
1479
1478
  return dir;
1480
1479
  }
1481
1480
  function summariesDir() {
1482
- const dir = join5(agenthudHomeDir(), "summaries");
1481
+ const dir = join4(agenthudHomeDir(), "summaries");
1483
1482
  if (!existsSync4(dir)) mkdirSync2(dir, { recursive: true });
1484
1483
  return dir;
1485
1484
  }
@@ -1487,20 +1486,20 @@ function promptFilename(kind) {
1487
1486
  return kind === "daily" ? "summary-prompt.md" : "summary-range-prompt.md";
1488
1487
  }
1489
1488
  function userPromptPath(kind) {
1490
- return join5(homedir3(), ".agenthud", promptFilename(kind));
1489
+ return join4(homedir3(), ".agenthud", promptFilename(kind));
1491
1490
  }
1492
1491
  function templatePath(kind) {
1493
1492
  const here = dirname2(fileURLToPath2(import.meta.url));
1494
- return join5(here, "templates", promptFilename(kind));
1493
+ return join4(here, "templates", promptFilename(kind));
1495
1494
  }
1496
1495
  function dateKey(d) {
1497
1496
  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
1498
1497
  }
1499
1498
  function dailyCachePath(date) {
1500
- return join5(summariesDir(), `${dateKey(date)}.md`);
1499
+ return join4(summariesDir(), `${dateKey(date)}.md`);
1501
1500
  }
1502
1501
  function rangeCachePath(from, to) {
1503
- return join5(summariesDir(), `range-${dateKey(from)}_${dateKey(to)}.md`);
1502
+ return join4(summariesDir(), `range-${dateKey(from)}_${dateKey(to)}.md`);
1504
1503
  }
1505
1504
  function isSameLocalDay2(a, b) {
1506
1505
  return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
@@ -1959,6 +1958,7 @@ agenthud: combining ${dailyMarkdowns.length} daily summaries into range summary.
1959
1958
 
1960
1959
  // src/ui/App.tsx
1961
1960
  import { existsSync as existsSync5, watch } from "fs";
1961
+ import { basename as basename3 } from "path";
1962
1962
  import { Box as Box5, Text as Text5, useApp, useInput, useStdout } from "ink";
1963
1963
  import { useCallback, useEffect as useEffect3, useMemo, useRef, useState as useState3 } from "react";
1964
1964
 
@@ -2255,8 +2255,31 @@ function getLineStyle(category) {
2255
2255
  }
2256
2256
 
2257
2257
  // src/ui/DetailViewPanel.tsx
2258
- import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
2259
- function wrapClassified(text, maxWidth, classifier) {
2258
+ import { Fragment as Fragment2, jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
2259
+ function splitLineNumberGutter(text) {
2260
+ const m = text.match(/^(\s*\d+: )(.*)$/);
2261
+ return m ? [m[1], m[2]] : null;
2262
+ }
2263
+ function hardWrapByWidth(line, maxWidth) {
2264
+ if (maxWidth <= 0) return [line];
2265
+ const out = [];
2266
+ let cur = "";
2267
+ let curW = 0;
2268
+ for (const ch of line) {
2269
+ const w = getDisplayWidth(ch);
2270
+ if (curW + w > maxWidth && cur !== "") {
2271
+ out.push(cur);
2272
+ cur = ch;
2273
+ curW = w;
2274
+ } else {
2275
+ cur += ch;
2276
+ curW += w;
2277
+ }
2278
+ }
2279
+ if (cur !== "") out.push(cur);
2280
+ return out.length > 0 ? out : [line];
2281
+ }
2282
+ function wrapClassified(text, maxWidth, classifier, preserveWhitespace = false) {
2260
2283
  if (!text) return [{ text: "(empty)", category: "prose" }];
2261
2284
  const sourceLines = text.split("\n");
2262
2285
  const categories = classifier(sourceLines);
@@ -2268,6 +2291,12 @@ function wrapClassified(text, maxWidth, classifier) {
2268
2291
  out.push({ text: "", category: cat });
2269
2292
  continue;
2270
2293
  }
2294
+ if (preserveWhitespace) {
2295
+ for (const chunk of hardWrapByWidth(line, maxWidth)) {
2296
+ out.push({ text: chunk, category: cat });
2297
+ }
2298
+ continue;
2299
+ }
2271
2300
  const words = line.split(" ");
2272
2301
  let current = "";
2273
2302
  for (const word of words) {
@@ -2294,7 +2323,13 @@ function DetailViewPanel({
2294
2323
  const contentWidth = innerWidth - 1;
2295
2324
  const body = activity.detailBody ?? activity.detail;
2296
2325
  const classifier = activity.detailKind === "diff" ? classifyDiffLines : activity.detailKind === "code" ? classifyCodeFences : activity.type === "commit" ? classifyDiffLines : classifyCodeFences;
2297
- const allLines = wrapClassified(body, contentWidth, classifier);
2326
+ const preserveWhitespace = activity.detailKind === "diff" || activity.detailKind === "code" || activity.type === "commit";
2327
+ const allLines = wrapClassified(
2328
+ body,
2329
+ contentWidth,
2330
+ classifier,
2331
+ preserveWhitespace
2332
+ );
2298
2333
  const totalLines = allLines.length;
2299
2334
  const clampedOffset = Math.min(
2300
2335
  scrollOffset,
@@ -2321,11 +2356,15 @@ function DetailViewPanel({
2321
2356
  const entry = visibleSlice[i] ?? { text: "", category: "prose" };
2322
2357
  const padding = Math.max(0, contentWidth - getDisplayWidth(entry.text));
2323
2358
  const lineStyle = getLineStyle(entry.category);
2359
+ const gutterSplit = activity.detailNumbered ? splitLineNumberGutter(entry.text) : null;
2324
2360
  contentRows.push(
2325
2361
  /* @__PURE__ */ jsxs2(Text2, { children: [
2326
2362
  BOX.v,
2327
2363
  " ",
2328
- /* @__PURE__ */ jsx2(Text2, { color: lineStyle.color, dimColor: lineStyle.dimColor, children: entry.text }),
2364
+ gutterSplit ? /* @__PURE__ */ jsxs2(Fragment2, { children: [
2365
+ /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: gutterSplit[0] }),
2366
+ /* @__PURE__ */ jsx2(Text2, { color: lineStyle.color, dimColor: lineStyle.dimColor, children: gutterSplit[1] })
2367
+ ] }) : /* @__PURE__ */ jsx2(Text2, { color: lineStyle.color, dimColor: lineStyle.dimColor, children: entry.text }),
2329
2368
  " ".repeat(padding),
2330
2369
  BOX.v
2331
2370
  ] }, i)
@@ -2497,6 +2536,8 @@ function useHotkeys({
2497
2536
  onDetailClose,
2498
2537
  onDetailScrollUp,
2499
2538
  onDetailScrollDown,
2539
+ onDetailScrollHalfPageUp,
2540
+ onDetailScrollHalfPageDown,
2500
2541
  onFilter,
2501
2542
  onHelp,
2502
2543
  onHelpScroll,
@@ -2544,6 +2585,14 @@ function useHotkeys({
2544
2585
  return;
2545
2586
  }
2546
2587
  if (detailMode) {
2588
+ if (key.ctrl && input === "u") {
2589
+ onDetailScrollHalfPageUp();
2590
+ return;
2591
+ }
2592
+ if (key.ctrl && input === "d") {
2593
+ onDetailScrollHalfPageDown();
2594
+ return;
2595
+ }
2547
2596
  if (key.upArrow || input === "k") {
2548
2597
  onDetailScrollUp();
2549
2598
  return;
@@ -2642,7 +2691,7 @@ function useHotkeys({
2642
2691
  }
2643
2692
  };
2644
2693
  const trackingItems = trackingOn ? ["TRK \u25CF"] : ["t: track"];
2645
- const statusBarItems = helpMode ? ["\u2191\u2193/jk: scroll", "PgDn/Space: page", "\u21B5/Esc/q/?: close"] : detailMode ? ["\u2191\u2193/jk: scroll", "\u21B5/Esc: close", "?: help"] : focus === "tree" ? [
2694
+ const statusBarItems = helpMode ? ["\u2191\u2193/jk: scroll", "PgDn/Space: page", "\u21B5/Esc/q/?: close"] : detailMode ? ["\u2191\u2193/jk: scroll", "C-u/d: \xBDpage", "\u21B5/Esc: close", "?: help"] : focus === "tree" ? [
2646
2695
  ...trackingItems,
2647
2696
  "Tab: viewer",
2648
2697
  "\u2191\u2193/jk: select",
@@ -2667,24 +2716,12 @@ function useHotkeys({
2667
2716
  return { handleInput, statusBarItems };
2668
2717
  }
2669
2718
 
2670
- // src/ui/hooks/useTick.ts
2671
- import { useEffect, useState } from "react";
2672
- function useTick(active, intervalMs = 100) {
2673
- const [n, setN] = useState(0);
2674
- useEffect(() => {
2675
- if (!active) return;
2676
- const id = setInterval(() => setN((x) => x + 1), intervalMs);
2677
- return () => clearInterval(id);
2678
- }, [active, intervalMs]);
2679
- return n;
2680
- }
2681
-
2682
2719
  // src/ui/hooks/useSpinner.ts
2683
- import { useEffect as useEffect2, useState as useState2 } from "react";
2720
+ import { useEffect, useState } from "react";
2684
2721
  var FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
2685
2722
  function useSpinner(active, intervalMs = 100) {
2686
- const [index, setIndex] = useState2(0);
2687
- useEffect2(() => {
2723
+ const [index, setIndex] = useState(0);
2724
+ useEffect(() => {
2688
2725
  if (!active) return;
2689
2726
  const timer = setInterval(() => {
2690
2727
  setIndex((i) => (i + 1) % FRAMES.length);
@@ -2694,10 +2731,22 @@ function useSpinner(active, intervalMs = 100) {
2694
2731
  return active ? FRAMES[index] : "";
2695
2732
  }
2696
2733
 
2734
+ // src/ui/hooks/useTick.ts
2735
+ import { useEffect as useEffect2, useState as useState2 } from "react";
2736
+ function useTick(active, intervalMs = 100) {
2737
+ const [n, setN] = useState2(0);
2738
+ useEffect2(() => {
2739
+ if (!active) return;
2740
+ const id = setInterval(() => setN((x) => x + 1), intervalMs);
2741
+ return () => clearInterval(id);
2742
+ }, [active, intervalMs]);
2743
+ return n;
2744
+ }
2745
+
2697
2746
  // src/ui/SessionTreePanel.tsx
2698
2747
  import { homedir as homedir4 } from "os";
2699
2748
  import { Box as Box4, Text as Text4 } from "ink";
2700
- import { Fragment as Fragment2, jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
2749
+ import { Fragment as Fragment3, jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
2701
2750
  function formatElapsed(lastModifiedMs, now = Date.now()) {
2702
2751
  const elapsed = Math.max(0, now - lastModifiedMs);
2703
2752
  const seconds = Math.floor(elapsed / 1e3);
@@ -2921,7 +2970,7 @@ function ProjectRow({
2921
2970
  dimColor: muted,
2922
2971
  children: [
2923
2972
  nameText,
2924
- pathText ? /* @__PURE__ */ jsxs4(Fragment2, { children: [
2973
+ pathText ? /* @__PURE__ */ jsxs4(Fragment3, { children: [
2925
2974
  " ",
2926
2975
  /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: pathText })
2927
2976
  ] }) : null,
@@ -2998,12 +3047,14 @@ function SessionTreePanel({
2998
3047
  maxRows,
2999
3048
  expandedIds = /* @__PURE__ */ new Set(),
3000
3049
  trackingOn = false,
3001
- spinner = ""
3050
+ spinner = "",
3051
+ scopeLabel
3002
3052
  }) {
3003
3053
  const innerWidth = getInnerWidth(width);
3004
3054
  const contentWidth = innerWidth - 1;
3005
3055
  const titleSuffix = trackingOn ? `[LIVE ${spinner || "\u25BC"}]` : "";
3006
- const titleLine = createTitleLine("Projects", titleSuffix, width);
3056
+ const titleText = scopeLabel ? `Projects [${scopeLabel}]` : "Projects";
3057
+ const titleLine = createTitleLine(titleText, titleSuffix, width);
3007
3058
  const bottomLine = createBottomLine(width);
3008
3059
  const totalProjectCount = projects.length + coldProjects.length;
3009
3060
  if (totalProjectCount === 0) {
@@ -3097,7 +3148,7 @@ function SessionTreePanel({
3097
3148
  }
3098
3149
 
3099
3150
  // src/ui/App.tsx
3100
- import { Fragment as Fragment3, jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
3151
+ import { Fragment as Fragment4, jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
3101
3152
  var VIEWER_HEIGHT_FRACTION = 0.55;
3102
3153
  function subSummarySentinel(parentId) {
3103
3154
  return {
@@ -3249,14 +3300,21 @@ function collectAllIds(tree) {
3249
3300
  }
3250
3301
  return ids;
3251
3302
  }
3252
- function App({ mode }) {
3303
+ function App({
3304
+ mode,
3305
+ scopeToProject: scopeToProject2
3306
+ }) {
3253
3307
  const { exit } = useApp();
3254
3308
  const { stdout } = useStdout();
3255
3309
  const isWatchMode = mode === "watch";
3256
3310
  const config = useMemo(() => loadGlobalConfig(), []);
3257
3311
  const migrationWarning = useMemo(() => hasProjectLevelConfig(), []);
3312
+ const discoverOptions = useMemo(
3313
+ () => scopeToProject2 ? { scopeToProject: scopeToProject2 } : void 0,
3314
+ [scopeToProject2]
3315
+ );
3258
3316
  const [sessionTree, setSessionTree] = useState3(
3259
- () => discoverSessions(config)
3317
+ () => discoverSessions(config, discoverOptions)
3260
3318
  );
3261
3319
  const [selectedId, setSelectedId] = useState3(() => {
3262
3320
  const firstProject = sessionTree.projects[0];
@@ -3364,7 +3422,7 @@ function App({ mode }) {
3364
3422
  }, [selectedId, isWatchMode]);
3365
3423
  const refresh = useCallback(() => {
3366
3424
  const freshConfig = loadGlobalConfig();
3367
- const tree = discoverSessions(freshConfig);
3425
+ const tree = discoverSessions(freshConfig, discoverOptions);
3368
3426
  const updatedFlat = flattenSessions2(tree, expandedIds);
3369
3427
  let nextSelected = selectedId;
3370
3428
  if (tracking) {
@@ -3396,7 +3454,7 @@ function App({ mode }) {
3396
3454
  setScrollOffset((o) => o + delta);
3397
3455
  setNewCount((n) => n + delta);
3398
3456
  }
3399
- }, [selectedId, isLive, expandedIds, tracking]);
3457
+ }, [selectedId, isLive, expandedIds, tracking, discoverOptions]);
3400
3458
  const refreshRef = useRef(refresh);
3401
3459
  useEffect3(() => {
3402
3460
  refreshRef.current = refresh;
@@ -3629,6 +3687,14 @@ function App({ mode }) {
3629
3687
  onDetailScrollDown: () => {
3630
3688
  setDetailScrollOffset((o) => o + 1);
3631
3689
  },
3690
+ onDetailScrollHalfPageUp: () => {
3691
+ const step = Math.max(1, Math.floor(viewerRows / 2));
3692
+ setDetailScrollOffset((o) => Math.max(0, o - step));
3693
+ },
3694
+ onDetailScrollHalfPageDown: () => {
3695
+ const step = Math.max(1, Math.floor(viewerRows / 2));
3696
+ setDetailScrollOffset((o) => o + step);
3697
+ },
3632
3698
  onEnter: () => {
3633
3699
  if (focus === "viewer") {
3634
3700
  const act = getSelectedActivity(
@@ -3838,7 +3904,7 @@ function App({ mode }) {
3838
3904
  helpTotalLinesRef.current = n;
3839
3905
  }
3840
3906
  }
3841
- ) : /* @__PURE__ */ jsxs5(Fragment3, { children: [
3907
+ ) : /* @__PURE__ */ jsxs5(Fragment4, { children: [
3842
3908
  migrationWarning && /* @__PURE__ */ jsx5(Box5, { marginBottom: 1, children: /* @__PURE__ */ jsx5(Text5, { color: "yellow", children: "Config moved to ~/.agenthud/config.yaml" }) }),
3843
3909
  /* @__PURE__ */ jsx5(
3844
3910
  SessionTreePanel,
@@ -3851,7 +3917,8 @@ function App({ mode }) {
3851
3917
  maxRows: treeRows,
3852
3918
  expandedIds,
3853
3919
  trackingOn: tracking,
3854
- spinner
3920
+ spinner,
3921
+ scopeLabel: scopeToProject2 ? basename3(scopeToProject2) : void 0
3855
3922
  }
3856
3923
  ),
3857
3924
  /* @__PURE__ */ jsx5(Box5, { marginTop: 1, children: detailMode && detailActivity ? /* @__PURE__ */ jsx5(
@@ -3885,6 +3952,52 @@ function App({ mode }) {
3885
3952
  ] });
3886
3953
  }
3887
3954
 
3955
+ // src/utils/altScreen.ts
3956
+ var ENTER = "\x1B[?1049h";
3957
+ var LEAVE = "\x1B[?1049l";
3958
+ var entered = false;
3959
+ var left = false;
3960
+ function enterAltScreen() {
3961
+ if (entered) return;
3962
+ entered = true;
3963
+ process.stdout.write(ENTER);
3964
+ }
3965
+ function leaveAltScreen() {
3966
+ if (left || !entered) return;
3967
+ left = true;
3968
+ process.stdout.write(LEAVE);
3969
+ }
3970
+ var hooksInstalled = false;
3971
+ function installAltScreenCleanup() {
3972
+ if (hooksInstalled) return;
3973
+ hooksInstalled = true;
3974
+ process.on("exit", () => {
3975
+ leaveAltScreen();
3976
+ });
3977
+ process.on("SIGINT", () => {
3978
+ leaveAltScreen();
3979
+ process.exit(130);
3980
+ });
3981
+ process.on("SIGTERM", () => {
3982
+ leaveAltScreen();
3983
+ process.exit(143);
3984
+ });
3985
+ process.on("uncaughtException", (err) => {
3986
+ leaveAltScreen();
3987
+ setImmediate(() => {
3988
+ throw err;
3989
+ });
3990
+ });
3991
+ }
3992
+
3993
+ // src/utils/legacyConfig.ts
3994
+ import { join as join5, resolve } from "path";
3995
+ function isLegacyProjectConfig(cwd, home) {
3996
+ const legacy = resolve(join5(cwd, ".agenthud", "config.yaml"));
3997
+ const global = resolve(join5(home, ".agenthud", "config.yaml"));
3998
+ return legacy !== global;
3999
+ }
4000
+
3888
4001
  // src/main.ts
3889
4002
  var options = parseArgs(process.argv.slice(2));
3890
4003
  if (options.error) {
@@ -3971,10 +4084,39 @@ if (options.mode === "summary") {
3971
4084
  });
3972
4085
  process.exit(exitCode);
3973
4086
  }
4087
+ var scopeToProject;
4088
+ if (options.scopeToCwd) {
4089
+ const projectsDir = getProjectsDir();
4090
+ let registered = [];
4091
+ try {
4092
+ registered = readdirSync2(projectsDir).map(decodeProjectPath);
4093
+ } catch {
4094
+ }
4095
+ const safeReal = (p) => {
4096
+ try {
4097
+ return realpathSync(p);
4098
+ } catch {
4099
+ return p;
4100
+ }
4101
+ };
4102
+ const match = findContainingProject(process.cwd(), registered, {
4103
+ realpath: safeReal
4104
+ });
4105
+ if (!match) {
4106
+ process.stderr.write(
4107
+ `agenthud: --cwd: no Claude project found at or above ${process.cwd()}
4108
+ `
4109
+ );
4110
+ process.exit(1);
4111
+ }
4112
+ scopeToProject = match;
4113
+ process.stderr.write(`agenthud: scope = ${match}
4114
+ `);
4115
+ }
3974
4116
  if (options.mode === "watch") {
3975
4117
  installAltScreenCleanup();
3976
4118
  enterAltScreen();
3977
4119
  } else {
3978
4120
  if (options.mode === "once") clearScreen();
3979
4121
  }
3980
- render(React.createElement(App, { mode: options.mode }));
4122
+ render(React.createElement(App, { mode: options.mode, scopeToProject }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agenthud",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "CLI tool to monitor agent status in real-time. Works with Claude Code, multi-agent workflows, and any AI agent system.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -0,0 +1,72 @@
1
+ #!/usr/bin/env bash
2
+ # Interactive screencast workflow: record what you do in the terminal,
3
+ # automatically encode to demo/live.gif on Ctrl+D.
4
+ #
5
+ # Usage:
6
+ # scripts/record-demo.sh
7
+ #
8
+ # Override defaults with env vars, e.g.:
9
+ # COLS=120 ROWS=32 AGG_THEME=dracula scripts/record-demo.sh
10
+ #
11
+ # Dependencies (install once):
12
+ # brew install asciinema agg
13
+
14
+ set -euo pipefail
15
+
16
+ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
17
+ CAST="$ROOT/demo/.cache/live.cast"
18
+ GIF="$ROOT/demo/live.gif"
19
+
20
+ # Wider than the VHS tape's effective area so the split-pane TUI has
21
+ # room to breathe (tree gets ~22 rows, activity viewer ~25 rows at
22
+ # 48 total). For an even more generous recording use e.g.
23
+ # COLS=220 ROWS=56. Most modern emulators honor the xterm resize
24
+ # escape used below.
25
+ COLS="${COLS:-180}"
26
+ ROWS="${ROWS:-48}"
27
+
28
+ # agg ships: asciinema, dracula, github-dark, github-light, monokai,
29
+ # solarized-dark, solarized-light. Pass a path to a TOML file for
30
+ # anything else (e.g. Catppuccin Mocha).
31
+ AGG_THEME="${AGG_THEME:-monokai}"
32
+ AGG_FONT_SIZE="${AGG_FONT_SIZE:-16}"
33
+
34
+ for cmd in asciinema agg; do
35
+ if ! command -v "$cmd" >/dev/null 2>&1; then
36
+ echo "[record-demo] $cmd not found. Install with: brew install $cmd" >&2
37
+ exit 1
38
+ fi
39
+ done
40
+
41
+ mkdir -p "$(dirname "$CAST")"
42
+ rm -f "$CAST"
43
+
44
+ # Ask the terminal to resize itself. Works in iTerm2, Terminal.app,
45
+ # kitty, alacritty, wezterm; silently ignored elsewhere.
46
+ printf '\033[8;%d;%dt' "$ROWS" "$COLS"
47
+
48
+ cat <<EOF
49
+ [record-demo]
50
+ size : ${COLS} cols × ${ROWS} rows
51
+ cast : ${CAST}
52
+ gif : ${GIF}
53
+
54
+ Tips:
55
+ - Type 'clear' once the recording shell appears so the prompt
56
+ starts on a clean screen (your normal PS1 will be captured
57
+ otherwise).
58
+ - Run your scenario at a natural pace.
59
+ - When you're done, press Ctrl+D (or type 'exit') to stop.
60
+
61
+ EOF
62
+
63
+ read -r -p "Press Enter to start recording..." _
64
+
65
+ asciinema rec "$CAST"
66
+
67
+ echo
68
+ echo "[record-demo] Encoding ${CAST} → ${GIF}"
69
+ agg --font-size "$AGG_FONT_SIZE" --theme "$AGG_THEME" "$CAST" "$GIF"
70
+
71
+ echo "[record-demo] Done: ${GIF}"
72
+ echo "[record-demo] Preview: open '${GIF}'"