agenthud 0.9.2 → 0.9.4

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.
@@ -53,6 +53,7 @@ var KNOWN_SUMMARY_FLAGS = /* @__PURE__ */ new Set([
53
53
  "--to",
54
54
  "--prompt",
55
55
  "--force",
56
+ "--model",
56
57
  "-y",
57
58
  "--yes"
58
59
  ]);
@@ -89,6 +90,7 @@ Commands:
89
90
  --to YYYY-MM-DD Date range: end date (use with --from)
90
91
  --prompt TEXT Override prompt for this run (daily only)
91
92
  --force Regenerate even if cached
93
+ --model NAME Pass --model to claude (e.g. "sonnet", "haiku", or a full model ID)
92
94
  -y, --yes Skip confirmation prompts for new daily summaries
93
95
 
94
96
  Environment:
@@ -172,10 +174,18 @@ function parseArgs(args) {
172
174
  const includeIdx = rest.indexOf("--include");
173
175
  if (includeIdx !== -1) {
174
176
  const includeStr = rest[includeIdx + 1];
175
- if (includeStr === "all") {
177
+ if (!includeStr) {
178
+ reportError = "Invalid --include: missing value.";
179
+ } else if (includeStr === "all") {
176
180
  reportInclude = ALL_TYPES;
177
- } else if (includeStr) {
178
- reportInclude = includeStr.split(",").map((s) => s.trim()).filter(Boolean);
181
+ } else {
182
+ const tokens = includeStr.split(",").map((s) => s.trim()).filter(Boolean);
183
+ const unknown = tokens.filter((t) => !ALL_TYPES.includes(t));
184
+ if (unknown.length > 0) {
185
+ reportError = `Unknown --include type${unknown.length > 1 ? "s" : ""}: ${unknown.map((u) => `"${u}"`).join(", ")}. Valid types: ${ALL_TYPES.join(", ")} (or "all").`;
186
+ } else {
187
+ reportInclude = tokens;
188
+ }
179
189
  }
180
190
  }
181
191
  let reportFormat = "markdown";
@@ -220,13 +230,15 @@ function parseArgs(args) {
220
230
  let summaryPrompt;
221
231
  let summaryForce = false;
222
232
  let summaryAssumeYes = false;
233
+ let summaryModel;
223
234
  let summaryError;
224
235
  const FLAGS_WITH_VALUE = /* @__PURE__ */ new Set([
225
236
  "--date",
226
237
  "--last",
227
238
  "--from",
228
239
  "--to",
229
- "--prompt"
240
+ "--prompt",
241
+ "--model"
230
242
  ]);
231
243
  for (let i = 0; i < rest.length; i++) {
232
244
  const arg = rest[i];
@@ -318,6 +330,15 @@ function parseArgs(args) {
318
330
  summaryPrompt = val;
319
331
  }
320
332
  }
333
+ const modelIdx = rest.indexOf("--model");
334
+ if (modelIdx !== -1) {
335
+ const val = rest[modelIdx + 1];
336
+ if (!val) {
337
+ summaryError = "Invalid --model: missing value (e.g. --model sonnet).";
338
+ } else {
339
+ summaryModel = val;
340
+ }
341
+ }
321
342
  if (rest.includes("--force")) summaryForce = true;
322
343
  if (rest.includes("-y") || rest.includes("--yes")) summaryAssumeYes = true;
323
344
  return {
@@ -328,6 +349,7 @@ function parseArgs(args) {
328
349
  summaryPrompt,
329
350
  summaryForce,
330
351
  summaryAssumeYes,
352
+ summaryModel,
331
353
  summaryError
332
354
  };
333
355
  }
@@ -348,6 +370,44 @@ function parseArgs(args) {
348
370
  return { mode: "watch" };
349
371
  }
350
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
+ });
409
+ }
410
+
351
411
  // src/config/globalConfig.ts
352
412
  import { existsSync, mkdirSync, readFileSync as readFileSync2, writeFileSync } from "fs";
353
413
  import { homedir } from "os";
@@ -359,9 +419,16 @@ var DEFAULT_GLOBAL_CONFIG = {
359
419
  refreshIntervalMs: 2e3,
360
420
  hiddenSessions: [],
361
421
  hiddenSubAgents: [],
362
- filterPresets: [[], ["response"], ["commit"]],
422
+ // [] means "show all"; conversation preset bundles assistant + user;
423
+ // commits-only preset filters down to git activity.
424
+ filterPresets: [[], ["response", "user"], ["commit"]],
363
425
  hiddenProjects: []
364
426
  };
427
+ var ALL_PRESET_KEYWORDS = /* @__PURE__ */ new Set(["all", "*", "any"]);
428
+ function normalizePreset(tokens) {
429
+ if (tokens.some((t) => ALL_PRESET_KEYWORDS.has(t.toLowerCase()))) return [];
430
+ return tokens;
431
+ }
365
432
  function parseInterval(value) {
366
433
  const match = value.match(/^(\d+)(s|m)$/);
367
434
  if (!match) return null;
@@ -381,10 +448,11 @@ function writeDefaultConfig() {
381
448
  refreshInterval: 2s
382
449
 
383
450
  # Activity filter presets (cycle with 'f' key in viewer)
384
- # Each list is one preset; [] means "all". First preset is the default.
451
+ # Each list is one preset. Use "all" (or "*") to show everything.
452
+ # Types: response, user, bash, edit, thinking, read, glob, commit
385
453
  filterPresets:
386
- - []
387
- - ["response"]
454
+ - ["all"]
455
+ - ["response", "user"]
388
456
  - ["commit"]
389
457
  `;
390
458
  try {
@@ -430,9 +498,12 @@ function loadGlobalConfig() {
430
498
  if (ms !== null) config.refreshIntervalMs = ms;
431
499
  }
432
500
  if (Array.isArray(configRaw.filterPresets)) {
433
- const presets = configRaw.filterPresets.filter(Array.isArray).map(
434
- (p) => p.filter((t) => typeof t === "string")
435
- );
501
+ const presets = configRaw.filterPresets.filter(Array.isArray).map((p) => {
502
+ const tokens = p.filter(
503
+ (t) => typeof t === "string"
504
+ );
505
+ return normalizePreset(tokens);
506
+ });
436
507
  if (presets.length > 0) config.filterPresets = presets;
437
508
  }
438
509
  const legacyHidden = {};
@@ -572,7 +643,7 @@ function getCommitDetail(projectPath, hash) {
572
643
  if (!projectPath) return null;
573
644
  try {
574
645
  return execSync(
575
- `git --git-dir="${projectPath}/.git" show --stat --no-color ${hash}`,
646
+ `git --git-dir="${projectPath}/.git" show --stat --patch --no-color ${hash}`,
576
647
  { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }
577
648
  ).trim();
578
649
  } catch {
@@ -931,6 +1002,21 @@ function createBottomLine(panelWidth = DEFAULT_PANEL_WIDTH) {
931
1002
  return BOX.bl + BOX.h.repeat(getInnerWidth(panelWidth)) + BOX.br;
932
1003
  }
933
1004
  var SEPARATOR = "\u2500".repeat(CONTENT_WIDTH);
1005
+ function truncateByWidth(text, maxWidth) {
1006
+ if (maxWidth <= 0) return "";
1007
+ if (getDisplayWidth(text) <= maxWidth) return text;
1008
+ if (maxWidth === 1) return "\u2026";
1009
+ const ellipsisWidth = 1;
1010
+ let acc = "";
1011
+ let used = 0;
1012
+ for (const ch of text) {
1013
+ const w = getDisplayWidth(ch);
1014
+ if (used + w + ellipsisWidth > maxWidth) break;
1015
+ acc += ch;
1016
+ used += w;
1017
+ }
1018
+ return `${acc}\u2026`;
1019
+ }
934
1020
  var widthCache = /* @__PURE__ */ new Map();
935
1021
  function getDisplayWidth(s) {
936
1022
  const cached = widthCache.get(s);
@@ -965,13 +1051,19 @@ function getSessionStatus(mtimeMs) {
965
1051
  }
966
1052
  return "cold";
967
1053
  }
1054
+ var MAX_TITLE_LEN = 300;
1055
+ function capWithEllipsis(s, max = MAX_TITLE_LEN) {
1056
+ const trimmed = s.trim();
1057
+ if (trimmed.length <= max) return trimmed;
1058
+ return `${trimmed.slice(0, max - 1)}\u2026`;
1059
+ }
968
1060
  function extractTaskDescription(content) {
969
1061
  const headerMatch = content.match(/##\s*(Task\s+\d+[:\s].+)/m);
970
- if (headerMatch) return headerMatch[1].trim().slice(0, 60);
1062
+ if (headerMatch) return capWithEllipsis(headerMatch[1]);
971
1063
  const thisTaskMatch = content.match(/\*\*This Task[^:]+:\*\*\s*(.+)/);
972
- if (thisTaskMatch) return thisTaskMatch[1].trim().slice(0, 60);
1064
+ if (thisTaskMatch) return capWithEllipsis(thisTaskMatch[1]);
973
1065
  const firstLine = content.split("\n").find((l) => l.trim());
974
- return (firstLine ?? "").trim().slice(0, 60);
1066
+ return capWithEllipsis(firstLine ?? "");
975
1067
  }
976
1068
  function readSubAgentInfo(filePath) {
977
1069
  if (!existsSync3(filePath)) return { agentId: null, taskDescription: null };
@@ -1054,8 +1146,7 @@ function readFirstUserPrompt(filePath) {
1054
1146
  if (!text || isSystemNoise(text)) continue;
1055
1147
  const firstLine = text.split("\n").find((l) => l.trim()) ?? "";
1056
1148
  if (!firstLine || isSystemNoise(firstLine)) continue;
1057
- const trimmed = firstLine.trim();
1058
- return trimmed.length > 80 ? trimmed.slice(0, 80) : trimmed;
1149
+ return capWithEllipsis(firstLine);
1059
1150
  }
1060
1151
  return null;
1061
1152
  }
@@ -1322,16 +1413,18 @@ function formatUsage(u) {
1322
1413
  }
1323
1414
  function spawnClaude(opts) {
1324
1415
  return new Promise((resolve2) => {
1416
+ const args = [
1417
+ "-p",
1418
+ "--no-session-persistence",
1419
+ "--output-format",
1420
+ "stream-json",
1421
+ "--verbose"
1422
+ ];
1423
+ if (opts.model) args.push("--model", opts.model);
1424
+ args.push(opts.prompt);
1325
1425
  const proc = spawn(
1326
1426
  "claude",
1327
- [
1328
- "-p",
1329
- "--no-session-persistence",
1330
- "--output-format",
1331
- "stream-json",
1332
- "--verbose",
1333
- opts.prompt
1334
- ],
1427
+ args,
1335
1428
  {
1336
1429
  stdio: ["pipe", "pipe", "pipe"],
1337
1430
  cwd: agenthudHomeDir()
@@ -1444,6 +1537,7 @@ function spawnClaude(opts) {
1444
1537
  proc.stdin.end(opts.stdin);
1445
1538
  });
1446
1539
  }
1540
+ var REPORT_TOKEN_WARN_THRESHOLD = 3e5;
1447
1541
  async function generateDailySummary(opts) {
1448
1542
  ensureUserPromptFile("daily");
1449
1543
  const isToday = isSameLocalDay2(opts.date, opts.today);
@@ -1487,6 +1581,8 @@ async function generateDailySummary(opts) {
1487
1581
  detailLimit: 0,
1488
1582
  withGit: true
1489
1583
  });
1584
+ const reportBytes = Buffer.byteLength(reportMarkdown, "utf-8");
1585
+ const estimatedTokens = Math.ceil(reportBytes / 4);
1490
1586
  if (opts.announce) {
1491
1587
  const reportLines = reportMarkdown.split("\n");
1492
1588
  const sessionCount = reportLines.filter((l) => l.startsWith("## ")).length;
@@ -1496,11 +1592,9 @@ async function generateDailySummary(opts) {
1496
1592
  const commitCount = reportLines.filter(
1497
1593
  (l) => /^\[\d{2}:\d{2}\] ◆/.test(l)
1498
1594
  ).length;
1499
- const sizeKb = (Buffer.byteLength(reportMarkdown, "utf-8") / 1024).toFixed(
1500
- 1
1501
- );
1595
+ const sizeKb = (reportBytes / 1024).toFixed(1);
1502
1596
  process.stderr.write(
1503
- `agenthud: input: ${sessionCount} sessions, ${activityCount} activities, ${commitCount} commits (${reportLines.length} lines, ${sizeKb}KB)
1597
+ `agenthud: input: ${sessionCount} sessions, ${activityCount} activities, ${commitCount} commits (${reportLines.length} lines, ${sizeKb}KB \u2248 ${estimatedTokens.toLocaleString()} tokens)
1504
1598
  `
1505
1599
  );
1506
1600
  }
@@ -1516,6 +1610,29 @@ async function generateDailySummary(opts) {
1516
1610
  };
1517
1611
  }
1518
1612
  }
1613
+ if (estimatedTokens > REPORT_TOKEN_WARN_THRESHOLD) {
1614
+ const sizeMb = (reportBytes / (1024 * 1024)).toFixed(1);
1615
+ process.stderr.write(
1616
+ `agenthud: \u26A0 report is large (~${estimatedTokens.toLocaleString()} tokens, ${sizeMb}MB). Cost will be high; very long reports may exceed context.
1617
+ `
1618
+ );
1619
+ if (!opts.assumeYes) {
1620
+ const proceed = await ask("Send anyway? [Y/n] ", true);
1621
+ if (!proceed) {
1622
+ process.stderr.write(
1623
+ `agenthud: ${dateLabel} \u2014 aborted (report too large).
1624
+ `
1625
+ );
1626
+ return {
1627
+ code: 0,
1628
+ markdown: "",
1629
+ fromCache: false,
1630
+ skipped: true,
1631
+ usage: null
1632
+ };
1633
+ }
1634
+ }
1635
+ }
1519
1636
  if (opts.announce) {
1520
1637
  process.stderr.write(
1521
1638
  `agenthud: sending to claude (this may take a minute)...
@@ -1528,7 +1645,8 @@ async function generateDailySummary(opts) {
1528
1645
  prompt,
1529
1646
  stdin: reportMarkdown,
1530
1647
  cachePath: cached,
1531
- streamToStdout: opts.streamToStdout
1648
+ streamToStdout: opts.streamToStdout,
1649
+ model: opts.model
1532
1650
  });
1533
1651
  if (opts.announce && result.code === 0) {
1534
1652
  process.stderr.write("\n");
@@ -1554,7 +1672,8 @@ async function runSummary(options2) {
1554
1672
  force: options2.force,
1555
1673
  promptOverride: options2.prompt,
1556
1674
  streamToStdout: true,
1557
- announce: true
1675
+ announce: true,
1676
+ model: options2.model
1558
1677
  });
1559
1678
  return res.code;
1560
1679
  }
@@ -1617,7 +1736,9 @@ agenthud: --- ${label} ---
1617
1736
  force: false,
1618
1737
  streamToStdout: false,
1619
1738
  announce: true,
1620
- confirmBeforeSpawn: confirmer
1739
+ confirmBeforeSpawn: confirmer,
1740
+ assumeYes: options2.assumeYes,
1741
+ model: options2.model
1621
1742
  });
1622
1743
  if (res.skipped) {
1623
1744
  process.stderr.write(`agenthud: ${label} \u2014 skipped by user.
@@ -1662,7 +1783,8 @@ agenthud: combining ${dailyMarkdowns.length} daily summaries into range summary.
1662
1783
  prompt: metaPrompt,
1663
1784
  stdin: metaInput,
1664
1785
  cachePath: rangeCache,
1665
- streamToStdout: true
1786
+ streamToStdout: true,
1787
+ model: options2.model
1666
1788
  });
1667
1789
  if (metaResult.code !== 0) {
1668
1790
  return metaResult.code;
@@ -1680,7 +1802,7 @@ agenthud: combining ${dailyMarkdowns.length} daily summaries into range summary.
1680
1802
  // src/ui/App.tsx
1681
1803
  import { existsSync as existsSync5, watch } from "fs";
1682
1804
  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";
1805
+ import { useCallback, useEffect as useEffect3, useMemo, useRef, useState as useState3 } from "react";
1684
1806
 
1685
1807
  // src/ui/ActivityViewerPanel.tsx
1686
1808
  import { Box, Text } from "ink";
@@ -1740,6 +1862,8 @@ function ActivityViewerPanel({
1740
1862
  isLive,
1741
1863
  newCount,
1742
1864
  visibleRows,
1865
+ trailingBlankRows = 0,
1866
+ liveIndicatorPosition = null,
1743
1867
  width,
1744
1868
  cursorLine,
1745
1869
  hasFocus,
@@ -1753,18 +1877,18 @@ function ActivityViewerPanel({
1753
1877
  if (isLive) {
1754
1878
  titleSuffix = `[LIVE ${spinner || "\u25BC"}${filterSuffix}]`;
1755
1879
  } else {
1756
- const badge = newCount > 0 ? ` +${newCount}\u2191` : "";
1757
- titleSuffix = `[PAUSED \u2193${scrollOffset}${badge}${filterSuffix}]`;
1880
+ const badge = newCount > 0 ? ` +${newCount}\u2193` : "";
1881
+ titleSuffix = `[PAUSED \u2191${scrollOffset}${badge}${filterSuffix}]`;
1758
1882
  }
1759
1883
  let visibleActivities;
1760
1884
  if (activities.length === 0) {
1761
1885
  visibleActivities = [];
1762
1886
  } else if (isLive) {
1763
- visibleActivities = activities.slice(-visibleRows).reverse();
1887
+ visibleActivities = activities.slice(-visibleRows);
1764
1888
  } else {
1765
1889
  const end = Math.max(0, activities.length - scrollOffset);
1766
1890
  const start = Math.max(0, end - visibleRows);
1767
- visibleActivities = activities.slice(start, end).reverse();
1891
+ visibleActivities = activities.slice(start, end);
1768
1892
  }
1769
1893
  const now = /* @__PURE__ */ new Date();
1770
1894
  const lines = [];
@@ -1782,10 +1906,11 @@ function ActivityViewerPanel({
1782
1906
  );
1783
1907
  } else {
1784
1908
  const effectiveCursor = Math.min(cursorLine, visibleActivities.length - 1);
1909
+ const cursorIndexInSlice = visibleActivities.length - 1 - effectiveCursor;
1785
1910
  for (let i = 0; i < visibleActivities.length; i++) {
1786
1911
  const activity = visibleActivities[i];
1787
1912
  const style = getActivityStyle(activity);
1788
- const isCursor = hasFocus && i === effectiveCursor;
1913
+ const isCursor = hasFocus && i === cursorIndexInSlice;
1789
1914
  const time = formatActivityTime(activity.timestamp, now);
1790
1915
  const timestamp = `[${time}] `;
1791
1916
  const timestampWidth = timestamp.length;
@@ -1837,28 +1962,110 @@ function ActivityViewerPanel({
1837
1962
  }
1838
1963
  }
1839
1964
  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}`));
1965
+ const padCount = Math.max(0, visibleRows - lines.length);
1966
+ const padded = [];
1967
+ for (let i = 0; i < padCount; i++) {
1968
+ padded.push(/* @__PURE__ */ jsx(Text, { children: emptyRow }, `pad-${i}`));
1969
+ }
1970
+ const hasContent = visibleActivities.length > 0;
1971
+ const trailing = [];
1972
+ for (let i = 0; i < trailingBlankRows; i++) {
1973
+ if (i === 0 && isLive && liveIndicatorPosition != null && hasContent) {
1974
+ const pos = Math.max(0, liveIndicatorPosition);
1975
+ const arrow = "\u203A";
1976
+ const safePos = Math.min(pos, Math.max(0, contentWidth - 1));
1977
+ const padAfter = Math.max(0, contentWidth - safePos - 1);
1978
+ trailing.push(
1979
+ /* @__PURE__ */ jsxs(Text, { children: [
1980
+ BOX.v,
1981
+ " ",
1982
+ " ".repeat(safePos),
1983
+ /* @__PURE__ */ jsx(Text, { color: "cyan", dimColor: true, children: arrow }),
1984
+ " ".repeat(padAfter),
1985
+ BOX.v
1986
+ ] }, `trail-${i}`)
1987
+ );
1988
+ } else {
1989
+ trailing.push(/* @__PURE__ */ jsx(Text, { children: emptyRow }, `trail-${i}`));
1990
+ }
1842
1991
  }
1992
+ const finalLines = [...padded, ...lines, ...trailing];
1843
1993
  return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", width, children: [
1844
1994
  /* @__PURE__ */ jsx(Text, { color: isLive ? void 0 : "yellow", children: createTitleLine(sessionName, titleSuffix, width) }),
1845
- lines,
1995
+ finalLines,
1846
1996
  /* @__PURE__ */ jsx(Text, { children: createBottomLine(width) })
1847
1997
  ] });
1848
1998
  }
1849
1999
 
1850
2000
  // src/ui/DetailViewPanel.tsx
1851
2001
  import { Box as Box2, Text as Text2 } from "ink";
2002
+
2003
+ // src/ui/lineColoring.ts
2004
+ var DIFF_META_PREFIXES = [
2005
+ "diff --git",
2006
+ "index ",
2007
+ "commit ",
2008
+ "Author:",
2009
+ "Date:",
2010
+ "Merge:"
2011
+ ];
2012
+ function classifyDiffLines(lines) {
2013
+ return lines.map((line) => {
2014
+ if (line.startsWith("+++") || line.startsWith("---")) return "diff-meta";
2015
+ if (DIFF_META_PREFIXES.some((p) => line.startsWith(p))) return "diff-meta";
2016
+ if (line.startsWith("@@")) return "diff-hunk";
2017
+ if (line.startsWith("+")) return "diff-add";
2018
+ if (line.startsWith("-")) return "diff-remove";
2019
+ return "prose";
2020
+ });
2021
+ }
2022
+ function classifyCodeFences(lines) {
2023
+ const out = [];
2024
+ let inCode = false;
2025
+ for (const line of lines) {
2026
+ if (/^\s*```/.test(line)) {
2027
+ out.push("code-fence");
2028
+ inCode = !inCode;
2029
+ } else {
2030
+ out.push(inCode ? "code" : "prose");
2031
+ }
2032
+ }
2033
+ return out;
2034
+ }
2035
+ function getLineStyle(category) {
2036
+ switch (category) {
2037
+ case "diff-add":
2038
+ return { color: "green" };
2039
+ case "diff-remove":
2040
+ return { color: "red" };
2041
+ case "diff-hunk":
2042
+ return { color: "cyan" };
2043
+ case "diff-meta":
2044
+ return { dimColor: true };
2045
+ case "code-fence":
2046
+ return { color: "cyan", dimColor: true };
2047
+ case "code":
2048
+ return { color: "cyan" };
2049
+ case "prose":
2050
+ return {};
2051
+ }
2052
+ }
2053
+
2054
+ // src/ui/DetailViewPanel.tsx
1852
2055
  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("");
2056
+ function wrapClassified(text, maxWidth, classifier) {
2057
+ if (!text) return [{ text: "(empty)", category: "prose" }];
2058
+ const sourceLines = text.split("\n");
2059
+ const categories = classifier(sourceLines);
2060
+ const out = [];
2061
+ for (let i = 0; i < sourceLines.length; i++) {
2062
+ const line = sourceLines[i];
2063
+ const cat = categories[i] ?? "prose";
2064
+ if (!line) {
2065
+ out.push({ text: "", category: cat });
1859
2066
  continue;
1860
2067
  }
1861
- const words = rawLine.split(" ");
2068
+ const words = line.split(" ");
1862
2069
  let current = "";
1863
2070
  for (const word of words) {
1864
2071
  if (!current) {
@@ -1866,13 +2073,13 @@ function wrapText(text, maxWidth) {
1866
2073
  } else if (getDisplayWidth(`${current} ${word}`) <= maxWidth) {
1867
2074
  current += ` ${word}`;
1868
2075
  } else {
1869
- result.push(current);
2076
+ out.push({ text: current, category: cat });
1870
2077
  current = word;
1871
2078
  }
1872
2079
  }
1873
- if (current) result.push(current);
2080
+ if (current) out.push({ text: current, category: cat });
1874
2081
  }
1875
- return result.length > 0 ? result : ["(empty)"];
2082
+ return out.length > 0 ? out : [{ text: "(empty)", category: "prose" }];
1876
2083
  }
1877
2084
  function DetailViewPanel({
1878
2085
  activity,
@@ -1882,7 +2089,8 @@ function DetailViewPanel({
1882
2089
  }) {
1883
2090
  const innerWidth = getInnerWidth(width);
1884
2091
  const contentWidth = innerWidth - 1;
1885
- const allLines = wrapText(activity.detail, contentWidth);
2092
+ const classifier = activity.type === "commit" ? classifyDiffLines : classifyCodeFences;
2093
+ const allLines = wrapClassified(activity.detail, contentWidth, classifier);
1886
2094
  const totalLines = allLines.length;
1887
2095
  const clampedOffset = Math.min(
1888
2096
  scrollOffset,
@@ -1906,13 +2114,14 @@ function DetailViewPanel({
1906
2114
  const titleRight = `${dashes}${scrollPart}${BOX.tr}`;
1907
2115
  const contentRows = [];
1908
2116
  for (let i = 0; i < visibleRows; i++) {
1909
- const line = visibleSlice[i] ?? "";
1910
- const padding = Math.max(0, contentWidth - getDisplayWidth(line));
2117
+ const entry = visibleSlice[i] ?? { text: "", category: "prose" };
2118
+ const padding = Math.max(0, contentWidth - getDisplayWidth(entry.text));
2119
+ const lineStyle = getLineStyle(entry.category);
1911
2120
  contentRows.push(
1912
2121
  /* @__PURE__ */ jsxs2(Text2, { children: [
1913
2122
  BOX.v,
1914
2123
  " ",
1915
- line,
2124
+ /* @__PURE__ */ jsx2(Text2, { color: lineStyle.color, dimColor: lineStyle.dimColor, children: entry.text }),
1916
2125
  " ".repeat(padding),
1917
2126
  BOX.v
1918
2127
  ] }, i)
@@ -1956,8 +2165,8 @@ var SECTIONS = [
1956
2165
  ["\u2191 \u2193 / k j", "Scroll one line"],
1957
2166
  ["PgUp/Dn, Ctrl+B/F", "Scroll one page"],
1958
2167
  ["Ctrl+U / Ctrl+D", "Scroll half page"],
1959
- ["g", "Jump to live (newest)"],
1960
- ["G", "Jump to oldest"],
2168
+ ["g", "Jump to top (oldest)"],
2169
+ ["G", "Jump to live (newest, bottom)"],
1961
2170
  ["\u21B5", "Open detail view for selected activity"],
1962
2171
  ["f", "Cycle filter preset (set in config.yaml)"],
1963
2172
  ["Tab", "Switch focus to project tree"]
@@ -2234,8 +2443,8 @@ function useHotkeys({
2234
2443
  "Tab: projects",
2235
2444
  "\u2191\u2193/jk: scroll",
2236
2445
  "PgUp/Dn: page",
2237
- "g: live",
2238
- "G: oldest",
2446
+ "g: oldest",
2447
+ "G: live",
2239
2448
  "\u21B5: detail",
2240
2449
  `f: ${filterLabel}`,
2241
2450
  "?: help",
@@ -2244,12 +2453,29 @@ function useHotkeys({
2244
2453
  return { handleInput, statusBarItems };
2245
2454
  }
2246
2455
 
2247
- // src/ui/hooks/useSpinner.ts
2456
+ // src/ui/hooks/useSlide.ts
2248
2457
  import { useEffect, useState } from "react";
2249
- var FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
2250
- function useSpinner(active, intervalMs = 100) {
2458
+ function useSlide(active, positions, intervalMs = 180, resetKey) {
2251
2459
  const [index, setIndex] = useState(0);
2460
+ useEffect(() => {
2461
+ setIndex(0);
2462
+ }, [resetKey]);
2252
2463
  useEffect(() => {
2464
+ if (!active) return;
2465
+ const timer = setInterval(() => {
2466
+ setIndex((i) => (i + 1) % positions);
2467
+ }, intervalMs);
2468
+ return () => clearInterval(timer);
2469
+ }, [active, positions, intervalMs]);
2470
+ return index;
2471
+ }
2472
+
2473
+ // src/ui/hooks/useSpinner.ts
2474
+ import { useEffect as useEffect2, useState as useState2 } from "react";
2475
+ var FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
2476
+ function useSpinner(active, intervalMs = 100) {
2477
+ const [index, setIndex] = useState2(0);
2478
+ useEffect2(() => {
2253
2479
  if (!active) return;
2254
2480
  const timer = setInterval(() => {
2255
2481
  setIndex((i) => (i + 1) % FRAMES.length);
@@ -2263,12 +2489,20 @@ function useSpinner(active, intervalMs = 100) {
2263
2489
  import { homedir as homedir4 } from "os";
2264
2490
  import { Box as Box4, Text as Text4 } from "ink";
2265
2491
  import { Fragment, jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
2266
- function formatElapsed(lastModifiedMs) {
2267
- const elapsed = Date.now() - lastModifiedMs;
2492
+ function formatElapsed(lastModifiedMs, now = Date.now()) {
2493
+ const elapsed = Math.max(0, now - lastModifiedMs);
2268
2494
  const seconds = Math.floor(elapsed / 1e3);
2269
2495
  const minutes = Math.floor(seconds / 60);
2270
2496
  const hours = Math.floor(minutes / 60);
2271
- if (hours > 0) return `${hours}h${minutes % 60}m`;
2497
+ const days = Math.floor(hours / 24);
2498
+ const weeks = Math.floor(days / 7);
2499
+ const months = Math.floor(days / 30);
2500
+ const years = Math.floor(days / 365);
2501
+ if (years >= 1) return `${years}y`;
2502
+ if (months >= 1) return `${months}mo`;
2503
+ if (weeks >= 1) return `${weeks}w`;
2504
+ if (days >= 1) return `${days}d`;
2505
+ if (hours > 0) return `${hours}h`;
2272
2506
  if (minutes > 0) return `${minutes}m`;
2273
2507
  if (seconds > 0) return `${seconds}s`;
2274
2508
  return "<1s";
@@ -2290,11 +2524,6 @@ function formatProjectPath(projectPath) {
2290
2524
  const raw = projectPath.startsWith(home) ? `~${projectPath.slice(home.length)}` : projectPath;
2291
2525
  return raw;
2292
2526
  }
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
2527
  function SessionRow({
2299
2528
  session,
2300
2529
  isSelected,
@@ -2316,36 +2545,40 @@ function SessionRow({
2316
2545
  const leftCoreBase = `${prefix}${rawName}${shortIdDisplay} ${badge}`;
2317
2546
  const leftCoreWidth = getDisplayWidth(leftCoreBase);
2318
2547
  const rightWidth = getDisplayWidth(rightSide);
2319
- const middleAvailable = contentWidth - leftCoreWidth - 1 - rightWidth - 1;
2548
+ const RIGHT_GAP = 3;
2549
+ const middleAvailable = contentWidth - leftCoreWidth - 1 - rightWidth - RIGHT_GAP;
2320
2550
  let middleText = "";
2321
- if (middleAvailable > 3) {
2551
+ if (middleAvailable > 1) {
2322
2552
  const raw = isParent ? session.firstUserPrompt ?? "" : session.taskDescription ?? "";
2323
2553
  if (raw) {
2324
- const truncated = truncatePath(raw, middleAvailable);
2554
+ const flat = raw.replace(/[\r\n\t]+/g, " ").trim();
2555
+ const truncated = truncateByWidth(flat, middleAvailable);
2325
2556
  if (truncated) middleText = truncated;
2326
2557
  }
2327
2558
  }
2328
2559
  const middleSection = middleText ? ` ${middleText}` : "";
2329
2560
  const gapWidth = Math.max(
2330
- 1,
2561
+ RIGHT_GAP,
2331
2562
  contentWidth - leftCoreWidth - getDisplayWidth(middleSection) - rightWidth
2332
2563
  );
2333
2564
  const gap = " ".repeat(gapWidth);
2334
2565
  const fullLine = leftCoreBase + middleSection + gap + rightSide;
2335
2566
  const linePadding = Math.max(0, contentWidth - getDisplayWidth(fullLine));
2336
- const highlight = isSelected && hasFocus;
2337
- const shouldDim = isNonInteractive;
2567
+ const focused = isSelected && hasFocus;
2568
+ const muted = isSelected && !hasFocus;
2569
+ const showBg = focused || muted;
2570
+ const shouldDim = isNonInteractive || muted;
2338
2571
  return /* @__PURE__ */ jsxs4(Text4, { children: [
2339
2572
  BOX.v,
2340
2573
  " ",
2341
2574
  /* @__PURE__ */ jsxs4(
2342
2575
  Text4,
2343
2576
  {
2344
- backgroundColor: highlight ? "blue" : void 0,
2345
- bold: highlight,
2346
- dimColor: shouldDim && !highlight,
2577
+ backgroundColor: showBg ? "blue" : void 0,
2578
+ bold: focused,
2579
+ dimColor: shouldDim && !focused,
2347
2580
  children: [
2348
- /* @__PURE__ */ jsx4(Text4, { dimColor: shouldDim && !highlight, children: prefix }),
2581
+ /* @__PURE__ */ jsx4(Text4, { dimColor: shouldDim && !focused, children: prefix }),
2349
2582
  /* @__PURE__ */ jsx4(Text4, { bold: !shouldDim, children: rawName }),
2350
2583
  shortIdDisplay ? /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: shortIdDisplay }) : null,
2351
2584
  /* @__PURE__ */ jsx4(Text4, { children: " " }),
@@ -2442,23 +2675,47 @@ function ProjectRow({
2442
2675
  }) {
2443
2676
  const nameText = `> ${project.name}`;
2444
2677
  const pathText = project.projectPath ? formatProjectPath(project.projectPath) : "";
2678
+ const latestMtime = project.sessions.reduce(
2679
+ (acc, s) => Math.max(acc, s.lastModifiedMs),
2680
+ 0
2681
+ );
2682
+ const elapsed = latestMtime > 0 ? formatElapsed(latestMtime) : "";
2445
2683
  const nameWidth = getDisplayWidth(nameText);
2446
2684
  const pathWidth = pathText ? getDisplayWidth(pathText) : 0;
2447
- const gapWidth = pathText ? 2 : 0;
2448
- const totalWidth = nameWidth + gapWidth + pathWidth;
2685
+ const elapsedWidth = elapsed ? getDisplayWidth(elapsed) : 0;
2686
+ const middleGap = pathText ? 2 : 0;
2687
+ const leftWidth = nameWidth + middleGap + pathWidth;
2688
+ const PROJECT_RIGHT_GAP = 3;
2689
+ const rightGap = Math.max(
2690
+ PROJECT_RIGHT_GAP,
2691
+ contentWidth - leftWidth - elapsedWidth
2692
+ );
2693
+ const totalWidth = leftWidth + rightGap + elapsedWidth;
2449
2694
  const padding = Math.max(0, contentWidth - totalWidth);
2450
- const highlight = isSelected && hasFocus;
2695
+ const focused = isSelected && hasFocus;
2696
+ const muted = isSelected && !hasFocus;
2697
+ const showBg = focused || muted;
2451
2698
  return /* @__PURE__ */ jsxs4(Text4, { children: [
2452
2699
  BOX.v,
2453
2700
  " ",
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
- ] }),
2701
+ /* @__PURE__ */ jsxs4(
2702
+ Text4,
2703
+ {
2704
+ backgroundColor: showBg ? "blue" : void 0,
2705
+ bold: !showBg,
2706
+ dimColor: muted,
2707
+ children: [
2708
+ nameText,
2709
+ pathText ? /* @__PURE__ */ jsxs4(Fragment, { children: [
2710
+ " ",
2711
+ /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: pathText })
2712
+ ] }) : null,
2713
+ " ".repeat(rightGap),
2714
+ elapsed ? /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: elapsed }) : null,
2715
+ " ".repeat(padding)
2716
+ ]
2717
+ }
2718
+ ),
2462
2719
  BOX.v
2463
2720
  ] });
2464
2721
  }
@@ -2478,11 +2735,12 @@ function SubagentSummaryRow({
2478
2735
  0,
2479
2736
  contentWidth - getDisplayWidth(text) - getDisplayWidth(hint)
2480
2737
  );
2481
- const active = isSelected && hasFocus;
2738
+ const focused = isSelected && hasFocus;
2739
+ const muted = isSelected && !hasFocus;
2482
2740
  return /* @__PURE__ */ jsxs4(Text4, { children: [
2483
2741
  BOX.v,
2484
2742
  " ",
2485
- /* @__PURE__ */ jsxs4(Text4, { dimColor: !active, inverse: active, children: [
2743
+ /* @__PURE__ */ jsxs4(Text4, { dimColor: !focused, inverse: focused || muted, children: [
2486
2744
  text,
2487
2745
  " ".repeat(padding),
2488
2746
  hint
@@ -2504,13 +2762,14 @@ function ColdProjectsSummaryRow({
2504
2762
  const dashCount = Math.max(0, innerWidth - 1 - labelWidth - hintWidth);
2505
2763
  const dashes = BOX.h.repeat(dashCount);
2506
2764
  const line = `${BOX.ml}${BOX.h}${label}${dashes}${hint}${BOX.mr}`;
2507
- const highlight = isSelected && hasFocus;
2765
+ const focused = isSelected && hasFocus;
2766
+ const muted = isSelected && !hasFocus;
2508
2767
  return /* @__PURE__ */ jsx4(
2509
2768
  Text4,
2510
2769
  {
2511
- backgroundColor: highlight ? "blue" : void 0,
2512
- bold: highlight,
2513
- dimColor: !highlight,
2770
+ backgroundColor: focused || muted ? "blue" : void 0,
2771
+ bold: focused,
2772
+ dimColor: !focused,
2514
2773
  children: line
2515
2774
  }
2516
2775
  );
@@ -2712,14 +2971,14 @@ function getSelectedActivity(acts, live, scrollOff, rows, cursorLine) {
2712
2971
  if (acts.length === 0) return null;
2713
2972
  let visible;
2714
2973
  if (live) {
2715
- visible = acts.slice(-rows).reverse();
2974
+ visible = acts.slice(-rows);
2716
2975
  } else {
2717
2976
  const end = Math.max(0, acts.length - scrollOff);
2718
2977
  const start = Math.max(0, end - rows);
2719
- visible = acts.slice(start, end).reverse();
2978
+ visible = acts.slice(start, end);
2720
2979
  }
2721
2980
  const effectiveCursor = Math.min(cursorLine, visible.length - 1);
2722
- return visible[effectiveCursor] ?? null;
2981
+ return visible[visible.length - 1 - effectiveCursor] ?? null;
2723
2982
  }
2724
2983
  function App({ mode }) {
2725
2984
  const { exit } = useApp();
@@ -2727,47 +2986,47 @@ function App({ mode }) {
2727
2986
  const isWatchMode = mode === "watch";
2728
2987
  const config = useMemo(() => loadGlobalConfig(), []);
2729
2988
  const migrationWarning = useMemo(() => hasProjectLevelConfig(), []);
2730
- const [sessionTree, setSessionTree] = useState2(
2989
+ const [sessionTree, setSessionTree] = useState3(
2731
2990
  () => discoverSessions(config)
2732
2991
  );
2733
- const [selectedId, setSelectedId] = useState2(() => {
2992
+ const [selectedId, setSelectedId] = useState3(() => {
2734
2993
  const firstProject = sessionTree.projects[0];
2735
2994
  if (firstProject) return `__proj-${firstProject.name}__`;
2736
2995
  return null;
2737
2996
  });
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(
2997
+ const [focus, setFocus] = useState3("tree");
2998
+ const [scrollOffset, setScrollOffset] = useState3(0);
2999
+ const [isLive, setIsLive] = useState3(true);
3000
+ const [activities, setActivities] = useState3([]);
3001
+ const [gitActivities, setGitActivities] = useState3([]);
3002
+ const [newCount, setNewCount] = useState3(0);
3003
+ const [expandedIds, setExpandedIds] = useState3(/* @__PURE__ */ new Set());
3004
+ const [viewerCursorLine, setViewerCursorLine] = useState3(0);
3005
+ const [detailMode, setDetailMode] = useState3(false);
3006
+ const [detailActivity, setDetailActivity] = useState3(
2748
3007
  null
2749
3008
  );
2750
- const [detailScrollOffset, setDetailScrollOffset] = useState2(0);
2751
- const [filterIndex, setFilterIndex] = useState2(0);
2752
- const [helpMode, setHelpMode] = useState2(false);
2753
- const [helpScroll, setHelpScroll] = useState2(0);
3009
+ const [detailScrollOffset, setDetailScrollOffset] = useState3(0);
3010
+ const [filterIndex, setFilterIndex] = useState3(0);
3011
+ const [helpMode, setHelpMode] = useState3(false);
3012
+ const [helpScroll, setHelpScroll] = useState3(0);
2754
3013
  const helpTotalLinesRef = useRef(0);
2755
3014
  const allFlat = useMemo(
2756
3015
  () => flattenSessions2(sessionTree, expandedIds),
2757
3016
  [sessionTree, expandedIds]
2758
3017
  );
2759
3018
  const allFlatRef = useRef(allFlat);
2760
- useEffect2(() => {
3019
+ useEffect3(() => {
2761
3020
  allFlatRef.current = allFlat;
2762
3021
  }, [allFlat]);
2763
3022
  const activitiesLengthRef = useRef(0);
2764
3023
  const activitiesRef = useRef(activities);
2765
- useEffect2(() => {
3024
+ useEffect3(() => {
2766
3025
  activitiesLengthRef.current = activities.length;
2767
3026
  activitiesRef.current = activities;
2768
3027
  }, [activities]);
2769
3028
  const lastLoadedFileRef = useRef(null);
2770
- useEffect2(() => {
3029
+ useEffect3(() => {
2771
3030
  let node = allFlatRef.current.find((s) => s.id === selectedId);
2772
3031
  if (node && selectedId?.startsWith("__proj-") && selectedId.endsWith("__")) {
2773
3032
  const projectName = selectedId.slice(7, -2);
@@ -2795,12 +3054,12 @@ function App({ mode }) {
2795
3054
  if (fileChanged) setGitActivities([]);
2796
3055
  }
2797
3056
  }, [selectedId, sessionTree]);
2798
- useEffect2(() => {
3057
+ useEffect3(() => {
2799
3058
  setScrollOffset(0);
2800
3059
  setIsLive(true);
2801
3060
  setViewerCursorLine(0);
2802
3061
  }, [filterIndex]);
2803
- useEffect2(() => {
3062
+ useEffect3(() => {
2804
3063
  if (!isWatchMode) return;
2805
3064
  const node = allFlatRef.current.find((s) => s.id === selectedId);
2806
3065
  if (!node?.projectPath) return;
@@ -2855,10 +3114,10 @@ function App({ mode }) {
2855
3114
  }
2856
3115
  }, [selectedId, isLive, expandedIds]);
2857
3116
  const refreshRef = useRef(refresh);
2858
- useEffect2(() => {
3117
+ useEffect3(() => {
2859
3118
  refreshRef.current = refresh;
2860
3119
  }, [refresh]);
2861
- useEffect2(() => {
3120
+ useEffect3(() => {
2862
3121
  if (!isWatchMode) return;
2863
3122
  const projectsDir = getProjectsDir();
2864
3123
  const usePolling = process.platform === "linux" || !existsSync5(projectsDir);
@@ -2912,8 +3171,21 @@ function App({ mode }) {
2912
3171
  const maxTreeRows = Math.floor(height * (1 - VIEWER_HEIGHT_FRACTION));
2913
3172
  const naturalTreeRows = allFlat.length;
2914
3173
  const treeRows = Math.max(1, Math.min(naturalTreeRows, maxTreeRows));
2915
- const viewerRows = Math.max(5, height - 7 - treeRows);
3174
+ const VIEWER_BREATHING_ROWS = 1;
3175
+ const viewerRows = Math.max(
3176
+ 5,
3177
+ height - 7 - treeRows - VIEWER_BREATHING_ROWS
3178
+ );
2916
3179
  const spinner = useSpinner(isWatchMode);
3180
+ const viewerIndicatorWidth = Math.max(1, width - 3);
3181
+ const liveIndicatorPosition = useSlide(
3182
+ isWatchMode,
3183
+ viewerIndicatorWidth,
3184
+ 180,
3185
+ // Reset to 0 whenever the viewer's subject changes so each new
3186
+ // session/sub-agent restarts the arrow from the left.
3187
+ selectedId
3188
+ );
2917
3189
  const helpViewportRows = Math.max(1, height - 3);
2918
3190
  const helpScrollStep = (delta) => {
2919
3191
  const max = Math.max(0, helpTotalLinesRef.current - helpViewportRows);
@@ -2930,11 +3202,30 @@ function App({ mode }) {
2930
3202
  onHelpScroll: helpScrollStep,
2931
3203
  onHelpScrollToTop: () => setHelpScroll(0),
2932
3204
  onSwitchFocus: () => setFocus((f) => f === "tree" ? "viewer" : "tree"),
3205
+ // cursorLine = "entries back from the newest" (0 = newest = bottom row).
3206
+ // Up arrow moves visually upward = older direction = cursorLine++.
3207
+ // Down arrow moves visually downward = newer direction = cursorLine--.
2933
3208
  onScrollUp: () => {
2934
3209
  if (focus === "tree") {
2935
3210
  if (selectedIndex === -1) return;
2936
3211
  const prev = Math.max(0, selectedIndex - 1);
2937
3212
  setSelectedId(allFlat[prev]?.id ?? selectedId);
3213
+ } else {
3214
+ if (viewerCursorLine < viewerRows - 1) {
3215
+ setViewerCursorLine((c) => c + 1);
3216
+ } else {
3217
+ setIsLive(false);
3218
+ setScrollOffset(
3219
+ (o) => Math.min(o + 1, Math.max(0, activities.length - viewerRows))
3220
+ );
3221
+ }
3222
+ }
3223
+ },
3224
+ onScrollDown: () => {
3225
+ if (focus === "tree") {
3226
+ if (selectedIndex === -1) return;
3227
+ const next = Math.min(allFlat.length - 1, selectedIndex + 1);
3228
+ setSelectedId(allFlat[next]?.id ?? selectedId);
2938
3229
  } else {
2939
3230
  if (viewerCursorLine > 0) {
2940
3231
  setViewerCursorLine((c) => c - 1);
@@ -2950,26 +3241,25 @@ function App({ mode }) {
2950
3241
  }
2951
3242
  }
2952
3243
  },
2953
- onScrollDown: () => {
3244
+ // PgUp/PgDn semantics flip to match the bottom-feed layout:
3245
+ // PgUp = visually up = older direction = scrollOffset++
3246
+ // PgDn = visually down = newer direction = scrollOffset--
3247
+ onScrollPageUp: () => {
2954
3248
  if (focus === "tree") {
2955
- if (selectedIndex === -1) return;
2956
- const next = Math.min(allFlat.length - 1, selectedIndex + 1);
2957
- setSelectedId(allFlat[next]?.id ?? selectedId);
3249
+ const prev = Math.max(0, selectedIndex - 5);
3250
+ setSelectedId(allFlat[prev]?.id ?? selectedId);
2958
3251
  } 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
- }
3252
+ setViewerCursorLine(0);
3253
+ setIsLive(false);
3254
+ setScrollOffset(
3255
+ (o) => Math.min(o + viewerRows, Math.max(0, activities.length - viewerRows))
3256
+ );
2967
3257
  }
2968
3258
  },
2969
- onScrollPageUp: () => {
3259
+ onScrollPageDown: () => {
2970
3260
  if (focus === "tree") {
2971
- const prev = Math.max(0, selectedIndex - 5);
2972
- setSelectedId(allFlat[prev]?.id ?? selectedId);
3261
+ const next = Math.min(allFlat.length - 1, selectedIndex + 5);
3262
+ setSelectedId(allFlat[next]?.id ?? selectedId);
2973
3263
  } else {
2974
3264
  setViewerCursorLine(0);
2975
3265
  setScrollOffset((o) => {
@@ -2982,22 +3272,28 @@ function App({ mode }) {
2982
3272
  });
2983
3273
  }
2984
3274
  },
2985
- onScrollPageDown: () => {
3275
+ onScrollHalfPageUp: () => {
2986
3276
  if (focus === "tree") {
2987
- const next = Math.min(allFlat.length - 1, selectedIndex + 5);
2988
- setSelectedId(allFlat[next]?.id ?? selectedId);
3277
+ const prev = Math.max(0, selectedIndex - Math.ceil(5 / 2));
3278
+ setSelectedId(allFlat[prev]?.id ?? selectedId);
2989
3279
  } else {
2990
3280
  setViewerCursorLine(0);
2991
3281
  setIsLive(false);
2992
3282
  setScrollOffset(
2993
- (o) => Math.min(o + viewerRows, Math.max(0, activities.length - viewerRows))
3283
+ (o) => Math.min(
3284
+ o + Math.floor(viewerRows / 2),
3285
+ Math.max(0, activities.length - viewerRows)
3286
+ )
2994
3287
  );
2995
3288
  }
2996
3289
  },
2997
- onScrollHalfPageUp: () => {
3290
+ onScrollHalfPageDown: () => {
2998
3291
  if (focus === "tree") {
2999
- const prev = Math.max(0, selectedIndex - Math.ceil(5 / 2));
3000
- setSelectedId(allFlat[prev]?.id ?? selectedId);
3292
+ const next = Math.min(
3293
+ allFlat.length - 1,
3294
+ selectedIndex + Math.ceil(5 / 2)
3295
+ );
3296
+ setSelectedId(allFlat[next]?.id ?? selectedId);
3001
3297
  } else {
3002
3298
  setViewerCursorLine(0);
3003
3299
  setScrollOffset((o) => {
@@ -3010,35 +3306,17 @@ function App({ mode }) {
3010
3306
  });
3011
3307
  }
3012
3308
  },
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
3309
  onScrollTop: () => {
3310
+ setViewerCursorLine(Math.max(0, viewerRows - 1));
3311
+ setIsLive(false);
3312
+ setScrollOffset(Math.max(0, mergedActivities.length - viewerRows));
3313
+ },
3314
+ onScrollBottom: () => {
3032
3315
  setViewerCursorLine(0);
3033
3316
  setIsLive(true);
3034
3317
  setScrollOffset(0);
3035
3318
  setNewCount(0);
3036
3319
  },
3037
- onScrollBottom: () => {
3038
- setViewerCursorLine(0);
3039
- setIsLive(false);
3040
- setScrollOffset(Math.max(0, mergedActivities.length - viewerRows));
3041
- },
3042
3320
  onDetailClose: () => {
3043
3321
  setDetailMode(false);
3044
3322
  },
@@ -3130,15 +3408,23 @@ function App({ mode }) {
3130
3408
  const toggleKey = isCold ? `__expanded-session-${selectedId}` : `__collapsed-session-${selectedId}`;
3131
3409
  setExpandedIds((prev) => {
3132
3410
  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) {
3411
+ if (isCold) {
3412
+ if (next.has(toggleKey)) {
3413
+ next.delete(toggleKey);
3414
+ next.delete(selectedId);
3415
+ } else {
3416
+ next.add(toggleKey);
3139
3417
  const firstSub = selectedSessionObj.subAgents[0];
3140
3418
  if (firstSub) setSelectedId(firstSub.id);
3141
3419
  }
3420
+ } else {
3421
+ if (next.has(toggleKey)) {
3422
+ next.delete(toggleKey);
3423
+ } else {
3424
+ next.add(toggleKey);
3425
+ next.delete(selectedId);
3426
+ setSelectedId(selectedId);
3427
+ }
3142
3428
  }
3143
3429
  return next;
3144
3430
  });
@@ -3273,6 +3559,8 @@ function App({ mode }) {
3273
3559
  isLive,
3274
3560
  newCount,
3275
3561
  visibleRows: viewerRows,
3562
+ trailingBlankRows: VIEWER_BREATHING_ROWS,
3563
+ liveIndicatorPosition,
3276
3564
  width,
3277
3565
  cursorLine: viewerCursorLine,
3278
3566
  hasFocus: focus === "viewer",
@@ -3356,7 +3644,8 @@ if (options.mode === "summary") {
3356
3644
  to: options.summaryTo,
3357
3645
  today,
3358
3646
  force: options.summaryForce ?? false,
3359
- assumeYes: options.summaryAssumeYes ?? false
3647
+ assumeYes: options.summaryAssumeYes ?? false,
3648
+ model: options.summaryModel
3360
3649
  });
3361
3650
  process.exit(exitCode2);
3362
3651
  }
@@ -3364,11 +3653,15 @@ if (options.mode === "summary") {
3364
3653
  date: options.summaryDate,
3365
3654
  prompt: options.summaryPrompt,
3366
3655
  force: options.summaryForce ?? false,
3367
- today
3656
+ today,
3657
+ model: options.summaryModel
3368
3658
  });
3369
3659
  process.exit(exitCode);
3370
3660
  }
3371
3661
  if (options.mode === "watch") {
3372
- clearScreen();
3662
+ installAltScreenCleanup();
3663
+ enterAltScreen();
3664
+ } else {
3665
+ if (options.mode === "once") clearScreen();
3373
3666
  }
3374
3667
  render(React.createElement(App, { mode: options.mode }));