things-api 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (261) hide show
  1. package/README.md +57 -12
  2. package/dist/audit/log.js +56 -2
  3. package/dist/audit/log.js.map +1 -1
  4. package/dist/audit/schema.d.ts +26 -8
  5. package/dist/audit/schema.js +42 -1
  6. package/dist/audit/schema.js.map +1 -1
  7. package/dist/cli/clock.d.ts +8 -0
  8. package/dist/cli/clock.js +24 -0
  9. package/dist/cli/clock.js.map +1 -0
  10. package/dist/cli/commands/area.d.ts +26 -3
  11. package/dist/cli/commands/area.js +129 -75
  12. package/dist/cli/commands/area.js.map +1 -1
  13. package/dist/cli/commands/doctor.d.ts +1 -1
  14. package/dist/cli/commands/doctor.js +44 -5
  15. package/dist/cli/commands/doctor.js.map +1 -1
  16. package/dist/cli/commands/install-skill.d.ts +45 -0
  17. package/dist/cli/commands/install-skill.js +207 -0
  18. package/dist/cli/commands/install-skill.js.map +1 -0
  19. package/dist/cli/commands/mcp.js +23 -4
  20. package/dist/cli/commands/mcp.js.map +1 -1
  21. package/dist/cli/commands/project.d.ts +18 -1
  22. package/dist/cli/commands/project.js +74 -35
  23. package/dist/cli/commands/project.js.map +1 -1
  24. package/dist/cli/commands/reads.d.ts +15 -2
  25. package/dist/cli/commands/reads.js +459 -165
  26. package/dist/cli/commands/reads.js.map +1 -1
  27. package/dist/cli/commands/repeat-flags.d.ts +20 -0
  28. package/dist/cli/commands/repeat-flags.js +76 -0
  29. package/dist/cli/commands/repeat-flags.js.map +1 -0
  30. package/dist/cli/commands/setup.js +8 -4
  31. package/dist/cli/commands/setup.js.map +1 -1
  32. package/dist/cli/commands/show.js +53 -30
  33. package/dist/cli/commands/show.js.map +1 -1
  34. package/dist/cli/commands/todo.d.ts +1 -1
  35. package/dist/cli/commands/todo.js +20 -13
  36. package/dist/cli/commands/todo.js.map +1 -1
  37. package/dist/cli/commands/writes.js +388 -161
  38. package/dist/cli/commands/writes.js.map +1 -1
  39. package/dist/cli/did-you-mean.d.ts +1 -1
  40. package/dist/cli/excess-args.d.ts +15 -0
  41. package/dist/cli/excess-args.js +51 -0
  42. package/dist/cli/excess-args.js.map +1 -0
  43. package/dist/cli/glyphs.d.ts +55 -2
  44. package/dist/cli/glyphs.js +114 -21
  45. package/dist/cli/glyphs.js.map +1 -1
  46. package/dist/cli/help.d.ts +54 -0
  47. package/dist/cli/help.js +405 -0
  48. package/dist/cli/help.js.map +1 -0
  49. package/dist/cli/main.js +64 -26
  50. package/dist/cli/main.js.map +1 -1
  51. package/dist/cli/move-hint.d.ts +45 -0
  52. package/dist/cli/move-hint.js +196 -0
  53. package/dist/cli/move-hint.js.map +1 -0
  54. package/dist/cli/period.d.ts +9 -8
  55. package/dist/cli/period.js +56 -30
  56. package/dist/cli/period.js.map +1 -1
  57. package/dist/cli/read-driver.d.ts +26 -19
  58. package/dist/cli/read-driver.js +77 -33
  59. package/dist/cli/read-driver.js.map +1 -1
  60. package/dist/cli/render.d.ts +92 -28
  61. package/dist/cli/render.js +333 -103
  62. package/dist/cli/render.js.map +1 -1
  63. package/dist/cli/resolve-invocation.d.ts +42 -4
  64. package/dist/cli/resolve-invocation.js +97 -14
  65. package/dist/cli/resolve-invocation.js.map +1 -1
  66. package/dist/cli/skill-check.d.ts +21 -0
  67. package/dist/cli/skill-check.js +82 -0
  68. package/dist/cli/skill-check.js.map +1 -0
  69. package/dist/cli/skill.d.ts +62 -0
  70. package/dist/cli/skill.js +142 -0
  71. package/dist/cli/skill.js.map +1 -0
  72. package/dist/cli/tag-filters.d.ts +63 -0
  73. package/dist/cli/tag-filters.js +59 -0
  74. package/dist/cli/tag-filters.js.map +1 -0
  75. package/dist/cli/verb-hint.d.ts +25 -0
  76. package/dist/cli/verb-hint.js +163 -0
  77. package/dist/cli/verb-hint.js.map +1 -0
  78. package/dist/cli/version.d.ts +4 -0
  79. package/dist/cli/version.js +18 -0
  80. package/dist/cli/version.js.map +1 -0
  81. package/dist/cli/width.d.ts +135 -0
  82. package/dist/cli/width.js +313 -0
  83. package/dist/cli/width.js.map +1 -0
  84. package/dist/client.d.ts +160 -24
  85. package/dist/client.js +132 -19
  86. package/dist/client.js.map +1 -1
  87. package/dist/config.d.ts +11 -0
  88. package/dist/config.js +3 -0
  89. package/dist/config.js.map +1 -1
  90. package/dist/contracts.d.ts +97 -17
  91. package/dist/contracts.js +44 -1
  92. package/dist/contracts.js.map +1 -1
  93. package/dist/db/fingerprint.d.ts +12 -0
  94. package/dist/db/fingerprint.js +15 -1
  95. package/dist/db/fingerprint.js.map +1 -1
  96. package/dist/db/locate.js +1 -1
  97. package/dist/db/locate.js.map +1 -1
  98. package/dist/diagnose.d.ts +63 -0
  99. package/dist/diagnose.js +38 -1
  100. package/dist/diagnose.js.map +1 -1
  101. package/dist/index.d.ts +36 -5
  102. package/dist/index.js +38 -2
  103. package/dist/index.js.map +1 -1
  104. package/dist/mcp/server.d.ts +9 -1
  105. package/dist/mcp/server.js +1038 -436
  106. package/dist/mcp/server.js.map +1 -1
  107. package/dist/model/clock.d.ts +61 -0
  108. package/dist/model/clock.js +71 -0
  109. package/dist/model/clock.js.map +1 -0
  110. package/dist/model/dates.d.ts +26 -3
  111. package/dist/model/dates.js +84 -3
  112. package/dist/model/dates.js.map +1 -1
  113. package/dist/model/entities.d.ts +48 -10
  114. package/dist/model/entities.js.map +1 -1
  115. package/dist/model/mappers.d.ts +9 -2
  116. package/dist/model/mappers.js +32 -9
  117. package/dist/model/mappers.js.map +1 -1
  118. package/dist/model/serialize.d.ts +28 -0
  119. package/dist/model/serialize.js +80 -0
  120. package/dist/model/serialize.js.map +1 -0
  121. package/dist/model/when-sugar.d.ts +47 -0
  122. package/dist/model/when-sugar.js +45 -0
  123. package/dist/model/when-sugar.js.map +1 -0
  124. package/dist/read/area-view.d.ts +8 -1
  125. package/dist/read/area-view.js +55 -17
  126. package/dist/read/area-view.js.map +1 -1
  127. package/dist/read/detail.d.ts +1 -1
  128. package/dist/read/detail.js +11 -4
  129. package/dist/read/detail.js.map +1 -1
  130. package/dist/read/filter-contract.d.ts +117 -0
  131. package/dist/read/filter-contract.js +78 -0
  132. package/dist/read/filter-contract.js.map +1 -0
  133. package/dist/read/log-boundary.d.ts +1 -1
  134. package/dist/read/log-boundary.js +13 -3
  135. package/dist/read/log-boundary.js.map +1 -1
  136. package/dist/read/predicates.d.ts +30 -0
  137. package/dist/read/predicates.js +30 -0
  138. package/dist/read/predicates.js.map +1 -1
  139. package/dist/read/project-view.d.ts +12 -1
  140. package/dist/read/project-view.js +45 -12
  141. package/dist/read/project-view.js.map +1 -1
  142. package/dist/read/queries.d.ts +152 -7
  143. package/dist/read/queries.js +277 -36
  144. package/dist/read/queries.js.map +1 -1
  145. package/dist/read/sections.d.ts +51 -0
  146. package/dist/read/sections.js +37 -0
  147. package/dist/read/sections.js.map +1 -0
  148. package/dist/read/sidebar-order.js +2 -1
  149. package/dist/read/sidebar-order.js.map +1 -1
  150. package/dist/read/snapshot.d.ts +1 -1
  151. package/dist/read/snapshot.js +9 -2
  152. package/dist/read/snapshot.js.map +1 -1
  153. package/dist/read/tags.d.ts +27 -3
  154. package/dist/read/tags.js +83 -12
  155. package/dist/read/tags.js.map +1 -1
  156. package/dist/read/truncation.d.ts +71 -0
  157. package/dist/read/{pagination.js → truncation.js} +103 -94
  158. package/dist/read/truncation.js.map +1 -0
  159. package/dist/read/views.d.ts +147 -22
  160. package/dist/read/views.js +282 -80
  161. package/dist/read/views.js.map +1 -1
  162. package/dist/surface-copy.d.ts +19 -0
  163. package/dist/surface-copy.js +32 -0
  164. package/dist/surface-copy.js.map +1 -1
  165. package/dist/sync-health.d.ts +78 -0
  166. package/dist/sync-health.js +312 -0
  167. package/dist/sync-health.js.map +1 -0
  168. package/dist/write/accessibility-probe.d.ts +12 -0
  169. package/dist/write/accessibility-probe.js +71 -0
  170. package/dist/write/accessibility-probe.js.map +1 -0
  171. package/dist/write/automation-probe.d.ts +8 -1
  172. package/dist/write/automation-probe.js +16 -1
  173. package/dist/write/automation-probe.js.map +1 -1
  174. package/dist/write/batch.js +5 -2
  175. package/dist/write/batch.js.map +1 -1
  176. package/dist/write/capabilities.d.ts +8 -0
  177. package/dist/write/capabilities.js +13 -6
  178. package/dist/write/capabilities.js.map +1 -1
  179. package/dist/write/commands.d.ts +3 -1
  180. package/dist/write/commands.js +602 -49
  181. package/dist/write/commands.js.map +1 -1
  182. package/dist/write/edit-checklist.js +3 -2
  183. package/dist/write/edit-checklist.js.map +1 -1
  184. package/dist/write/guards.d.ts +2 -7
  185. package/dist/write/guards.js +87 -6
  186. package/dist/write/guards.js.map +1 -1
  187. package/dist/write/heading.js +2 -0
  188. package/dist/write/heading.js.map +1 -1
  189. package/dist/write/lock.d.ts +40 -2
  190. package/dist/write/lock.js +91 -14
  191. package/dist/write/lock.js.map +1 -1
  192. package/dist/write/make-repeating-project.d.ts +4 -0
  193. package/dist/write/make-repeating-project.js +253 -0
  194. package/dist/write/make-repeating-project.js.map +1 -0
  195. package/dist/write/operations.d.ts +145 -1
  196. package/dist/write/operations.js +48 -0
  197. package/dist/write/operations.js.map +1 -1
  198. package/dist/write/pipeline.d.ts +73 -2
  199. package/dist/write/pipeline.js +218 -43
  200. package/dist/write/pipeline.js.map +1 -1
  201. package/dist/write/planner.js +8 -1
  202. package/dist/write/planner.js.map +1 -1
  203. package/dist/write/pre-state.d.ts +105 -3
  204. package/dist/write/pre-state.js +144 -3
  205. package/dist/write/pre-state.js.map +1 -1
  206. package/dist/write/recurrence-rule-blob.d.ts +65 -0
  207. package/dist/write/recurrence-rule-blob.js +150 -0
  208. package/dist/write/recurrence-rule-blob.js.map +1 -0
  209. package/dist/write/reopen.js +3 -2
  210. package/dist/write/reopen.js.map +1 -1
  211. package/dist/write/reorder.js +27 -17
  212. package/dist/write/reorder.js.map +1 -1
  213. package/dist/write/repeat-rule.d.ts +22 -0
  214. package/dist/write/repeat-rule.js +258 -0
  215. package/dist/write/repeat-rule.js.map +1 -0
  216. package/dist/write/reversibility.js +49 -0
  217. package/dist/write/reversibility.js.map +1 -1
  218. package/dist/write/tag-refs.d.ts +47 -0
  219. package/dist/write/tag-refs.js +126 -0
  220. package/dist/write/tag-refs.js.map +1 -0
  221. package/dist/write/undo.d.ts +74 -5
  222. package/dist/write/undo.js +493 -80
  223. package/dist/write/undo.js.map +1 -1
  224. package/dist/write/vectors/registry.d.ts +15 -1
  225. package/dist/write/vectors/registry.js +65 -2
  226. package/dist/write/vectors/registry.js.map +1 -1
  227. package/dist/write/vectors/simulator.d.ts +44 -0
  228. package/dist/write/vectors/simulator.js +1136 -0
  229. package/dist/write/vectors/simulator.js.map +1 -0
  230. package/dist/write/vectors/types.d.ts +154 -3
  231. package/dist/write/vectors/ui-certification.d.ts +54 -0
  232. package/dist/write/vectors/ui-certification.js +43 -0
  233. package/dist/write/vectors/ui-certification.js.map +1 -0
  234. package/dist/write/vectors/ui-drag.d.ts +188 -0
  235. package/dist/write/vectors/ui-drag.js +1095 -0
  236. package/dist/write/vectors/ui-drag.js.map +1 -0
  237. package/dist/write/vectors/ui-recipes.d.ts +83 -0
  238. package/dist/write/vectors/ui-recipes.js +610 -0
  239. package/dist/write/vectors/ui-recipes.js.map +1 -0
  240. package/dist/write/vectors/ui.d.ts +152 -0
  241. package/dist/write/vectors/ui.js +704 -0
  242. package/dist/write/vectors/ui.js.map +1 -0
  243. package/dist/write/verify/delta.d.ts +141 -4
  244. package/dist/write/verify/delta.js +199 -21
  245. package/dist/write/verify/delta.js.map +1 -1
  246. package/dist/write/verify/poller.d.ts +7 -1
  247. package/dist/write/verify/poller.js +21 -1
  248. package/dist/write/verify/poller.js.map +1 -1
  249. package/package.json +18 -3
  250. package/skills/things-cli/SKILL.md +53 -0
  251. package/skills/things-cli/references/contracts.md +41 -0
  252. package/skills/things-cli/references/gui.md +11 -0
  253. package/skills/things-cli/references/model.md +51 -0
  254. package/dist/cli/exit-codes.d.ts +0 -26
  255. package/dist/cli/exit-codes.js +0 -26
  256. package/dist/cli/exit-codes.js.map +0 -1
  257. package/dist/cli/output.d.ts +0 -42
  258. package/dist/cli/output.js +0 -16
  259. package/dist/cli/output.js.map +0 -1
  260. package/dist/read/pagination.d.ts +0 -104
  261. package/dist/read/pagination.js.map +0 -1
@@ -7,13 +7,12 @@
7
7
  * (agents and humans both need stable references); colors engage on a TTY only
8
8
  * (../cli/style.ts).
9
9
  */
10
- import { isTodayMember, } from "../read/views.js";
11
- import { localToday } from "../model/dates.js";
12
- import { templateStatus } from "../model/recurrence.js";
13
- import { blue, bold, dim, strike, underline } from "./style.js";
14
- import { areaMark, CHECKLIST_MARK, countChip, dateChip, deadlineToken, eveningMoon, LEGEND, LEGEND_GROUPS, loggedDate, NOTES_MARK, projectCircle, REMINDER_MARK, todayStar, todoBox, } from "./glyphs.js";
15
- import { partitionSomedaySection, splitSectionBlocks, } from "../read/pagination.js";
10
+ import { isTodayMember, localToday, partitionSomedaySection, splitSectionBlocks, templateStatus, } from "../index.js";
11
+ import { renderNow, renderZone } from "./clock.js";
12
+ import { bold, dim, strike, underline } from "./style.js";
13
+ import { areaMark, CHECKLIST_MARK, containerLabel, countChip, dateChip, deadlineToken, eveningMoon, LEGEND, LEGEND_GROUPS, loggedDate, NOTES_MARK, projectCircle, projectTitleAccent, REMINDER_MARK, todayStar, todoBox, } from "./glyphs.js";
16
14
  import { FULL_MONTHS, upcomingBucket } from "./period.js";
15
+ import { fitRow, getFitWidth, resolveFit, stripSgr, TITLE_MIN, visibleWidth, } from "./width.js";
17
16
  /**
18
17
  * A view's TTY-only title preamble: bold view name + its dim Things deep link,
19
18
  * then a blank line — e.g. `Anytime (things:///show?id=anytime)`. The id is the
@@ -25,9 +24,8 @@ export function viewHeaderLines(view) {
25
24
  const title = view.charAt(0).toUpperCase() + view.slice(1);
26
25
  return [`${bold(title)} ${dim(`(things:///show?id=${view})`)}`, ""];
27
26
  }
28
- const ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g");
29
- export const stripAnsi = (s) => s.replace(ANSI, "");
30
- const visibleWidth = (s) => [...stripAnsi(s)].length;
27
+ /** Re-export the canonical ANSI-stripper (impl in ./width.ts) — the legend and `--json` legend path use it. */
28
+ export const stripAnsi = stripSgr;
31
29
  /** Left-column width for the legend's glyph samples (short marks align; long textual samples overflow). */
32
30
  const LEGEND_GUTTER = 10;
33
31
  /**
@@ -52,15 +50,20 @@ export function renderLegend() {
52
50
  }
53
51
  return lines;
54
52
  }
53
+ /** Wraps a `#a #b` tag form in the row's dim styling (incl. its leading space). */
54
+ const styleTags = (form) => ` ${dim(form)}`;
55
55
  /**
56
56
  * One item line:
57
57
  * `<uuid-prefix> <box> [★|⏾] [logged-date] [‹chip›] <title> [‹n›] [⍾] [≡] [≔] (container) #tags [⚑ deadline]`.
58
58
  * Repeating templates seat ↻ INSIDE the box (`[↻]`/`(↻)`) rather than as a
59
- * separate mark; open project rows render their circle and title blue.
60
- * The box is the glyph-language state carrier (../glyphs.ts): `[ ]`-family
61
- * for to-dos, `( )`-family for projects state survives with color
62
- * stripped. Completed titles dim; canceled titles dim+strike (the `[×]`
63
- * mark keeps the state when strike/ANSI is unavailable). Human output shows
59
+ * separate mark; open project circles render blue, and project TITLES render
60
+ * bold + default-colored in every state (projectTitleAccent the render-
61
+ * language law). The box is the glyph-language state carrier (../glyphs.ts):
62
+ * `[ ]`-family for to-dos, `( )`-family for projects — state survives with
63
+ * color stripped. Completed titles dim; canceled titles dim+strike (the `[×]`
64
+ * mark keeps the state when strike/ANSI is unavailable) — except where a view
65
+ * declares resolved its norm (Logbook, `resolvedNormal`), where completed is
66
+ * plain and canceled keeps only its strike. Human output shows
64
67
  * a SHORTENED uuid prefix (every command accepts unique prefixes >= 6
65
68
  * chars); `uuidWidth` is the display length from uuidDisplayWidth — never
66
69
  * below 8 so a copied prefix stays unique across the whole database, not
@@ -71,15 +74,16 @@ export function renderLegend() {
71
74
  * (../style.ts); `--json` always carries full uuids.
72
75
  */
73
76
  export function formatItem(item, uuidWidth = 0, opts = {}) {
74
- const todayIso = localToday(opts.now);
77
+ // The consumer render clock supplies now+zone when the caller pins neither
78
+ // (production CLI); an explicit opts.now (tests) still wins, and the zone
79
+ // defaults to undefined off a set render clock — byte-identical to before.
80
+ const todayIso = localToday(opts.now ?? renderNow(), renderZone());
75
81
  const asTitle = opts.projectTitle === true && item.type === "project";
76
82
  const box = item.type === "project" ? projectCircle(item) : todoBox(item);
77
83
  const meta = [];
78
84
  if (opts.mark != null)
79
85
  meta.push(opts.mark);
80
86
  // ↻ now lives INSIDE the box for templates (glyphs.ts) — no separate mark.
81
- if (opts.statusWord !== undefined)
82
- meta.push(dim(opts.statusWord));
83
87
  if (item.status !== "open" && item.stopped !== null)
84
88
  meta.push(loggedDate(item.stopped, todayIso));
85
89
  if (opts.hideDateChip === true) {
@@ -90,6 +94,12 @@ export function formatItem(item, uuidWidth = 0, opts = {}) {
90
94
  // Repeating templates chip their app-materialized next occurrence.
91
95
  else if (item.repeating.isTemplate && item.repeating.nextOccurrence != null)
92
96
  meta.push(dateChip(item.repeating.nextOccurrence, todayIso));
97
+ // A template with NO next date chips its scheduling word instead
98
+ // (waiting/paused/ended), in the same ‹chevron› form — derived HERE so every
99
+ // list view inherits it, not just upcoming's Repeating To-Dos section. (The
100
+ // detail card's prose repeat state is NOT chipped.)
101
+ else if (item.repeating.isTemplate)
102
+ meta.push(dim(`‹${templateStatus(item.repeating, todayIso)}›`));
93
103
  // List rows mute their tags (the GUI's gray pills); tags go green only on
94
104
  // the opened resource (todo show / the project|area header row).
95
105
  const tags = item.tags.length > 0 ? ` ${dim(`#${item.tags.map((t) => t.title).join(" #")}`)}` : "";
@@ -98,9 +108,16 @@ export function formatItem(item, uuidWidth = 0, opts = {}) {
98
108
  // template rows drop it via the sentinel guard: their deadline column
99
109
  // carries app-internal 4001-01-01 sentinels (upcoming's synthesized
100
110
  // occurrences carry REAL rule-derived deadlines and must keep the flag).
111
+ // The full-vs-compact deadline form is decided ONCE per view from the effective
112
+ // width against the two derived floors (width.ts resolveFit) — never per row —
113
+ // so the right-pinned gutter never mixes `Aug 12` and `8/12`. The null path
114
+ // (pipes/grep/--json/non-TTY) is always full-form (byte-identical contract).
115
+ const rawFitWidth = getFitWidth();
116
+ const fit = rawFitWidth === null ? null : resolveFit(rawFitWidth, FULL_FIT_FLOOR, COMPACT_FIT_FLOOR);
117
+ const compact = fit?.compact === true;
101
118
  const rule = item.repeating.rule;
102
119
  const deadline = item.status === "open" && item.deadline !== null && item.deadline < "4000"
103
- ? ` ${deadlineToken(item.deadline, todayIso)}`
120
+ ? ` ${deadlineToken(item.deadline, todayIso, compact)}`
104
121
  : // The GUI's bare flag on no-date repeating templates: the rule WILL
105
122
  // assign each occurrence a deadline (fixed rules always do; after-
106
123
  // completion only with a start offset), date unknown until spawned.
@@ -113,7 +130,10 @@ export function formatItem(item, uuidWidth = 0, opts = {}) {
113
130
  const context = container !== null
114
131
  ? container.uuid === opts.suppressProject
115
132
  ? ""
116
- : ` (${container.title})`
133
+ : // The ↻ prefix (containerLabel) marks a container that is a repeating
134
+ // TEMPLATE project — the whole `(…)` run is dimmed below, so the mark
135
+ // rides the muted container styling. Areas never carry the flag.
136
+ ` (${containerLabel(container.title, container.isRepeatingTemplate === true)})`
117
137
  : item.area
118
138
  ? item.area.uuid === opts.suppressArea
119
139
  ? ""
@@ -121,17 +141,32 @@ export function formatItem(item, uuidWidth = 0, opts = {}) {
121
141
  : "";
122
142
  // width 0 (the default) means "no column" — show the full uuid untouched.
123
143
  const shownUuid = uuidWidth > 0 ? uuidCol(item.uuid, uuidWidth) : item.uuid;
124
- let title = item.title;
125
- if (asTitle)
126
- title = bold(underline(title));
127
- else if (item.status === "canceled")
128
- title = dim(strike(title));
129
- else if (item.status === "completed")
130
- title = dim(title);
131
- // Open project rows render their title blue — GUI parity (list accent) and
132
- // a color cue reinforcing the round bracket. To-dos stay default-colored.
133
- else if (item.type === "project")
134
- title = blue(title);
144
+ // Title styling composes four independent channels (docs/design/render-
145
+ // language.md) — a row marks only its deviations from the container's normal:
146
+ // strike — a canceled item (kept in EVERY context, GUI parity)
147
+ // dim — a resolved row DEVIATING from its list's norm (completed/
148
+ // canceled among open rows); dropped where resolved IS the norm
149
+ // (the Logbook passes resolvedNormal)
150
+ // bold — the project TYPE weight, every project row and every state,
151
+ // routed through the single law projectTitleAccent (glyphs.ts)
152
+ // underline heading ROLE only (asTitle): a project that heads its own
153
+ // to-do group, never a plain project row
154
+ // Title styling is a closure over the raw text so the width fitter can
155
+ // truncate the PLAIN title and re-apply the same wraps — the ellipsis/clip
156
+ // boundary always lands outside the SGR runs (a cut never splits an escape).
157
+ const resolved = item.status === "completed" || item.status === "canceled";
158
+ const styleTitle = (text) => {
159
+ let t = text;
160
+ if (item.status === "canceled")
161
+ t = strike(t);
162
+ if (resolved && opts.resolvedNormal !== true)
163
+ t = dim(t);
164
+ if (item.type === "project")
165
+ t = projectTitleAccent(t);
166
+ if (asTitle)
167
+ t = underline(t);
168
+ return t;
169
+ };
135
170
  // GUI indicator order after the title: bell, document, checklist.
136
171
  const tail = [
137
172
  ...(item.type === "project" ? [countChip(item)] : []),
@@ -139,12 +174,34 @@ export function formatItem(item, uuidWidth = 0, opts = {}) {
139
174
  ...(item.notes !== "" ? [dim(NOTES_MARK)] : []),
140
175
  ...(item.type === "to-do" && item.checklistItemsCount > 0 ? [dim(CHECKLIST_MARK)] : []),
141
176
  ];
142
- return [
143
- `${dim(shownUuid)} `,
144
- box,
145
- ...meta,
146
- `${title}${tail.length > 0 ? ` ${tail.join(" ")}` : ""}${tags}${context === "" ? "" : dim(context)}${deadline}`,
147
- ].join(" ");
177
+ // Named segments (docs/design/width-aware-tty.md): the fixed left run (uuid +
178
+ // box + meta chips), the fixed tail markers, the collapsible tags, the CLI-
179
+ // only container, and the full deadline. `styleTags` wraps a `#a #b` form in
180
+ // the row's dim styling (incl. its leading space); the fitter folds the list.
181
+ const left = `${dim(shownUuid)} ${box}${meta.length > 0 ? ` ${meta.join(" ")}` : ""}`;
182
+ const tailStr = tail.length > 0 ? ` ${tail.join(" ")}` : "";
183
+ const ctxStr = context === "" ? "" : dim(context);
184
+ const tagNames = item.tags.map((t) => t.title);
185
+ const full = `${left} ${styleTitle(item.title)}${tailStr}${tags}${ctxStr}${deadline}`;
186
+ // width null (the default — pipes/grep/--json/non-TTY) means NO fitting:
187
+ // return the fully-composed row, byte-identical to before this feature.
188
+ if (fit === null)
189
+ return full;
190
+ const seg = {
191
+ left,
192
+ rawTitle: item.title,
193
+ styleTitle,
194
+ tail: tailStr,
195
+ tagNames,
196
+ styleTags,
197
+ context: ctxStr,
198
+ deadline,
199
+ full,
200
+ };
201
+ // Fit to the effective width: never below the compact floor (a sub-floor
202
+ // terminal renders at COMPACT_FIT_FLOOR and wraps, rather than clipping
203
+ // metadata). resolveFit already clamped fit.width up to the compact floor.
204
+ return fitRow(seg, fit.width);
148
205
  }
149
206
  /**
150
207
  * Row prefix in today-aware views: yellow ★ for Today members, cyan ⏾ for
@@ -152,13 +209,83 @@ export function formatItem(item, uuidWidth = 0, opts = {}) {
152
209
  * startDate is exactly today — the UI's daily expiry), null otherwise.
153
210
  */
154
211
  export function todayMark(item, now) {
155
- if (!isTodayMember(item, now))
212
+ const instant = now ?? renderNow();
213
+ const zone = renderZone();
214
+ if (!isTodayMember(item, instant, zone))
156
215
  return null;
157
- const evening = item.todaySection === "evening" && item.startDate === localToday(now);
216
+ const evening = item.todaySection === "evening" && item.startDate === localToday(instant, zone);
158
217
  return evening ? eveningMoon() : todayStar();
159
218
  }
160
219
  /** Minimum displayed-prefix length: shorter prefixes collide across the DB. */
161
- const UUID_DISPLAY_MIN = 8;
220
+ export const UUID_DISPLAY_MIN = 8;
221
+ /**
222
+ * The two DERIVED width floors (docs/design/width-aware-tty.md § Compact
223
+ * deadline forms). Not chosen: computed from the actual glyph inventory as the
224
+ * worst-case FIXED furniture a row can carry plus a {@link TITLE_MIN}-column
225
+ * title. The only part that differs between the floors is the deadline token —
226
+ * the FULL floor budgets the full worst-case form (`⚑ 14 days left`), the
227
+ * COMPACT floor the narrow iOS-oracle worst case (`⚑ 14d left`; year-bearing
228
+ * far dates collapse to the bare year in compact mode, so they no longer
229
+ * compete — Mike's ruling 2026-07-14). Everything else
230
+ * is shared, so this returns both from one enumeration. Enumerated parts (any
231
+ * glyph change re-derives these; `width.test.ts` recomputes them independently
232
+ * so they cannot silently drift):
233
+ * - the id column (never shrinks) + its two-space separator
234
+ * - the checkbox glyph
235
+ * - a space + the widest meta chip (a future ‹date› carrying a year)
236
+ * - a space + the tail: project count chip + all three marks ◷ ≡ ≔
237
+ * (a conservative superset — the count chip and checklist never truly
238
+ * co-occur on one row, but budgeting both keeps the floor safe)
239
+ * - a space + the bare `#…` marker (tags never fully vanish)
240
+ * - a space + the longest deadline token (full or compact worst case)
241
+ * - a space + a TITLE_MIN-column title
242
+ *
243
+ * The driver fits every row to at least the COMPACT floor
244
+ * (`max(terminalWidth, COMPACT_FIT_FLOOR)`); a width in `[compact, full)` renders
245
+ * compact deadlines and every wider width the full form. So the worst-furniture
246
+ * row's title lands at exactly TITLE_MIN at whichever floor is active, every
247
+ * lighter row's title is wider (no per-row raggedness), and below the compact
248
+ * floor the terminal wraps (nothing is clipped) — mirroring the GUI, whose
249
+ * minimum WINDOW width is the same worst-case derivation.
250
+ */
251
+ function computeFitFloors() {
252
+ const todayIso = "2000-01-01";
253
+ const box = "[ ]"; // every box form is 3 cells
254
+ const metaChip = dateChip("2027-09-22", todayIso); // widest ‹date› (with year)
255
+ const countChipWorst = countChip({
256
+ untrashedLeafActionsCount: 999,
257
+ openUntrashedLeafActionsCount: 999,
258
+ });
259
+ const tail = [countChipWorst, REMINDER_MARK, NOTES_MARK, CHECKLIST_MARK].join(" ");
260
+ const fullDeadline = deadlineToken("2000-01-15", todayIso); // ⚑ 14 days left — longest full form
261
+ // Compact worst case: the widest token that can appear in compact mode. Far
262
+ // dates collapse to the bare year (`⚑ 2001`, 6 cells), so the narrow
263
+ // relative `⚑ 14d left` (10 cells) governs; keep the max() so the floor
264
+ // stays safe if the compact vocabulary ever shifts.
265
+ const compactDeadline = Math.max(visibleWidth(deadlineToken("2000-01-15", todayIso, true)), // ⚑ 14d left
266
+ visibleWidth(deadlineToken("2001-02-10", todayIso, true)));
267
+ const furniture = UUID_DISPLAY_MIN +
268
+ 2 + // the two spaces after the id column
269
+ visibleWidth(box) +
270
+ 1 +
271
+ visibleWidth(metaChip) + // space + widest meta chip
272
+ 1 + // the space before the title
273
+ TITLE_MIN +
274
+ 1 +
275
+ visibleWidth(tail) + // space + count chip + ◷ ≡ ≔
276
+ 1 +
277
+ visibleWidth("#…") + // space + the bare tag marker
278
+ 1; // the space before the deadline token
279
+ return {
280
+ full: furniture + visibleWidth(fullDeadline),
281
+ compact: furniture + compactDeadline,
282
+ };
283
+ }
284
+ const FIT_FLOORS = computeFitFloors();
285
+ /** The full-form TTY row floor — worst-case furniture + full deadline. See {@link computeFitFloors}. */
286
+ export const FULL_FIT_FLOOR = FIT_FLOORS.full;
287
+ /** The compact-form TTY row floor — worst-case furniture + compact deadline. See {@link computeFitFloors}. */
288
+ export const COMPACT_FIT_FLOOR = FIT_FLOORS.compact;
162
289
  /**
163
290
  * Display width for a list's uuid column: the shortest prefix that is
164
291
  * unique WITHIN the list, floored at UUID_DISPLAY_MIN (list-local
@@ -200,31 +327,66 @@ export function renderList(items) {
200
327
  * the per-row ★/⏾, where the marker still carries information.
201
328
  *
202
329
  * This Evening mirrors the GUI: it renders ONLY when evening items exist —
203
- * a truly-empty evening has no header at all. `full` is the pre-cap view and
204
- * `shown` the rows that survived the global `--limit`; the split lets the
205
- * section stay honest under truncation. When the cap hid some or all evening
206
- * rows, an honest muted hint counts the hidden ones and names the `--limit`
207
- * that reveals them never the misleading `(empty)` a truncated evening used
208
- * to show. `base` is the user's own invocation (flags echoed). The global
209
- * footer (row driver) still reports the whole-view remainder separately.
330
+ * a truly-empty evening has no header at all. `view` is the rows that survived
331
+ * the global `--limit`; `sections` is the metadata's pre-cap per-section counts
332
+ * ({@link SectionCount}) the Today/This-Evening totals the hint math needs, so
333
+ * the section stays honest under truncation without a pre-cap copy of the view.
334
+ * When the cap hid some or all evening rows, an honest muted hint counts the
335
+ * hidden ones and points at the isolated `--evening` view never the misleading
336
+ * `(empty)` a truncated evening used to show. (In `--evening` mode that pointer
337
+ * is redundant, so the hint keeps the `--limit`/`--all` levers instead.) `base`
338
+ * is the user's own invocation (flags echoed). The global footer (row driver)
339
+ * still reports the whole-view remainder separately.
340
+ *
341
+ * `options.eveningOnly` (the `--evening` section filter) renders ONLY the This
342
+ * Evening block — the Today header and its `(empty)` placeholder are suppressed
343
+ * because that section is deliberately filtered out, not merely empty.
210
344
  */
211
- export function renderToday(full, shown, base) {
212
- const w = uuidDisplayWidth([...shown.today, ...shown.evening]);
213
- const lines = [
214
- `${bold("──")} ${todayStar()} ${bold(`Today (badge: ${full.badge.dueOrOverdue} due/overdue · ${full.badge.other} other) ──`)}`,
215
- ...(shown.today.length === 0 ? ["(empty)"] : shown.today.map((i) => formatItem(i, w))),
216
- ];
217
- if (full.evening.length > 0) {
345
+ export function renderToday(view, sections, base, options) {
346
+ // Pre-cap totals from the truncation metadata; fall back to the shown view's
347
+ // own lengths when a caller hands an unbounded view with no section counts.
348
+ const todayTotal = sections?.find((s) => s.key === "today")?.total ?? view.today.length;
349
+ const eveningTotal = sections?.find((s) => s.key === "evening")?.total ?? view.evening.length;
350
+ const w = uuidDisplayWidth([...view.today, ...view.evening]);
351
+ const eveningOnly = options?.eveningOnly === true;
352
+ const lines = eveningOnly
353
+ ? []
354
+ : [
355
+ `${bold("──")} ${todayStar()} ${bold(`Today (badge: ${view.badge.dueOrOverdue} due/overdue · ${view.badge.other} other) ──`)}`,
356
+ ...(view.today.length === 0 ? ["(empty)"] : view.today.map((i) => formatItem(i, w))),
357
+ ];
358
+ if (eveningTotal > 0) {
359
+ // A blank line before the header matches every other grouped renderer's
360
+ // section spacing — but only when the Today section rendered above it. In
361
+ // --evening mode the header is the first line, so no leading blank.
362
+ if (!eveningOnly)
363
+ lines.push("");
218
364
  lines.push(`${bold("──")} ${eveningMoon()} ${bold("This Evening ──")}`);
219
- for (const i of shown.evening)
365
+ for (const i of view.evening)
220
366
  lines.push(formatItem(i, w));
221
- const hidden = full.evening.length - shown.evening.length;
367
+ const hidden = eveningTotal - view.evening.length;
222
368
  if (hidden > 0) {
223
- const total = full.today.length + full.evening.length;
224
- const more = shown.evening.length > 0 ? "more " : "";
225
- lines.push(dim(`… ${hidden} ${more}evening item${hidden === 1 ? "" : "s"} \`${base} --limit ${total}\` · all: \`${base} --all\``));
369
+ const more = view.evening.length > 0 ? "more " : "";
370
+ const count = `${hidden} ${more}evening item${hidden === 1 ? "" : "s"}`;
371
+ // Normal Today view: the global truncation footer already carries the
372
+ // quantity levers (a bigger --limit / --all), so this hint is a pure
373
+ // pointer to the isolated This Evening view. In --evening mode that
374
+ // pointer is redundant (--evening is already active), so keep offering
375
+ // the levers that actually reveal more rows.
376
+ if (eveningOnly) {
377
+ const total = todayTotal + eveningTotal;
378
+ lines.push(dim(`… ${count} — \`${base} --limit ${total}\` · \`${base} --all\``));
379
+ }
380
+ else {
381
+ lines.push(dim(`… ${count} — \`${base} --evening\``));
382
+ }
226
383
  }
227
384
  }
385
+ else if (eveningOnly) {
386
+ // Evening filter with no evening members: the section is genuinely empty
387
+ // (nothing was filtered out here), so an honest `(empty)` is correct.
388
+ lines.push("(empty)");
389
+ }
228
390
  return lines;
229
391
  }
230
392
  /**
@@ -284,33 +446,44 @@ export function renderProjectsSidebar(items, hints) {
284
446
  lines.push(`${bold("──")} ${areaMark()} ${bold(`${group.area.title} ──`)}`);
285
447
  }
286
448
  lines.push(...rows.map((item) => formatItem(item, w, { suppressArea: group.area?.uuid ?? null })));
449
+ // A per-group locator count (not a standalone disclosure): it marks WHERE
450
+ // the hidden later projects sit; the single reveal command rides the
451
+ // whole-view placeholder below.
287
452
  if (group.hidden > 0)
288
- lines.push(dim(`…${group.hidden} later project${group.hidden === 1 ? "" : "s"}`));
453
+ lines.push(dim(`… ${group.hidden} later project${group.hidden === 1 ? "" : "s"}`));
289
454
  else if (group.area !== null && rows.length === 0)
290
455
  lines.push(dim("(no projects)"));
291
456
  }
292
457
  if (total > 0)
293
- lines.push("", dim(`(${total} later project${total === 1 ? "" : "s"} — visible with \`things projects --show-later\`)`));
458
+ lines.push("", disclosureHint(total, "later project", [{ command: "things projects --show-later" }]));
294
459
  return lines;
295
460
  }
461
+ /** Upcoming rows group under startDate, or under deadline for non-template rows. */
462
+ const groupDate = (i) => i.startDate ?? (i.repeating.isTemplate ? null : i.deadline);
296
463
  /**
297
464
  * Upcoming rows under GUI-style date headers (empty periods are simply
298
465
  * absent), with the trailing Repeating To-Dos section: templates with no
299
466
  * set next occurrence, carrying their waiting/paused/ended status word and
300
467
  * the bare ⚑ when the rule will assign a deadline per occurrence.
468
+ *
469
+ * Deadline-forecast rows (UPC1) keep startDate=null but carry a real future
470
+ * deadline; they group under their DEADLINE date via COALESCE(startDate,
471
+ * deadline) — no when-date pill (the header carries the date; formatItem's
472
+ * chip keys on startDate, so a null-startDate row shows only the ⚑ deadline
473
+ * flag, mirroring the GUI's bare-flag anatomy).
301
474
  */
302
475
  export function renderUpcoming(items, now) {
303
476
  if (items.length === 0)
304
477
  return ["(empty)"];
305
- const todayIso = localToday(now);
478
+ const todayIso = localToday(now ?? renderNow(), renderZone());
306
479
  const w = uuidDisplayWidth(items);
307
480
  const fmtOpts = now === undefined ? {} : { now };
308
- const dated = items.filter((i) => i.startDate !== null);
481
+ const dated = items.filter((i) => groupDate(i) !== null);
309
482
  const resting = items.filter((i) => i.startDate === null && i.repeating.isTemplate);
310
483
  const lines = [];
311
484
  let openHeader = null;
312
485
  for (const item of dated) {
313
- const bucket = upcomingBucket(item.startDate ?? "", todayIso);
486
+ const bucket = upcomingBucket(groupDate(item) ?? "", todayIso);
314
487
  if (bucket.label !== openHeader) {
315
488
  if (lines.length > 0)
316
489
  lines.push("");
@@ -323,9 +496,10 @@ export function renderUpcoming(items, now) {
323
496
  if (lines.length > 0)
324
497
  lines.push("");
325
498
  lines.push(bold("── Repeating To-Dos ──"));
326
- for (const item of resting) {
327
- lines.push(formatItem(item, w, { ...fmtOpts, statusWord: templateStatus(item.repeating, todayIso) }));
328
- }
499
+ // The ‹waiting›/‹paused›/‹ended› chip is derived inside formatItem
500
+ // (a template with no next date), so resting rows need no special opts.
501
+ for (const item of resting)
502
+ lines.push(formatItem(item, w, fmtOpts));
329
503
  }
330
504
  return lines;
331
505
  }
@@ -334,12 +508,15 @@ export function renderUpcoming(items, now) {
334
508
  * — `── July ──` within the current year, `── March 2025 ──` beyond (finer
335
509
  * than the GUI's bare per-year buckets, deliberately). Truncation past the
336
510
  * row limit is reported by the shared hint the command appends, not here.
511
+ * Resolved is the Logbook's NORM, so rows pass `resolvedNormal`: completed
512
+ * titles render plain and canceled titles keep their strikethrough but drop
513
+ * the dim (the blue `[✓]`/`[×]` marks and logged date are unchanged).
337
514
  */
338
515
  export function renderLogbook(items, now) {
339
516
  if (items.length === 0)
340
517
  return ["(empty)"];
341
518
  const w = uuidDisplayWidth(items);
342
- const currentYear = localToday(now).slice(0, 4);
519
+ const currentYear = localToday(now ?? renderNow(), renderZone()).slice(0, 4);
343
520
  const lines = [];
344
521
  let openHeading = null;
345
522
  for (const item of items) {
@@ -353,7 +530,9 @@ export function renderLogbook(items, now) {
353
530
  lines.push(bold(`── ${heading} ──`));
354
531
  openHeading = heading;
355
532
  }
356
- lines.push(formatItem(item, w, now === undefined ? {} : { now }));
533
+ // The Logbook's normal IS the resolved state rows render plain
534
+ // (completed) / strike-only (canceled), never dim (render-language delta 6).
535
+ lines.push(formatItem(item, w, { resolvedNormal: true, ...(now === undefined ? {} : { now }) }));
357
536
  }
358
537
  return lines;
359
538
  }
@@ -409,10 +588,34 @@ export function renderSections(sections, star = false) {
409
588
  return lines;
410
589
  }
411
590
  /** Single-quote a title for a copy-pasteable drill-down command. */
412
- function quoteTitle(title) {
591
+ export function quoteTitle(title) {
413
592
  return `'${title.replace(/'/g, "'\\''")}'`;
414
593
  }
415
- const takeUpTo = (items, limit) => limit === null ? items : items.slice(0, limit);
594
+ /**
595
+ * The one disclosure-hint grammar (docs/design/render-language.md § Disclosure
596
+ * hints): a muted `… <count> <noun> — [label:] `command` [· [label:] `command`]`.
597
+ * Every muted hint that discloses more content routes through here so the
598
+ * grammar lives in ONE place (the same one-law pattern as projectTitleAccent).
599
+ *
600
+ * `count` pluralizes `noun` (pass a `{ one, many }` pair when the plural is not
601
+ * a trailing `s`); each action is a FULL command echoing the user's own
602
+ * invocation, and a `label` prefixes an action only where it adds semantics the
603
+ * command text doesn't carry (e.g. `recent:` before a capped `--show-logged`).
604
+ * `indent` marks a TRUNCATION FOOTER — two spaces under its partially-shown
605
+ * section — whereas a HIDDEN-SECTION placeholder stays flush at the position
606
+ * its unrendered section would occupy.
607
+ */
608
+ export function disclosureHint(count, noun, actions, opts = {}) {
609
+ const phrase = typeof noun === "string"
610
+ ? `${noun}${count === 1 ? "" : "s"}`
611
+ : count === 1
612
+ ? noun.one
613
+ : noun.many;
614
+ const acts = actions
615
+ .map((a) => `${a.label === undefined ? "" : `${a.label}: `}\`${a.command}\``)
616
+ .join(" · ");
617
+ return dim(`${opts.indent === true ? " " : ""}… ${count} ${phrase} — ${acts}`);
618
+ }
416
619
  /** Muted per-block truncation line: `… N more — \`drill-down\``. */
417
620
  function blockMoreLine(total, shown, drill) {
418
621
  return dim(` … ${total - shown} more${drill === null ? "" : ` — \`${drill}\``}`);
@@ -421,36 +624,51 @@ function blockMoreLine(total, shown, drill) {
421
624
  * Type-aware variant for blocks that mix project rows and to-dos (someday's
422
625
  * loose/area blocks): the hidden remainder is split by type — `… 5 more
423
626
  * projects, 14 more to-dos` — omitting a type with nothing hidden and
424
- * pluralizing per count.
627
+ * pluralizing per count. The counts come from the block metadata (projects
628
+ * always list first, so the hidden split is derivable from `totalProjects`).
425
629
  */
426
- function mixedMoreLine(hidden, drill) {
427
- const projects = hidden.filter((i) => i.type === "project").length;
428
- const todos = hidden.length - projects;
630
+ function mixedMoreLine(projects, todos, drill) {
429
631
  const parts = [
430
632
  ...(projects > 0 ? [`${projects} more project${projects === 1 ? "" : "s"}`] : []),
431
633
  ...(todos > 0 ? [`${todos} more to-do${todos === 1 ? "" : "s"}`] : []),
432
634
  ];
433
635
  return dim(` … ${parts.join(", ")}${drill === null ? "" : ` — \`${drill}\``}`);
434
636
  }
637
+ /** The area/loose block for a section, matched by identity in the grouped metadata. */
638
+ function areaBlockFor(grouped, area) {
639
+ const kind = area === null ? "loose" : "area";
640
+ const ref = area?.uuid ?? null;
641
+ return grouped.blocks.find((b) => b.kind === kind && b.ref === ref);
642
+ }
643
+ /** A nested project block anywhere in the grouped metadata, matched by project uuid. */
644
+ function projectBlockFor(grouped, projectUuid) {
645
+ for (const b of grouped.blocks) {
646
+ const hit = b.children?.find((c) => c.kind === "project" && c.ref === projectUuid);
647
+ if (hit !== undefined)
648
+ return hit;
649
+ }
650
+ return undefined;
651
+ }
435
652
  /**
436
653
  * Muted bottom line for a truncated grouped view: `── more per group — see
437
- * more: \`<base> <bigger flags>\` · all: \`<base> --all\` ──`, where the
438
- * bigger-flags command doubles exactly the caps that actually truncated.
654
+ * more: \`<base> <bigger flags>\` · \`<base> --all\` ──`, where the bigger-flags
655
+ * command doubles exactly the caps that actually truncated.
439
656
  */
440
657
  function groupedBottomLine(base, escalations, allBase = base) {
441
658
  const seeMore = escalations.length > 0 ? `see more: \`${base} ${escalations.join(" ")}\` · ` : "";
442
- return dim(`── more per group — ${seeMore}all: \`${allBase} --all\` ──`);
659
+ return dim(`── more per group — ${seeMore}\`${allBase} --all\` ──`);
443
660
  }
444
661
  /**
445
662
  * The anytime preview: the FULL block skeleton — every area header and every
446
- * project row — always renders; `limits.area` caps the loose block and each
447
- * area's direct to-dos, `limits.project` each project's to-dos. A truncated
448
- * block trails a muted `… N more — \`things (project|area) show '…'\``
449
- * drill-down (the loose block has no container, so it shows only the count),
450
- * and the view ends with one line escalating the caps that hit. Today members
451
- * are starred. Mirrors renderSections' layout exactly.
663
+ * project row — always renders; the `sections` are the already-bounded view
664
+ * and `grouped` the per-block metadata that carries each block's pre-cap total,
665
+ * so a truncated block trails a muted `… N more — \`things (project|area) show
666
+ * '…'\`` drill-down (the loose block has no container, so it shows only the
667
+ * count), and the view ends with one line escalating the caps that hit
668
+ * (`limits` supplies the doubling). Today members are starred. Mirrors
669
+ * renderSections' layout exactly.
452
670
  */
453
- export function renderAnytimePreview(sections, limits, base) {
671
+ export function renderAnytimePreview(sections, grouped, limits, base) {
454
672
  const all = sections.flatMap((s) => s.items);
455
673
  if (all.length === 0)
456
674
  return ["(empty)"];
@@ -467,31 +685,33 @@ export function renderAnytimePreview(sections, limits, base) {
467
685
  blank();
468
686
  lines.push(`${bold("──")} ${areaMark()} ${bold(`${section.area.title} ──`)}`);
469
687
  }
688
+ // The section is already bounded, so its direct/child lists ARE the shown
689
+ // rows; the pre-cap totals come from the block metadata.
470
690
  const { direct, projects } = splitSectionBlocks(section);
471
691
  const suppressArea = section.area?.uuid ?? null;
472
- const shownDirect = takeUpTo(direct, limits.area);
473
- for (const item of shownDirect) {
692
+ const areaBlock = areaBlockFor(grouped, section.area);
693
+ for (const item of direct) {
474
694
  lines.push(formatItem(item, w, { suppressArea, mark: todayMark(item) }));
475
695
  }
476
- if (direct.length > shownDirect.length) {
696
+ if (areaBlock !== undefined && areaBlock.total > areaBlock.shown) {
477
697
  areaHit = true;
478
698
  const drill = section.area === null ? null : `things area show ${quoteTitle(section.area.title)}`;
479
- lines.push(blockMoreLine(direct.length, shownDirect.length, drill));
699
+ lines.push(blockMoreLine(areaBlock.total, areaBlock.shown, drill));
480
700
  }
481
701
  for (const { project, items: children } of projects) {
482
702
  blank();
483
703
  lines.push(formatItem(project, w, { projectTitle: true, suppressArea, mark: todayMark(project) }));
484
- const shownChildren = takeUpTo(children, limits.project);
485
- for (const item of shownChildren) {
704
+ for (const item of children) {
486
705
  lines.push(formatItem(item, w, {
487
706
  suppressProject: project.uuid,
488
707
  suppressArea,
489
708
  mark: todayMark(item),
490
709
  }));
491
710
  }
492
- if (children.length > shownChildren.length) {
711
+ const projectBlock = areaBlock?.children?.find((c) => c.kind === "project" && c.ref === project.uuid);
712
+ if (projectBlock !== undefined && projectBlock.total > projectBlock.shown) {
493
713
  projectHit = true;
494
- lines.push(blockMoreLine(children.length, shownChildren.length, `things project show ${quoteTitle(project.title)}`));
714
+ lines.push(blockMoreLine(projectBlock.total, projectBlock.shown, `things project show ${quoteTitle(project.title)}`));
495
715
  }
496
716
  }
497
717
  }
@@ -516,7 +736,7 @@ export function renderAnytimePreview(sections, limits, base) {
516
736
  * When the toggle is off and such items exist, a muted bottom hint counts
517
737
  * them and names the flag.
518
738
  */
519
- export function renderSomedayPreview(sections, limits, base, showActive, hiddenActiveItems) {
739
+ export function renderSomedayPreview(sections, grouped, limits, base, showActive, hiddenActiveItems) {
520
740
  const all = sections.flatMap((s) => s.items);
521
741
  const lines = [];
522
742
  let areaHit = false;
@@ -532,6 +752,8 @@ export function renderSomedayPreview(sections, limits, base, showActive, hiddenA
532
752
  const w = uuidDisplayWidth(all);
533
753
  const trailing = [];
534
754
  for (const section of sections) {
755
+ // The section is already bounded; re-partitioning it yields the SHOWN own
756
+ // items and child groups, with pre-cap totals read from the metadata.
535
757
  const { own, children } = partitionSomedaySection(section);
536
758
  trailing.push(...children);
537
759
  if (section.area !== null) {
@@ -539,13 +761,18 @@ export function renderSomedayPreview(sections, limits, base, showActive, hiddenA
539
761
  lines.push(`${bold("──")} ${areaMark()} ${bold(`${section.area.title} ──`)}`);
540
762
  }
541
763
  const suppressArea = section.area?.uuid ?? null;
542
- const shownOwn = takeUpTo(own, limits.area);
543
- for (const item of shownOwn)
764
+ for (const item of own)
544
765
  lines.push(formatItem(item, w, { suppressArea }));
545
- if (own.length > shownOwn.length) {
766
+ const ownBlock = areaBlockFor(grouped, section.area);
767
+ if (ownBlock !== undefined && ownBlock.total > ownBlock.shown) {
546
768
  areaHit = true;
547
769
  const drill = section.area === null ? null : `things area show ${quoteTitle(section.area.title)}`;
548
- lines.push(mixedMoreLine(own.slice(shownOwn.length), drill));
770
+ // Projects list first, so the hidden split is derivable from the block's
771
+ // totalProjects and how many of them were shown.
772
+ const shownProjects = Math.min(ownBlock.shown, ownBlock.totalProjects ?? 0);
773
+ const hiddenProjects = (ownBlock.totalProjects ?? 0) - shownProjects;
774
+ const hiddenTodos = ownBlock.total - ownBlock.shown - hiddenProjects;
775
+ lines.push(mixedMoreLine(hiddenProjects, hiddenTodos, drill));
549
776
  }
550
777
  }
551
778
  if (trailing.length > 0) {
@@ -553,14 +780,14 @@ export function renderSomedayPreview(sections, limits, base, showActive, hiddenA
553
780
  lines.push(bold("── From active projects ──"));
554
781
  for (const group of trailing) {
555
782
  blank();
556
- lines.push(`${dim(uuidCol(group.project.uuid, w))} ${bold(underline(group.project.title))}`);
557
- const shown = takeUpTo(group.items, limits.project);
558
- for (const item of shown) {
783
+ lines.push(`${dim(uuidCol(group.project.uuid, w))} ${underline(projectTitleAccent(group.project.title))}`);
784
+ for (const item of group.items) {
559
785
  lines.push(formatItem(item, w, { suppressProject: group.project.uuid }));
560
786
  }
561
- if (group.items.length > shown.length) {
787
+ const projectBlock = projectBlockFor(grouped, group.project.uuid);
788
+ if (projectBlock !== undefined && projectBlock.total > projectBlock.shown) {
562
789
  projectHit = true;
563
- lines.push(blockMoreLine(group.items.length, shown.length, `things project show ${quoteTitle(group.project.title)}`));
790
+ lines.push(blockMoreLine(projectBlock.total, projectBlock.shown, `things project show ${quoteTitle(group.project.title)}`));
564
791
  }
565
792
  }
566
793
  }
@@ -578,7 +805,10 @@ export function renderSomedayPreview(sections, limits, base, showActive, hiddenA
578
805
  }
579
806
  if (!showActive && hiddenActiveItems > 0) {
580
807
  blank();
581
- lines.push(dim(`(${hiddenActiveItems} someday to-do${hiddenActiveItems === 1 ? "" : "s"} inside active projects — visible with \`things someday --show-active-project-items\`)`));
808
+ lines.push(disclosureHint(hiddenActiveItems, {
809
+ one: "someday to-do inside active projects",
810
+ many: "someday to-dos inside active projects",
811
+ }, [{ command: `${base} --show-active-project-items` }]));
582
812
  }
583
813
  return lines;
584
814
  }