agenthud 0.9.2 → 0.9.3

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
@@ -23,36 +23,39 @@ Run this in a separate terminal while using Claude Code. Press `?` inside the TU
23
23
  AgentHUD reads Claude Code's session files from `~/.claude/projects/` and displays them in a split view:
24
24
 
25
25
  ```
26
- ┌─ Projects ──────────────────────────────────────────────┐
27
- │ > agenthud ~/WestbrookAI/agenthud
28
- │ #864f [hot] Fix the auth bug in login flow
29
- │ ├─ » code-reviewer
30
- │ (#398c [warm])
31
- │ myproject ~/work/myproject
32
- │ #def4 [hot] Add OAuth support
33
- │ ... 12 cold projects
34
- └─────────────────────────────────────────────────────────┘
35
- ┌─ Activity · agenthud ───────────────────────────────────┐
36
- │ [10:23] ○ Read src/ui/App.tsx
37
- │ [10:23] ~ Edit src/ui/App.tsx
38
- │ [10:23] $ Bash npm test
39
- │ [10:23] < Response Tests passed successfully
40
- │ [10:25] ◆ abc1234 feat: fix auth callback
41
- └─────────────────────────────────────────────────────────┘
26
+ ┌─ Projects ───────────────────────────────────────────────┐
27
+ │ > agenthud ~/WestbrookAI/agenthud 13m
28
+ │ #864f [hot] Fix the auth bug in login flow
29
+ │ ├─ » code-reviewer
30
+ │ (#398c [warm])
31
+ │ myproject ~/work/myproject 2d
32
+ │ #def4 [hot] Add OAuth support
33
+ │ ... 12 cold projects
34
+ └──────────────────────────────────────────────────────────┘
35
+ ┌─ Activity · agenthud ────────────────────────────────────┐
36
+ │ [10:23] ○ Read src/ui/App.tsx
37
+ │ [10:23] ~ Edit src/ui/App.tsx
38
+ │ [10:23] $ Bash npm test
39
+ │ [10:23] < Response Tests passed successfully
40
+ │ [10:25] ◆ abc1234 feat: fix auth callback
41
+ │ › │
42
+ └──────────────────────────────────────────────────────────┘
42
43
  ```
43
44
 
44
45
  **Project tree (top pane)**
45
46
  - Sessions grouped under their project (project name + path at the top).
46
- - Session rows show short ID + first user prompt (the session's "topic").
47
+ - Session rows show short ID + first user prompt (the session's "topic"). Long titles truncate with a `…` suffix.
48
+ - Right edge of each row shows how long ago it was last touched: `42m`, `17h`, `3d`, `2w`, `1mo`, `1y`. Project rows use the most recent session's mtime.
47
49
  - Non-interactive sessions (from `claude -p`, SDK, `agenthud summary`) appear in parens and dimmed.
48
50
  - Sub-agents nest one level deeper under their parent session.
49
51
  - Cold projects collapse under `... N cold projects` at the bottom (press Enter on the line to expand).
50
52
  - Press `h` to hide a project, session, or sub-agent (saved to `~/.agenthud/state.yaml`).
51
53
 
52
54
  **Activity viewer (bottom pane)**
53
- - Real-time feed for the selected session: file reads, edits, bash, responses, thinking, git commits.
55
+ - Real-time feed for the selected session: file reads, edits, bash, responses, thinking, git commits. Newest at the bottom, like `tail -f`.
56
+ - A `›` slides left → right along the bottom row while the viewer is in LIVE mode — visible proof the feed is alive. Hidden when paused (scrolled into history) or when the session has no activity yet.
54
57
  - Press `f` to cycle through filter presets (configurable).
55
- - Press `↵` on any row to open a scrollable detail view; on a commit row this shows `git show --stat`.
58
+ - Press `↵` on any row to open a scrollable detail view; on a commit row this shows `git show --stat --patch`.
56
59
 
57
60
  ## Session status
58
61
 
@@ -110,10 +113,11 @@ Full reference is also available inside the app — press `?`.
110
113
  | `PgUp` / `Ctrl+B` | Page up |
111
114
  | `PgDn` / `Ctrl+F` | Page down |
112
115
  | `Ctrl+U` / `Ctrl+D` | Half page up / down |
113
- | `g` | Jump to live (newest) |
114
- | `G` | Jump to oldest |
116
+ | `g` | Jump to top (oldest) |
117
+ | `G` | Jump to live (newest, bottom) |
115
118
  | `↵` | Open detail view |
116
119
  | `f` | Cycle filter preset |
120
+ | `r` | Refresh now |
117
121
  | `Tab` | Switch focus to project tree |
118
122
  | `?` | Help |
119
123
  | `q` | Quit |
@@ -125,6 +129,17 @@ Full reference is also available inside the app — press `?`.
125
129
  | `↑` / `k` / `↓` / `j` | Scroll |
126
130
  | `↵` / `Esc` / `q` | Close |
127
131
 
132
+ Detail view colors the content based on activity type:
133
+
134
+ - **Git commit detail** (`git show --stat --patch`): added lines green (`+`), removed lines red (`-`), hunk headers cyan (`@@ ... @@`), `commit/Author/Date/diff` metadata dimmed.
135
+ - **Response / thinking / prompt**: text inside triple-backtick code fences renders in cyan so the boundary between prose and code is obvious. No language-specific syntax highlighting — just code-vs-prose separation.
136
+
137
+ ## Behavior
138
+
139
+ - **Alternate screen buffer.** Watch mode uses the alt-screen (like `vim`, `htop`, `btop`), so quitting (`q`) restores the pre-launch shell completely. No TUI residue, no "is it still running?" confusion.
140
+ - **Minimum terminal size.** 80 cols × 20 rows. Smaller terminals show a one-line hint and redraw automatically when you resize.
141
+ - **Help overlay scrolls.** Press `?` for an in-app reference. The overlay scrolls (`j/k`, `PgUp/PgDn`, `Ctrl+B/F`, `Space`, `g/G`) so the full content is reachable on shorter terminals.
142
+
128
143
  ## Report
129
144
 
130
145
  Print activity for a date in Markdown or JSON — suitable for piping to scripts or LLMs:
@@ -155,7 +170,7 @@ Output:
155
170
 
156
171
  | Flag | Default | Description |
157
172
  |------|---------|-------------|
158
- | `--date` | today | `YYYY-MM-DD` or `today` (local date) |
173
+ | `--date` | today | `YYYY-MM-DD`, `today`, `yesterday`, or `-Nd` (N days ago, local date) |
159
174
  | `--include` | `response,bash,edit,thinking` | Comma-separated types or `all` |
160
175
  | `--format` | `markdown` | `markdown` or `json` |
161
176
  | `--detail-limit` | `120` | Max chars per detail field; `0` = unlimited |
@@ -202,10 +217,11 @@ Each missing daily prompts for confirmation just before generation, so you see c
202
217
  refreshInterval: 2s
203
218
 
204
219
  # Activity filter presets (cycle with 'f' key in viewer)
205
- # Each list is one preset; [] means "all". First preset is the default.
220
+ # Each list is one preset. Use "all" (or "*") to show everything.
221
+ # Types: response, user, bash, edit, thinking, read, glob, commit
206
222
  filterPresets:
207
- - []
208
- - ["response"]
223
+ - ["all"]
224
+ - ["response", "user"]
209
225
  - ["commit"]
210
226
  ```
211
227
 
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-6SGKXL7E.js");
18
+ import("./main-S27FZ2BJ.js");
@@ -348,6 +348,44 @@ function parseArgs(args) {
348
348
  return { mode: "watch" };
349
349
  }
350
350
 
351
+ // src/utils/altScreen.ts
352
+ var ENTER = "\x1B[?1049h";
353
+ var LEAVE = "\x1B[?1049l";
354
+ var entered = false;
355
+ var left = false;
356
+ function enterAltScreen() {
357
+ if (entered) return;
358
+ entered = true;
359
+ process.stdout.write(ENTER);
360
+ }
361
+ function leaveAltScreen() {
362
+ if (left || !entered) return;
363
+ left = true;
364
+ process.stdout.write(LEAVE);
365
+ }
366
+ var hooksInstalled = false;
367
+ function installAltScreenCleanup() {
368
+ if (hooksInstalled) return;
369
+ hooksInstalled = true;
370
+ process.on("exit", () => {
371
+ leaveAltScreen();
372
+ });
373
+ process.on("SIGINT", () => {
374
+ leaveAltScreen();
375
+ process.exit(130);
376
+ });
377
+ process.on("SIGTERM", () => {
378
+ leaveAltScreen();
379
+ process.exit(143);
380
+ });
381
+ process.on("uncaughtException", (err) => {
382
+ leaveAltScreen();
383
+ setImmediate(() => {
384
+ throw err;
385
+ });
386
+ });
387
+ }
388
+
351
389
  // src/config/globalConfig.ts
352
390
  import { existsSync, mkdirSync, readFileSync as readFileSync2, writeFileSync } from "fs";
353
391
  import { homedir } from "os";
@@ -359,9 +397,16 @@ var DEFAULT_GLOBAL_CONFIG = {
359
397
  refreshIntervalMs: 2e3,
360
398
  hiddenSessions: [],
361
399
  hiddenSubAgents: [],
362
- filterPresets: [[], ["response"], ["commit"]],
400
+ // [] means "show all"; conversation preset bundles assistant + user;
401
+ // commits-only preset filters down to git activity.
402
+ filterPresets: [[], ["response", "user"], ["commit"]],
363
403
  hiddenProjects: []
364
404
  };
405
+ var ALL_PRESET_KEYWORDS = /* @__PURE__ */ new Set(["all", "*", "any"]);
406
+ function normalizePreset(tokens) {
407
+ if (tokens.some((t) => ALL_PRESET_KEYWORDS.has(t.toLowerCase()))) return [];
408
+ return tokens;
409
+ }
365
410
  function parseInterval(value) {
366
411
  const match = value.match(/^(\d+)(s|m)$/);
367
412
  if (!match) return null;
@@ -381,10 +426,11 @@ function writeDefaultConfig() {
381
426
  refreshInterval: 2s
382
427
 
383
428
  # Activity filter presets (cycle with 'f' key in viewer)
384
- # Each list is one preset; [] means "all". First preset is the default.
429
+ # Each list is one preset. Use "all" (or "*") to show everything.
430
+ # Types: response, user, bash, edit, thinking, read, glob, commit
385
431
  filterPresets:
386
- - []
387
- - ["response"]
432
+ - ["all"]
433
+ - ["response", "user"]
388
434
  - ["commit"]
389
435
  `;
390
436
  try {
@@ -430,9 +476,12 @@ function loadGlobalConfig() {
430
476
  if (ms !== null) config.refreshIntervalMs = ms;
431
477
  }
432
478
  if (Array.isArray(configRaw.filterPresets)) {
433
- const presets = configRaw.filterPresets.filter(Array.isArray).map(
434
- (p) => p.filter((t) => typeof t === "string")
435
- );
479
+ const presets = configRaw.filterPresets.filter(Array.isArray).map((p) => {
480
+ const tokens = p.filter(
481
+ (t) => typeof t === "string"
482
+ );
483
+ return normalizePreset(tokens);
484
+ });
436
485
  if (presets.length > 0) config.filterPresets = presets;
437
486
  }
438
487
  const legacyHidden = {};
@@ -572,7 +621,7 @@ function getCommitDetail(projectPath, hash) {
572
621
  if (!projectPath) return null;
573
622
  try {
574
623
  return execSync(
575
- `git --git-dir="${projectPath}/.git" show --stat --no-color ${hash}`,
624
+ `git --git-dir="${projectPath}/.git" show --stat --patch --no-color ${hash}`,
576
625
  { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }
577
626
  ).trim();
578
627
  } catch {
@@ -931,6 +980,21 @@ function createBottomLine(panelWidth = DEFAULT_PANEL_WIDTH) {
931
980
  return BOX.bl + BOX.h.repeat(getInnerWidth(panelWidth)) + BOX.br;
932
981
  }
933
982
  var SEPARATOR = "\u2500".repeat(CONTENT_WIDTH);
983
+ function truncateByWidth(text, maxWidth) {
984
+ if (maxWidth <= 0) return "";
985
+ if (getDisplayWidth(text) <= maxWidth) return text;
986
+ if (maxWidth === 1) return "\u2026";
987
+ const ellipsisWidth = 1;
988
+ let acc = "";
989
+ let used = 0;
990
+ for (const ch of text) {
991
+ const w = getDisplayWidth(ch);
992
+ if (used + w + ellipsisWidth > maxWidth) break;
993
+ acc += ch;
994
+ used += w;
995
+ }
996
+ return `${acc}\u2026`;
997
+ }
934
998
  var widthCache = /* @__PURE__ */ new Map();
935
999
  function getDisplayWidth(s) {
936
1000
  const cached = widthCache.get(s);
@@ -965,13 +1029,19 @@ function getSessionStatus(mtimeMs) {
965
1029
  }
966
1030
  return "cold";
967
1031
  }
1032
+ var MAX_TITLE_LEN = 300;
1033
+ function capWithEllipsis(s, max = MAX_TITLE_LEN) {
1034
+ const trimmed = s.trim();
1035
+ if (trimmed.length <= max) return trimmed;
1036
+ return `${trimmed.slice(0, max - 1)}\u2026`;
1037
+ }
968
1038
  function extractTaskDescription(content) {
969
1039
  const headerMatch = content.match(/##\s*(Task\s+\d+[:\s].+)/m);
970
- if (headerMatch) return headerMatch[1].trim().slice(0, 60);
1040
+ if (headerMatch) return capWithEllipsis(headerMatch[1]);
971
1041
  const thisTaskMatch = content.match(/\*\*This Task[^:]+:\*\*\s*(.+)/);
972
- if (thisTaskMatch) return thisTaskMatch[1].trim().slice(0, 60);
1042
+ if (thisTaskMatch) return capWithEllipsis(thisTaskMatch[1]);
973
1043
  const firstLine = content.split("\n").find((l) => l.trim());
974
- return (firstLine ?? "").trim().slice(0, 60);
1044
+ return capWithEllipsis(firstLine ?? "");
975
1045
  }
976
1046
  function readSubAgentInfo(filePath) {
977
1047
  if (!existsSync3(filePath)) return { agentId: null, taskDescription: null };
@@ -1054,8 +1124,7 @@ function readFirstUserPrompt(filePath) {
1054
1124
  if (!text || isSystemNoise(text)) continue;
1055
1125
  const firstLine = text.split("\n").find((l) => l.trim()) ?? "";
1056
1126
  if (!firstLine || isSystemNoise(firstLine)) continue;
1057
- const trimmed = firstLine.trim();
1058
- return trimmed.length > 80 ? trimmed.slice(0, 80) : trimmed;
1127
+ return capWithEllipsis(firstLine);
1059
1128
  }
1060
1129
  return null;
1061
1130
  }
@@ -1680,7 +1749,7 @@ agenthud: combining ${dailyMarkdowns.length} daily summaries into range summary.
1680
1749
  // src/ui/App.tsx
1681
1750
  import { existsSync as existsSync5, watch } from "fs";
1682
1751
  import { Box as Box5, Text as Text5, useApp, useInput, useStdout } from "ink";
1683
- import { useCallback, useEffect as useEffect2, useMemo, useRef, useState as useState2 } from "react";
1752
+ import { useCallback, useEffect as useEffect3, useMemo, useRef, useState as useState3 } from "react";
1684
1753
 
1685
1754
  // src/ui/ActivityViewerPanel.tsx
1686
1755
  import { Box, Text } from "ink";
@@ -1740,6 +1809,8 @@ function ActivityViewerPanel({
1740
1809
  isLive,
1741
1810
  newCount,
1742
1811
  visibleRows,
1812
+ trailingBlankRows = 0,
1813
+ liveIndicatorPosition = null,
1743
1814
  width,
1744
1815
  cursorLine,
1745
1816
  hasFocus,
@@ -1753,18 +1824,18 @@ function ActivityViewerPanel({
1753
1824
  if (isLive) {
1754
1825
  titleSuffix = `[LIVE ${spinner || "\u25BC"}${filterSuffix}]`;
1755
1826
  } else {
1756
- const badge = newCount > 0 ? ` +${newCount}\u2191` : "";
1757
- titleSuffix = `[PAUSED \u2193${scrollOffset}${badge}${filterSuffix}]`;
1827
+ const badge = newCount > 0 ? ` +${newCount}\u2193` : "";
1828
+ titleSuffix = `[PAUSED \u2191${scrollOffset}${badge}${filterSuffix}]`;
1758
1829
  }
1759
1830
  let visibleActivities;
1760
1831
  if (activities.length === 0) {
1761
1832
  visibleActivities = [];
1762
1833
  } else if (isLive) {
1763
- visibleActivities = activities.slice(-visibleRows).reverse();
1834
+ visibleActivities = activities.slice(-visibleRows);
1764
1835
  } else {
1765
1836
  const end = Math.max(0, activities.length - scrollOffset);
1766
1837
  const start = Math.max(0, end - visibleRows);
1767
- visibleActivities = activities.slice(start, end).reverse();
1838
+ visibleActivities = activities.slice(start, end);
1768
1839
  }
1769
1840
  const now = /* @__PURE__ */ new Date();
1770
1841
  const lines = [];
@@ -1782,10 +1853,11 @@ function ActivityViewerPanel({
1782
1853
  );
1783
1854
  } else {
1784
1855
  const effectiveCursor = Math.min(cursorLine, visibleActivities.length - 1);
1856
+ const cursorIndexInSlice = visibleActivities.length - 1 - effectiveCursor;
1785
1857
  for (let i = 0; i < visibleActivities.length; i++) {
1786
1858
  const activity = visibleActivities[i];
1787
1859
  const style = getActivityStyle(activity);
1788
- const isCursor = hasFocus && i === effectiveCursor;
1860
+ const isCursor = hasFocus && i === cursorIndexInSlice;
1789
1861
  const time = formatActivityTime(activity.timestamp, now);
1790
1862
  const timestamp = `[${time}] `;
1791
1863
  const timestampWidth = timestamp.length;
@@ -1837,28 +1909,110 @@ function ActivityViewerPanel({
1837
1909
  }
1838
1910
  }
1839
1911
  const emptyRow = `${BOX.v}${" ".repeat(contentWidth + 1)}${BOX.v}`;
1840
- while (lines.length < visibleRows) {
1841
- lines.push(/* @__PURE__ */ jsx(Text, { children: emptyRow }, `pad-${lines.length}`));
1912
+ const padCount = Math.max(0, visibleRows - lines.length);
1913
+ const padded = [];
1914
+ for (let i = 0; i < padCount; i++) {
1915
+ padded.push(/* @__PURE__ */ jsx(Text, { children: emptyRow }, `pad-${i}`));
1916
+ }
1917
+ const hasContent = visibleActivities.length > 0;
1918
+ const trailing = [];
1919
+ for (let i = 0; i < trailingBlankRows; i++) {
1920
+ if (i === 0 && isLive && liveIndicatorPosition != null && hasContent) {
1921
+ const pos = Math.max(0, liveIndicatorPosition);
1922
+ const arrow = "\u203A";
1923
+ const safePos = Math.min(pos, Math.max(0, contentWidth - 1));
1924
+ const padAfter = Math.max(0, contentWidth - safePos - 1);
1925
+ trailing.push(
1926
+ /* @__PURE__ */ jsxs(Text, { children: [
1927
+ BOX.v,
1928
+ " ",
1929
+ " ".repeat(safePos),
1930
+ /* @__PURE__ */ jsx(Text, { color: "cyan", dimColor: true, children: arrow }),
1931
+ " ".repeat(padAfter),
1932
+ BOX.v
1933
+ ] }, `trail-${i}`)
1934
+ );
1935
+ } else {
1936
+ trailing.push(/* @__PURE__ */ jsx(Text, { children: emptyRow }, `trail-${i}`));
1937
+ }
1842
1938
  }
1939
+ const finalLines = [...padded, ...lines, ...trailing];
1843
1940
  return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", width, children: [
1844
1941
  /* @__PURE__ */ jsx(Text, { color: isLive ? void 0 : "yellow", children: createTitleLine(sessionName, titleSuffix, width) }),
1845
- lines,
1942
+ finalLines,
1846
1943
  /* @__PURE__ */ jsx(Text, { children: createBottomLine(width) })
1847
1944
  ] });
1848
1945
  }
1849
1946
 
1850
1947
  // src/ui/DetailViewPanel.tsx
1851
1948
  import { Box as Box2, Text as Text2 } from "ink";
1949
+
1950
+ // src/ui/lineColoring.ts
1951
+ var DIFF_META_PREFIXES = [
1952
+ "diff --git",
1953
+ "index ",
1954
+ "commit ",
1955
+ "Author:",
1956
+ "Date:",
1957
+ "Merge:"
1958
+ ];
1959
+ function classifyDiffLines(lines) {
1960
+ return lines.map((line) => {
1961
+ if (line.startsWith("+++") || line.startsWith("---")) return "diff-meta";
1962
+ if (DIFF_META_PREFIXES.some((p) => line.startsWith(p))) return "diff-meta";
1963
+ if (line.startsWith("@@")) return "diff-hunk";
1964
+ if (line.startsWith("+")) return "diff-add";
1965
+ if (line.startsWith("-")) return "diff-remove";
1966
+ return "prose";
1967
+ });
1968
+ }
1969
+ function classifyCodeFences(lines) {
1970
+ const out = [];
1971
+ let inCode = false;
1972
+ for (const line of lines) {
1973
+ if (/^\s*```/.test(line)) {
1974
+ out.push("code-fence");
1975
+ inCode = !inCode;
1976
+ } else {
1977
+ out.push(inCode ? "code" : "prose");
1978
+ }
1979
+ }
1980
+ return out;
1981
+ }
1982
+ function getLineStyle(category) {
1983
+ switch (category) {
1984
+ case "diff-add":
1985
+ return { color: "green" };
1986
+ case "diff-remove":
1987
+ return { color: "red" };
1988
+ case "diff-hunk":
1989
+ return { color: "cyan" };
1990
+ case "diff-meta":
1991
+ return { dimColor: true };
1992
+ case "code-fence":
1993
+ return { color: "cyan", dimColor: true };
1994
+ case "code":
1995
+ return { color: "cyan" };
1996
+ case "prose":
1997
+ return {};
1998
+ }
1999
+ }
2000
+
2001
+ // src/ui/DetailViewPanel.tsx
1852
2002
  import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
1853
- function wrapText(text, maxWidth) {
1854
- if (!text) return ["(empty)"];
1855
- const result = [];
1856
- for (const rawLine of text.split("\n")) {
1857
- if (!rawLine) {
1858
- result.push("");
2003
+ function wrapClassified(text, maxWidth, classifier) {
2004
+ if (!text) return [{ text: "(empty)", category: "prose" }];
2005
+ const sourceLines = text.split("\n");
2006
+ const categories = classifier(sourceLines);
2007
+ const out = [];
2008
+ for (let i = 0; i < sourceLines.length; i++) {
2009
+ const line = sourceLines[i];
2010
+ const cat = categories[i] ?? "prose";
2011
+ if (!line) {
2012
+ out.push({ text: "", category: cat });
1859
2013
  continue;
1860
2014
  }
1861
- const words = rawLine.split(" ");
2015
+ const words = line.split(" ");
1862
2016
  let current = "";
1863
2017
  for (const word of words) {
1864
2018
  if (!current) {
@@ -1866,13 +2020,13 @@ function wrapText(text, maxWidth) {
1866
2020
  } else if (getDisplayWidth(`${current} ${word}`) <= maxWidth) {
1867
2021
  current += ` ${word}`;
1868
2022
  } else {
1869
- result.push(current);
2023
+ out.push({ text: current, category: cat });
1870
2024
  current = word;
1871
2025
  }
1872
2026
  }
1873
- if (current) result.push(current);
2027
+ if (current) out.push({ text: current, category: cat });
1874
2028
  }
1875
- return result.length > 0 ? result : ["(empty)"];
2029
+ return out.length > 0 ? out : [{ text: "(empty)", category: "prose" }];
1876
2030
  }
1877
2031
  function DetailViewPanel({
1878
2032
  activity,
@@ -1882,7 +2036,8 @@ function DetailViewPanel({
1882
2036
  }) {
1883
2037
  const innerWidth = getInnerWidth(width);
1884
2038
  const contentWidth = innerWidth - 1;
1885
- const allLines = wrapText(activity.detail, contentWidth);
2039
+ const classifier = activity.type === "commit" ? classifyDiffLines : classifyCodeFences;
2040
+ const allLines = wrapClassified(activity.detail, contentWidth, classifier);
1886
2041
  const totalLines = allLines.length;
1887
2042
  const clampedOffset = Math.min(
1888
2043
  scrollOffset,
@@ -1906,13 +2061,14 @@ function DetailViewPanel({
1906
2061
  const titleRight = `${dashes}${scrollPart}${BOX.tr}`;
1907
2062
  const contentRows = [];
1908
2063
  for (let i = 0; i < visibleRows; i++) {
1909
- const line = visibleSlice[i] ?? "";
1910
- const padding = Math.max(0, contentWidth - getDisplayWidth(line));
2064
+ const entry = visibleSlice[i] ?? { text: "", category: "prose" };
2065
+ const padding = Math.max(0, contentWidth - getDisplayWidth(entry.text));
2066
+ const lineStyle = getLineStyle(entry.category);
1911
2067
  contentRows.push(
1912
2068
  /* @__PURE__ */ jsxs2(Text2, { children: [
1913
2069
  BOX.v,
1914
2070
  " ",
1915
- line,
2071
+ /* @__PURE__ */ jsx2(Text2, { color: lineStyle.color, dimColor: lineStyle.dimColor, children: entry.text }),
1916
2072
  " ".repeat(padding),
1917
2073
  BOX.v
1918
2074
  ] }, i)
@@ -1956,8 +2112,8 @@ var SECTIONS = [
1956
2112
  ["\u2191 \u2193 / k j", "Scroll one line"],
1957
2113
  ["PgUp/Dn, Ctrl+B/F", "Scroll one page"],
1958
2114
  ["Ctrl+U / Ctrl+D", "Scroll half page"],
1959
- ["g", "Jump to live (newest)"],
1960
- ["G", "Jump to oldest"],
2115
+ ["g", "Jump to top (oldest)"],
2116
+ ["G", "Jump to live (newest, bottom)"],
1961
2117
  ["\u21B5", "Open detail view for selected activity"],
1962
2118
  ["f", "Cycle filter preset (set in config.yaml)"],
1963
2119
  ["Tab", "Switch focus to project tree"]
@@ -2234,8 +2390,8 @@ function useHotkeys({
2234
2390
  "Tab: projects",
2235
2391
  "\u2191\u2193/jk: scroll",
2236
2392
  "PgUp/Dn: page",
2237
- "g: live",
2238
- "G: oldest",
2393
+ "g: oldest",
2394
+ "G: live",
2239
2395
  "\u21B5: detail",
2240
2396
  `f: ${filterLabel}`,
2241
2397
  "?: help",
@@ -2244,12 +2400,29 @@ function useHotkeys({
2244
2400
  return { handleInput, statusBarItems };
2245
2401
  }
2246
2402
 
2247
- // src/ui/hooks/useSpinner.ts
2403
+ // src/ui/hooks/useSlide.ts
2248
2404
  import { useEffect, useState } from "react";
2249
- var FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
2250
- function useSpinner(active, intervalMs = 100) {
2405
+ function useSlide(active, positions, intervalMs = 180, resetKey) {
2251
2406
  const [index, setIndex] = useState(0);
2407
+ useEffect(() => {
2408
+ setIndex(0);
2409
+ }, [resetKey]);
2252
2410
  useEffect(() => {
2411
+ if (!active) return;
2412
+ const timer = setInterval(() => {
2413
+ setIndex((i) => (i + 1) % positions);
2414
+ }, intervalMs);
2415
+ return () => clearInterval(timer);
2416
+ }, [active, positions, intervalMs]);
2417
+ return index;
2418
+ }
2419
+
2420
+ // src/ui/hooks/useSpinner.ts
2421
+ import { useEffect as useEffect2, useState as useState2 } from "react";
2422
+ var FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
2423
+ function useSpinner(active, intervalMs = 100) {
2424
+ const [index, setIndex] = useState2(0);
2425
+ useEffect2(() => {
2253
2426
  if (!active) return;
2254
2427
  const timer = setInterval(() => {
2255
2428
  setIndex((i) => (i + 1) % FRAMES.length);
@@ -2263,12 +2436,20 @@ function useSpinner(active, intervalMs = 100) {
2263
2436
  import { homedir as homedir4 } from "os";
2264
2437
  import { Box as Box4, Text as Text4 } from "ink";
2265
2438
  import { Fragment, jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
2266
- function formatElapsed(lastModifiedMs) {
2267
- const elapsed = Date.now() - lastModifiedMs;
2439
+ function formatElapsed(lastModifiedMs, now = Date.now()) {
2440
+ const elapsed = Math.max(0, now - lastModifiedMs);
2268
2441
  const seconds = Math.floor(elapsed / 1e3);
2269
2442
  const minutes = Math.floor(seconds / 60);
2270
2443
  const hours = Math.floor(minutes / 60);
2271
- if (hours > 0) return `${hours}h${minutes % 60}m`;
2444
+ const days = Math.floor(hours / 24);
2445
+ const weeks = Math.floor(days / 7);
2446
+ const months = Math.floor(days / 30);
2447
+ const years = Math.floor(days / 365);
2448
+ if (years >= 1) return `${years}y`;
2449
+ if (months >= 1) return `${months}mo`;
2450
+ if (weeks >= 1) return `${weeks}w`;
2451
+ if (days >= 1) return `${days}d`;
2452
+ if (hours > 0) return `${hours}h`;
2272
2453
  if (minutes > 0) return `${minutes}m`;
2273
2454
  if (seconds > 0) return `${seconds}s`;
2274
2455
  return "<1s";
@@ -2290,11 +2471,6 @@ function formatProjectPath(projectPath) {
2290
2471
  const raw = projectPath.startsWith(home) ? `~${projectPath.slice(home.length)}` : projectPath;
2291
2472
  return raw;
2292
2473
  }
2293
- function truncatePath(path, maxWidth) {
2294
- if (getDisplayWidth(path) <= maxWidth) return path;
2295
- if (maxWidth < 4) return "";
2296
- return `...${path.slice(-(maxWidth - 3))}`;
2297
- }
2298
2474
  function SessionRow({
2299
2475
  session,
2300
2476
  isSelected,
@@ -2316,36 +2492,40 @@ function SessionRow({
2316
2492
  const leftCoreBase = `${prefix}${rawName}${shortIdDisplay} ${badge}`;
2317
2493
  const leftCoreWidth = getDisplayWidth(leftCoreBase);
2318
2494
  const rightWidth = getDisplayWidth(rightSide);
2319
- const middleAvailable = contentWidth - leftCoreWidth - 1 - rightWidth - 1;
2495
+ const RIGHT_GAP = 3;
2496
+ const middleAvailable = contentWidth - leftCoreWidth - 1 - rightWidth - RIGHT_GAP;
2320
2497
  let middleText = "";
2321
- if (middleAvailable > 3) {
2498
+ if (middleAvailable > 1) {
2322
2499
  const raw = isParent ? session.firstUserPrompt ?? "" : session.taskDescription ?? "";
2323
2500
  if (raw) {
2324
- const truncated = truncatePath(raw, middleAvailable);
2501
+ const flat = raw.replace(/[\r\n\t]+/g, " ").trim();
2502
+ const truncated = truncateByWidth(flat, middleAvailable);
2325
2503
  if (truncated) middleText = truncated;
2326
2504
  }
2327
2505
  }
2328
2506
  const middleSection = middleText ? ` ${middleText}` : "";
2329
2507
  const gapWidth = Math.max(
2330
- 1,
2508
+ RIGHT_GAP,
2331
2509
  contentWidth - leftCoreWidth - getDisplayWidth(middleSection) - rightWidth
2332
2510
  );
2333
2511
  const gap = " ".repeat(gapWidth);
2334
2512
  const fullLine = leftCoreBase + middleSection + gap + rightSide;
2335
2513
  const linePadding = Math.max(0, contentWidth - getDisplayWidth(fullLine));
2336
- const highlight = isSelected && hasFocus;
2337
- const shouldDim = isNonInteractive;
2514
+ const focused = isSelected && hasFocus;
2515
+ const muted = isSelected && !hasFocus;
2516
+ const showBg = focused || muted;
2517
+ const shouldDim = isNonInteractive || muted;
2338
2518
  return /* @__PURE__ */ jsxs4(Text4, { children: [
2339
2519
  BOX.v,
2340
2520
  " ",
2341
2521
  /* @__PURE__ */ jsxs4(
2342
2522
  Text4,
2343
2523
  {
2344
- backgroundColor: highlight ? "blue" : void 0,
2345
- bold: highlight,
2346
- dimColor: shouldDim && !highlight,
2524
+ backgroundColor: showBg ? "blue" : void 0,
2525
+ bold: focused,
2526
+ dimColor: shouldDim && !focused,
2347
2527
  children: [
2348
- /* @__PURE__ */ jsx4(Text4, { dimColor: shouldDim && !highlight, children: prefix }),
2528
+ /* @__PURE__ */ jsx4(Text4, { dimColor: shouldDim && !focused, children: prefix }),
2349
2529
  /* @__PURE__ */ jsx4(Text4, { bold: !shouldDim, children: rawName }),
2350
2530
  shortIdDisplay ? /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: shortIdDisplay }) : null,
2351
2531
  /* @__PURE__ */ jsx4(Text4, { children: " " }),
@@ -2442,23 +2622,47 @@ function ProjectRow({
2442
2622
  }) {
2443
2623
  const nameText = `> ${project.name}`;
2444
2624
  const pathText = project.projectPath ? formatProjectPath(project.projectPath) : "";
2625
+ const latestMtime = project.sessions.reduce(
2626
+ (acc, s) => Math.max(acc, s.lastModifiedMs),
2627
+ 0
2628
+ );
2629
+ const elapsed = latestMtime > 0 ? formatElapsed(latestMtime) : "";
2445
2630
  const nameWidth = getDisplayWidth(nameText);
2446
2631
  const pathWidth = pathText ? getDisplayWidth(pathText) : 0;
2447
- const gapWidth = pathText ? 2 : 0;
2448
- const totalWidth = nameWidth + gapWidth + pathWidth;
2632
+ const elapsedWidth = elapsed ? getDisplayWidth(elapsed) : 0;
2633
+ const middleGap = pathText ? 2 : 0;
2634
+ const leftWidth = nameWidth + middleGap + pathWidth;
2635
+ const PROJECT_RIGHT_GAP = 3;
2636
+ const rightGap = Math.max(
2637
+ PROJECT_RIGHT_GAP,
2638
+ contentWidth - leftWidth - elapsedWidth
2639
+ );
2640
+ const totalWidth = leftWidth + rightGap + elapsedWidth;
2449
2641
  const padding = Math.max(0, contentWidth - totalWidth);
2450
- const highlight = isSelected && hasFocus;
2642
+ const focused = isSelected && hasFocus;
2643
+ const muted = isSelected && !hasFocus;
2644
+ const showBg = focused || muted;
2451
2645
  return /* @__PURE__ */ jsxs4(Text4, { children: [
2452
2646
  BOX.v,
2453
2647
  " ",
2454
- /* @__PURE__ */ jsxs4(Text4, { backgroundColor: highlight ? "blue" : void 0, bold: !highlight, children: [
2455
- nameText,
2456
- pathText ? /* @__PURE__ */ jsxs4(Fragment, { children: [
2457
- " ",
2458
- /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: pathText })
2459
- ] }) : null,
2460
- " ".repeat(padding)
2461
- ] }),
2648
+ /* @__PURE__ */ jsxs4(
2649
+ Text4,
2650
+ {
2651
+ backgroundColor: showBg ? "blue" : void 0,
2652
+ bold: !showBg,
2653
+ dimColor: muted,
2654
+ children: [
2655
+ nameText,
2656
+ pathText ? /* @__PURE__ */ jsxs4(Fragment, { children: [
2657
+ " ",
2658
+ /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: pathText })
2659
+ ] }) : null,
2660
+ " ".repeat(rightGap),
2661
+ elapsed ? /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: elapsed }) : null,
2662
+ " ".repeat(padding)
2663
+ ]
2664
+ }
2665
+ ),
2462
2666
  BOX.v
2463
2667
  ] });
2464
2668
  }
@@ -2478,11 +2682,12 @@ function SubagentSummaryRow({
2478
2682
  0,
2479
2683
  contentWidth - getDisplayWidth(text) - getDisplayWidth(hint)
2480
2684
  );
2481
- const active = isSelected && hasFocus;
2685
+ const focused = isSelected && hasFocus;
2686
+ const muted = isSelected && !hasFocus;
2482
2687
  return /* @__PURE__ */ jsxs4(Text4, { children: [
2483
2688
  BOX.v,
2484
2689
  " ",
2485
- /* @__PURE__ */ jsxs4(Text4, { dimColor: !active, inverse: active, children: [
2690
+ /* @__PURE__ */ jsxs4(Text4, { dimColor: !focused, inverse: focused || muted, children: [
2486
2691
  text,
2487
2692
  " ".repeat(padding),
2488
2693
  hint
@@ -2504,13 +2709,14 @@ function ColdProjectsSummaryRow({
2504
2709
  const dashCount = Math.max(0, innerWidth - 1 - labelWidth - hintWidth);
2505
2710
  const dashes = BOX.h.repeat(dashCount);
2506
2711
  const line = `${BOX.ml}${BOX.h}${label}${dashes}${hint}${BOX.mr}`;
2507
- const highlight = isSelected && hasFocus;
2712
+ const focused = isSelected && hasFocus;
2713
+ const muted = isSelected && !hasFocus;
2508
2714
  return /* @__PURE__ */ jsx4(
2509
2715
  Text4,
2510
2716
  {
2511
- backgroundColor: highlight ? "blue" : void 0,
2512
- bold: highlight,
2513
- dimColor: !highlight,
2717
+ backgroundColor: focused || muted ? "blue" : void 0,
2718
+ bold: focused,
2719
+ dimColor: !focused,
2514
2720
  children: line
2515
2721
  }
2516
2722
  );
@@ -2712,14 +2918,14 @@ function getSelectedActivity(acts, live, scrollOff, rows, cursorLine) {
2712
2918
  if (acts.length === 0) return null;
2713
2919
  let visible;
2714
2920
  if (live) {
2715
- visible = acts.slice(-rows).reverse();
2921
+ visible = acts.slice(-rows);
2716
2922
  } else {
2717
2923
  const end = Math.max(0, acts.length - scrollOff);
2718
2924
  const start = Math.max(0, end - rows);
2719
- visible = acts.slice(start, end).reverse();
2925
+ visible = acts.slice(start, end);
2720
2926
  }
2721
2927
  const effectiveCursor = Math.min(cursorLine, visible.length - 1);
2722
- return visible[effectiveCursor] ?? null;
2928
+ return visible[visible.length - 1 - effectiveCursor] ?? null;
2723
2929
  }
2724
2930
  function App({ mode }) {
2725
2931
  const { exit } = useApp();
@@ -2727,47 +2933,47 @@ function App({ mode }) {
2727
2933
  const isWatchMode = mode === "watch";
2728
2934
  const config = useMemo(() => loadGlobalConfig(), []);
2729
2935
  const migrationWarning = useMemo(() => hasProjectLevelConfig(), []);
2730
- const [sessionTree, setSessionTree] = useState2(
2936
+ const [sessionTree, setSessionTree] = useState3(
2731
2937
  () => discoverSessions(config)
2732
2938
  );
2733
- const [selectedId, setSelectedId] = useState2(() => {
2939
+ const [selectedId, setSelectedId] = useState3(() => {
2734
2940
  const firstProject = sessionTree.projects[0];
2735
2941
  if (firstProject) return `__proj-${firstProject.name}__`;
2736
2942
  return null;
2737
2943
  });
2738
- const [focus, setFocus] = useState2("tree");
2739
- const [scrollOffset, setScrollOffset] = useState2(0);
2740
- const [isLive, setIsLive] = useState2(true);
2741
- const [activities, setActivities] = useState2([]);
2742
- const [gitActivities, setGitActivities] = useState2([]);
2743
- const [newCount, setNewCount] = useState2(0);
2744
- const [expandedIds, setExpandedIds] = useState2(/* @__PURE__ */ new Set());
2745
- const [viewerCursorLine, setViewerCursorLine] = useState2(0);
2746
- const [detailMode, setDetailMode] = useState2(false);
2747
- const [detailActivity, setDetailActivity] = useState2(
2944
+ const [focus, setFocus] = useState3("tree");
2945
+ const [scrollOffset, setScrollOffset] = useState3(0);
2946
+ const [isLive, setIsLive] = useState3(true);
2947
+ const [activities, setActivities] = useState3([]);
2948
+ const [gitActivities, setGitActivities] = useState3([]);
2949
+ const [newCount, setNewCount] = useState3(0);
2950
+ const [expandedIds, setExpandedIds] = useState3(/* @__PURE__ */ new Set());
2951
+ const [viewerCursorLine, setViewerCursorLine] = useState3(0);
2952
+ const [detailMode, setDetailMode] = useState3(false);
2953
+ const [detailActivity, setDetailActivity] = useState3(
2748
2954
  null
2749
2955
  );
2750
- const [detailScrollOffset, setDetailScrollOffset] = useState2(0);
2751
- const [filterIndex, setFilterIndex] = useState2(0);
2752
- const [helpMode, setHelpMode] = useState2(false);
2753
- const [helpScroll, setHelpScroll] = useState2(0);
2956
+ const [detailScrollOffset, setDetailScrollOffset] = useState3(0);
2957
+ const [filterIndex, setFilterIndex] = useState3(0);
2958
+ const [helpMode, setHelpMode] = useState3(false);
2959
+ const [helpScroll, setHelpScroll] = useState3(0);
2754
2960
  const helpTotalLinesRef = useRef(0);
2755
2961
  const allFlat = useMemo(
2756
2962
  () => flattenSessions2(sessionTree, expandedIds),
2757
2963
  [sessionTree, expandedIds]
2758
2964
  );
2759
2965
  const allFlatRef = useRef(allFlat);
2760
- useEffect2(() => {
2966
+ useEffect3(() => {
2761
2967
  allFlatRef.current = allFlat;
2762
2968
  }, [allFlat]);
2763
2969
  const activitiesLengthRef = useRef(0);
2764
2970
  const activitiesRef = useRef(activities);
2765
- useEffect2(() => {
2971
+ useEffect3(() => {
2766
2972
  activitiesLengthRef.current = activities.length;
2767
2973
  activitiesRef.current = activities;
2768
2974
  }, [activities]);
2769
2975
  const lastLoadedFileRef = useRef(null);
2770
- useEffect2(() => {
2976
+ useEffect3(() => {
2771
2977
  let node = allFlatRef.current.find((s) => s.id === selectedId);
2772
2978
  if (node && selectedId?.startsWith("__proj-") && selectedId.endsWith("__")) {
2773
2979
  const projectName = selectedId.slice(7, -2);
@@ -2795,12 +3001,12 @@ function App({ mode }) {
2795
3001
  if (fileChanged) setGitActivities([]);
2796
3002
  }
2797
3003
  }, [selectedId, sessionTree]);
2798
- useEffect2(() => {
3004
+ useEffect3(() => {
2799
3005
  setScrollOffset(0);
2800
3006
  setIsLive(true);
2801
3007
  setViewerCursorLine(0);
2802
3008
  }, [filterIndex]);
2803
- useEffect2(() => {
3009
+ useEffect3(() => {
2804
3010
  if (!isWatchMode) return;
2805
3011
  const node = allFlatRef.current.find((s) => s.id === selectedId);
2806
3012
  if (!node?.projectPath) return;
@@ -2855,10 +3061,10 @@ function App({ mode }) {
2855
3061
  }
2856
3062
  }, [selectedId, isLive, expandedIds]);
2857
3063
  const refreshRef = useRef(refresh);
2858
- useEffect2(() => {
3064
+ useEffect3(() => {
2859
3065
  refreshRef.current = refresh;
2860
3066
  }, [refresh]);
2861
- useEffect2(() => {
3067
+ useEffect3(() => {
2862
3068
  if (!isWatchMode) return;
2863
3069
  const projectsDir = getProjectsDir();
2864
3070
  const usePolling = process.platform === "linux" || !existsSync5(projectsDir);
@@ -2912,8 +3118,21 @@ function App({ mode }) {
2912
3118
  const maxTreeRows = Math.floor(height * (1 - VIEWER_HEIGHT_FRACTION));
2913
3119
  const naturalTreeRows = allFlat.length;
2914
3120
  const treeRows = Math.max(1, Math.min(naturalTreeRows, maxTreeRows));
2915
- const viewerRows = Math.max(5, height - 7 - treeRows);
3121
+ const VIEWER_BREATHING_ROWS = 1;
3122
+ const viewerRows = Math.max(
3123
+ 5,
3124
+ height - 7 - treeRows - VIEWER_BREATHING_ROWS
3125
+ );
2916
3126
  const spinner = useSpinner(isWatchMode);
3127
+ const viewerIndicatorWidth = Math.max(1, width - 3);
3128
+ const liveIndicatorPosition = useSlide(
3129
+ isWatchMode,
3130
+ viewerIndicatorWidth,
3131
+ 180,
3132
+ // Reset to 0 whenever the viewer's subject changes so each new
3133
+ // session/sub-agent restarts the arrow from the left.
3134
+ selectedId
3135
+ );
2917
3136
  const helpViewportRows = Math.max(1, height - 3);
2918
3137
  const helpScrollStep = (delta) => {
2919
3138
  const max = Math.max(0, helpTotalLinesRef.current - helpViewportRows);
@@ -2930,11 +3149,30 @@ function App({ mode }) {
2930
3149
  onHelpScroll: helpScrollStep,
2931
3150
  onHelpScrollToTop: () => setHelpScroll(0),
2932
3151
  onSwitchFocus: () => setFocus((f) => f === "tree" ? "viewer" : "tree"),
3152
+ // cursorLine = "entries back from the newest" (0 = newest = bottom row).
3153
+ // Up arrow moves visually upward = older direction = cursorLine++.
3154
+ // Down arrow moves visually downward = newer direction = cursorLine--.
2933
3155
  onScrollUp: () => {
2934
3156
  if (focus === "tree") {
2935
3157
  if (selectedIndex === -1) return;
2936
3158
  const prev = Math.max(0, selectedIndex - 1);
2937
3159
  setSelectedId(allFlat[prev]?.id ?? selectedId);
3160
+ } else {
3161
+ if (viewerCursorLine < viewerRows - 1) {
3162
+ setViewerCursorLine((c) => c + 1);
3163
+ } else {
3164
+ setIsLive(false);
3165
+ setScrollOffset(
3166
+ (o) => Math.min(o + 1, Math.max(0, activities.length - viewerRows))
3167
+ );
3168
+ }
3169
+ }
3170
+ },
3171
+ onScrollDown: () => {
3172
+ if (focus === "tree") {
3173
+ if (selectedIndex === -1) return;
3174
+ const next = Math.min(allFlat.length - 1, selectedIndex + 1);
3175
+ setSelectedId(allFlat[next]?.id ?? selectedId);
2938
3176
  } else {
2939
3177
  if (viewerCursorLine > 0) {
2940
3178
  setViewerCursorLine((c) => c - 1);
@@ -2950,26 +3188,25 @@ function App({ mode }) {
2950
3188
  }
2951
3189
  }
2952
3190
  },
2953
- onScrollDown: () => {
3191
+ // PgUp/PgDn semantics flip to match the bottom-feed layout:
3192
+ // PgUp = visually up = older direction = scrollOffset++
3193
+ // PgDn = visually down = newer direction = scrollOffset--
3194
+ onScrollPageUp: () => {
2954
3195
  if (focus === "tree") {
2955
- if (selectedIndex === -1) return;
2956
- const next = Math.min(allFlat.length - 1, selectedIndex + 1);
2957
- setSelectedId(allFlat[next]?.id ?? selectedId);
3196
+ const prev = Math.max(0, selectedIndex - 5);
3197
+ setSelectedId(allFlat[prev]?.id ?? selectedId);
2958
3198
  } else {
2959
- if (viewerCursorLine < viewerRows - 1) {
2960
- setViewerCursorLine((c) => c + 1);
2961
- } else {
2962
- setIsLive(false);
2963
- setScrollOffset(
2964
- (o) => Math.min(o + 1, Math.max(0, activities.length - viewerRows))
2965
- );
2966
- }
3199
+ setViewerCursorLine(0);
3200
+ setIsLive(false);
3201
+ setScrollOffset(
3202
+ (o) => Math.min(o + viewerRows, Math.max(0, activities.length - viewerRows))
3203
+ );
2967
3204
  }
2968
3205
  },
2969
- onScrollPageUp: () => {
3206
+ onScrollPageDown: () => {
2970
3207
  if (focus === "tree") {
2971
- const prev = Math.max(0, selectedIndex - 5);
2972
- setSelectedId(allFlat[prev]?.id ?? selectedId);
3208
+ const next = Math.min(allFlat.length - 1, selectedIndex + 5);
3209
+ setSelectedId(allFlat[next]?.id ?? selectedId);
2973
3210
  } else {
2974
3211
  setViewerCursorLine(0);
2975
3212
  setScrollOffset((o) => {
@@ -2982,22 +3219,28 @@ function App({ mode }) {
2982
3219
  });
2983
3220
  }
2984
3221
  },
2985
- onScrollPageDown: () => {
3222
+ onScrollHalfPageUp: () => {
2986
3223
  if (focus === "tree") {
2987
- const next = Math.min(allFlat.length - 1, selectedIndex + 5);
2988
- setSelectedId(allFlat[next]?.id ?? selectedId);
3224
+ const prev = Math.max(0, selectedIndex - Math.ceil(5 / 2));
3225
+ setSelectedId(allFlat[prev]?.id ?? selectedId);
2989
3226
  } else {
2990
3227
  setViewerCursorLine(0);
2991
3228
  setIsLive(false);
2992
3229
  setScrollOffset(
2993
- (o) => Math.min(o + viewerRows, Math.max(0, activities.length - viewerRows))
3230
+ (o) => Math.min(
3231
+ o + Math.floor(viewerRows / 2),
3232
+ Math.max(0, activities.length - viewerRows)
3233
+ )
2994
3234
  );
2995
3235
  }
2996
3236
  },
2997
- onScrollHalfPageUp: () => {
3237
+ onScrollHalfPageDown: () => {
2998
3238
  if (focus === "tree") {
2999
- const prev = Math.max(0, selectedIndex - Math.ceil(5 / 2));
3000
- setSelectedId(allFlat[prev]?.id ?? selectedId);
3239
+ const next = Math.min(
3240
+ allFlat.length - 1,
3241
+ selectedIndex + Math.ceil(5 / 2)
3242
+ );
3243
+ setSelectedId(allFlat[next]?.id ?? selectedId);
3001
3244
  } else {
3002
3245
  setViewerCursorLine(0);
3003
3246
  setScrollOffset((o) => {
@@ -3010,35 +3253,17 @@ function App({ mode }) {
3010
3253
  });
3011
3254
  }
3012
3255
  },
3013
- onScrollHalfPageDown: () => {
3014
- if (focus === "tree") {
3015
- const next = Math.min(
3016
- allFlat.length - 1,
3017
- selectedIndex + Math.ceil(5 / 2)
3018
- );
3019
- setSelectedId(allFlat[next]?.id ?? selectedId);
3020
- } else {
3021
- setViewerCursorLine(0);
3022
- setIsLive(false);
3023
- setScrollOffset(
3024
- (o) => Math.min(
3025
- o + Math.floor(viewerRows / 2),
3026
- Math.max(0, activities.length - viewerRows)
3027
- )
3028
- );
3029
- }
3030
- },
3031
3256
  onScrollTop: () => {
3257
+ setViewerCursorLine(Math.max(0, viewerRows - 1));
3258
+ setIsLive(false);
3259
+ setScrollOffset(Math.max(0, mergedActivities.length - viewerRows));
3260
+ },
3261
+ onScrollBottom: () => {
3032
3262
  setViewerCursorLine(0);
3033
3263
  setIsLive(true);
3034
3264
  setScrollOffset(0);
3035
3265
  setNewCount(0);
3036
3266
  },
3037
- onScrollBottom: () => {
3038
- setViewerCursorLine(0);
3039
- setIsLive(false);
3040
- setScrollOffset(Math.max(0, mergedActivities.length - viewerRows));
3041
- },
3042
3267
  onDetailClose: () => {
3043
3268
  setDetailMode(false);
3044
3269
  },
@@ -3130,15 +3355,23 @@ function App({ mode }) {
3130
3355
  const toggleKey = isCold ? `__expanded-session-${selectedId}` : `__collapsed-session-${selectedId}`;
3131
3356
  setExpandedIds((prev) => {
3132
3357
  const next = new Set(prev);
3133
- if (next.has(toggleKey)) {
3134
- next.delete(toggleKey);
3135
- if (!isCold) setSelectedId(selectedId);
3136
- } else {
3137
- next.add(toggleKey);
3138
- if (isCold) {
3358
+ if (isCold) {
3359
+ if (next.has(toggleKey)) {
3360
+ next.delete(toggleKey);
3361
+ next.delete(selectedId);
3362
+ } else {
3363
+ next.add(toggleKey);
3139
3364
  const firstSub = selectedSessionObj.subAgents[0];
3140
3365
  if (firstSub) setSelectedId(firstSub.id);
3141
3366
  }
3367
+ } else {
3368
+ if (next.has(toggleKey)) {
3369
+ next.delete(toggleKey);
3370
+ } else {
3371
+ next.add(toggleKey);
3372
+ next.delete(selectedId);
3373
+ setSelectedId(selectedId);
3374
+ }
3142
3375
  }
3143
3376
  return next;
3144
3377
  });
@@ -3273,6 +3506,8 @@ function App({ mode }) {
3273
3506
  isLive,
3274
3507
  newCount,
3275
3508
  visibleRows: viewerRows,
3509
+ trailingBlankRows: VIEWER_BREATHING_ROWS,
3510
+ liveIndicatorPosition,
3276
3511
  width,
3277
3512
  cursorLine: viewerCursorLine,
3278
3513
  hasFocus: focus === "viewer",
@@ -3369,6 +3604,9 @@ if (options.mode === "summary") {
3369
3604
  process.exit(exitCode);
3370
3605
  }
3371
3606
  if (options.mode === "watch") {
3372
- clearScreen();
3607
+ installAltScreenCleanup();
3608
+ enterAltScreen();
3609
+ } else {
3610
+ if (options.mode === "once") clearScreen();
3373
3611
  }
3374
3612
  render(React.createElement(App, { mode: options.mode }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agenthud",
3
- "version": "0.9.2",
3
+ "version": "0.9.3",
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",