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