things-api 0.8.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 (238) hide show
  1. package/README.md +31 -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 +34 -2
  8. package/dist/cli/commands/area.js +140 -28
  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 +23 -1
  16. package/dist/cli/commands/project.js +82 -15
  17. package/dist/cli/commands/project.js.map +1 -1
  18. package/dist/cli/commands/reads.d.ts +6 -125
  19. package/dist/cli/commands/reads.js +649 -657
  20. package/dist/cli/commands/reads.js.map +1 -1
  21. package/dist/cli/commands/repeat-flags.d.ts +20 -0
  22. package/dist/cli/commands/repeat-flags.js +76 -0
  23. package/dist/cli/commands/repeat-flags.js.map +1 -0
  24. package/dist/cli/commands/setup.js +2 -3
  25. package/dist/cli/commands/setup.js.map +1 -1
  26. package/dist/cli/commands/show.d.ts +1 -1
  27. package/dist/cli/commands/show.js +169 -18
  28. package/dist/cli/commands/show.js.map +1 -1
  29. package/dist/cli/commands/snapshot.js +1 -1
  30. package/dist/cli/commands/snapshot.js.map +1 -1
  31. package/dist/cli/commands/todo.d.ts +1 -1
  32. package/dist/cli/commands/todo.js +21 -9
  33. package/dist/cli/commands/todo.js.map +1 -1
  34. package/dist/cli/commands/writes.js +378 -148
  35. package/dist/cli/commands/writes.js.map +1 -1
  36. package/dist/cli/did-you-mean.d.ts +29 -0
  37. package/dist/cli/did-you-mean.js +53 -0
  38. package/dist/cli/did-you-mean.js.map +1 -0
  39. package/dist/cli/excess-args.d.ts +15 -0
  40. package/dist/cli/excess-args.js +51 -0
  41. package/dist/cli/excess-args.js.map +1 -0
  42. package/dist/cli/glyphs.d.ts +67 -7
  43. package/dist/cli/glyphs.js +202 -19
  44. package/dist/cli/glyphs.js.map +1 -1
  45. package/dist/cli/help.d.ts +54 -0
  46. package/dist/cli/help.js +355 -0
  47. package/dist/cli/help.js.map +1 -0
  48. package/dist/cli/main.js +36 -21
  49. package/dist/cli/main.js.map +1 -1
  50. package/dist/cli/period.d.ts +39 -0
  51. package/dist/cli/period.js +141 -0
  52. package/dist/cli/period.js.map +1 -0
  53. package/dist/cli/read-driver.d.ts +83 -0
  54. package/dist/cli/read-driver.js +215 -0
  55. package/dist/cli/read-driver.js.map +1 -0
  56. package/dist/cli/render.d.ts +246 -0
  57. package/dist/cli/render.js +806 -0
  58. package/dist/cli/render.js.map +1 -0
  59. package/dist/cli/resolve-invocation.d.ts +116 -0
  60. package/dist/cli/resolve-invocation.js +261 -0
  61. package/dist/cli/resolve-invocation.js.map +1 -0
  62. package/dist/cli/shell-quote.d.ts +9 -0
  63. package/dist/cli/shell-quote.js +12 -0
  64. package/dist/cli/shell-quote.js.map +1 -0
  65. package/dist/cli/tag-filters.d.ts +59 -0
  66. package/dist/cli/tag-filters.js +57 -0
  67. package/dist/cli/tag-filters.js.map +1 -0
  68. package/dist/cli/verb-hint.d.ts +25 -0
  69. package/dist/cli/verb-hint.js +138 -0
  70. package/dist/cli/verb-hint.js.map +1 -0
  71. package/dist/cli/width.d.ts +135 -0
  72. package/dist/cli/width.js +313 -0
  73. package/dist/cli/width.js.map +1 -0
  74. package/dist/client.d.ts +146 -24
  75. package/dist/client.js +106 -16
  76. package/dist/client.js.map +1 -1
  77. package/dist/config.d.ts +11 -0
  78. package/dist/config.js +3 -0
  79. package/dist/config.js.map +1 -1
  80. package/dist/contracts.d.ts +136 -1
  81. package/dist/contracts.js +44 -1
  82. package/dist/contracts.js.map +1 -1
  83. package/dist/db/fingerprint.d.ts +12 -0
  84. package/dist/db/fingerprint.js +15 -1
  85. package/dist/db/fingerprint.js.map +1 -1
  86. package/dist/db/locate.js +1 -1
  87. package/dist/db/locate.js.map +1 -1
  88. package/dist/diagnose.d.ts +63 -0
  89. package/dist/diagnose.js +38 -1
  90. package/dist/diagnose.js.map +1 -1
  91. package/dist/index.d.ts +30 -4
  92. package/dist/index.js +35 -2
  93. package/dist/index.js.map +1 -1
  94. package/dist/mcp/server.d.ts +9 -1
  95. package/dist/mcp/server.js +850 -128
  96. package/dist/mcp/server.js.map +1 -1
  97. package/dist/model/entities.d.ts +40 -7
  98. package/dist/model/entities.js.map +1 -1
  99. package/dist/model/mappers.d.ts +7 -0
  100. package/dist/model/mappers.js +20 -3
  101. package/dist/model/mappers.js.map +1 -1
  102. package/dist/model/occurrences.d.ts +6 -1
  103. package/dist/model/occurrences.js +9 -5
  104. package/dist/model/occurrences.js.map +1 -1
  105. package/dist/model/recurrence.d.ts +17 -8
  106. package/dist/model/recurrence.js.map +1 -1
  107. package/dist/model/serialize.d.ts +28 -0
  108. package/dist/model/serialize.js +80 -0
  109. package/dist/model/serialize.js.map +1 -0
  110. package/dist/model/when-sugar.d.ts +47 -0
  111. package/dist/model/when-sugar.js +45 -0
  112. package/dist/model/when-sugar.js.map +1 -0
  113. package/dist/read/area-view.d.ts +8 -1
  114. package/dist/read/area-view.js +49 -13
  115. package/dist/read/area-view.js.map +1 -1
  116. package/dist/read/caps.d.ts +14 -0
  117. package/dist/read/caps.js +21 -0
  118. package/dist/read/caps.js.map +1 -0
  119. package/dist/read/filter-contract.d.ts +117 -0
  120. package/dist/read/filter-contract.js +78 -0
  121. package/dist/read/filter-contract.js.map +1 -0
  122. package/dist/read/log-boundary.d.ts +9 -4
  123. package/dist/read/log-boundary.js.map +1 -1
  124. package/dist/read/predicates.d.ts +61 -0
  125. package/dist/read/predicates.js +78 -0
  126. package/dist/read/predicates.js.map +1 -0
  127. package/dist/read/project-view.d.ts +12 -1
  128. package/dist/read/project-view.js +39 -8
  129. package/dist/read/project-view.js.map +1 -1
  130. package/dist/read/queries.d.ts +156 -8
  131. package/dist/read/queries.js +261 -38
  132. package/dist/read/queries.js.map +1 -1
  133. package/dist/read/search-rank.d.ts +36 -0
  134. package/dist/read/search-rank.js +31 -0
  135. package/dist/read/search-rank.js.map +1 -0
  136. package/dist/read/sections.d.ts +51 -0
  137. package/dist/read/sections.js +37 -0
  138. package/dist/read/sections.js.map +1 -0
  139. package/dist/read/show-target.d.ts +13 -4
  140. package/dist/read/show-target.js +11 -2
  141. package/dist/read/show-target.js.map +1 -1
  142. package/dist/read/sidebar-order.d.ts +19 -0
  143. package/dist/read/sidebar-order.js +93 -0
  144. package/dist/read/sidebar-order.js.map +1 -0
  145. package/dist/read/tags.d.ts +27 -3
  146. package/dist/read/tags.js +83 -12
  147. package/dist/read/tags.js.map +1 -1
  148. package/dist/read/truncation.d.ts +71 -0
  149. package/dist/read/truncation.js +217 -0
  150. package/dist/read/truncation.js.map +1 -0
  151. package/dist/read/views.d.ts +177 -19
  152. package/dist/read/views.js +390 -213
  153. package/dist/read/views.js.map +1 -1
  154. package/dist/surface-copy.d.ts +42 -0
  155. package/dist/surface-copy.js +55 -0
  156. package/dist/surface-copy.js.map +1 -1
  157. package/dist/sync-health.d.ts +78 -0
  158. package/dist/sync-health.js +312 -0
  159. package/dist/sync-health.js.map +1 -0
  160. package/dist/write/accessibility-probe.d.ts +12 -0
  161. package/dist/write/accessibility-probe.js +63 -0
  162. package/dist/write/accessibility-probe.js.map +1 -0
  163. package/dist/write/automation-probe.d.ts +7 -0
  164. package/dist/write/automation-probe.js +8 -1
  165. package/dist/write/automation-probe.js.map +1 -1
  166. package/dist/write/batch.js +4 -2
  167. package/dist/write/batch.js.map +1 -1
  168. package/dist/write/capabilities.d.ts +8 -0
  169. package/dist/write/capabilities.js +13 -5
  170. package/dist/write/capabilities.js.map +1 -1
  171. package/dist/write/commands.d.ts +2 -0
  172. package/dist/write/commands.js +562 -48
  173. package/dist/write/commands.js.map +1 -1
  174. package/dist/write/edit-checklist.js +3 -2
  175. package/dist/write/edit-checklist.js.map +1 -1
  176. package/dist/write/guards.d.ts +2 -7
  177. package/dist/write/guards.js +64 -6
  178. package/dist/write/guards.js.map +1 -1
  179. package/dist/write/heading.js +2 -0
  180. package/dist/write/heading.js.map +1 -1
  181. package/dist/write/lock.d.ts +40 -2
  182. package/dist/write/lock.js +91 -14
  183. package/dist/write/lock.js.map +1 -1
  184. package/dist/write/make-repeating-project.d.ts +4 -0
  185. package/dist/write/make-repeating-project.js +253 -0
  186. package/dist/write/make-repeating-project.js.map +1 -0
  187. package/dist/write/operations.d.ts +145 -1
  188. package/dist/write/operations.js +48 -0
  189. package/dist/write/operations.js.map +1 -1
  190. package/dist/write/pipeline.d.ts +19 -0
  191. package/dist/write/pipeline.js +109 -26
  192. package/dist/write/pipeline.js.map +1 -1
  193. package/dist/write/pre-state.d.ts +68 -3
  194. package/dist/write/pre-state.js +102 -3
  195. package/dist/write/pre-state.js.map +1 -1
  196. package/dist/write/reopen.js +3 -2
  197. package/dist/write/reopen.js.map +1 -1
  198. package/dist/write/reorder.js +27 -17
  199. package/dist/write/reorder.js.map +1 -1
  200. package/dist/write/repeat-rule.d.ts +22 -0
  201. package/dist/write/repeat-rule.js +258 -0
  202. package/dist/write/repeat-rule.js.map +1 -0
  203. package/dist/write/reversibility.js +49 -0
  204. package/dist/write/reversibility.js.map +1 -1
  205. package/dist/write/tag-refs.d.ts +47 -0
  206. package/dist/write/tag-refs.js +126 -0
  207. package/dist/write/tag-refs.js.map +1 -0
  208. package/dist/write/undo.d.ts +74 -5
  209. package/dist/write/undo.js +494 -80
  210. package/dist/write/undo.js.map +1 -1
  211. package/dist/write/vectors/registry.d.ts +17 -1
  212. package/dist/write/vectors/registry.js +18 -2
  213. package/dist/write/vectors/registry.js.map +1 -1
  214. package/dist/write/vectors/types.d.ts +119 -3
  215. package/dist/write/vectors/ui-certification.d.ts +48 -0
  216. package/dist/write/vectors/ui-certification.js +46 -0
  217. package/dist/write/vectors/ui-certification.js.map +1 -0
  218. package/dist/write/vectors/ui-drag.d.ts +188 -0
  219. package/dist/write/vectors/ui-drag.js +1095 -0
  220. package/dist/write/vectors/ui-drag.js.map +1 -0
  221. package/dist/write/vectors/ui-recipes.d.ts +70 -0
  222. package/dist/write/vectors/ui-recipes.js +558 -0
  223. package/dist/write/vectors/ui-recipes.js.map +1 -0
  224. package/dist/write/vectors/ui.d.ts +137 -0
  225. package/dist/write/vectors/ui.js +656 -0
  226. package/dist/write/vectors/ui.js.map +1 -0
  227. package/dist/write/verify/delta.d.ts +31 -3
  228. package/dist/write/verify/delta.js +22 -6
  229. package/dist/write/verify/delta.js.map +1 -1
  230. package/dist/write/verify/poller.js +1 -0
  231. package/dist/write/verify/poller.js.map +1 -1
  232. package/package.json +1 -1
  233. package/dist/cli/exit-codes.d.ts +0 -26
  234. package/dist/cli/exit-codes.js +0 -26
  235. package/dist/cli/exit-codes.js.map +0 -1
  236. package/dist/cli/output.d.ts +0 -42
  237. package/dist/cli/output.js +0 -16
  238. package/dist/cli/output.js.map +0 -1
@@ -0,0 +1,806 @@
1
+ /**
2
+ * The pure human-rendering family for the read views: the glyph-language item
3
+ * formatter, the flat and grouped list renderers, the sidebar/upcoming/logbook
4
+ * layouts, the anytime/someday previews, and the legend. No commander and no
5
+ * database access — every function maps already-fetched data to output lines,
6
+ * so the whole module is unit-testable in isolation. UUIDs are always shown
7
+ * (agents and humans both need stable references); colors engage on a TTY only
8
+ * (../cli/style.ts).
9
+ */
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";
13
+ import { FULL_MONTHS, upcomingBucket } from "./period.js";
14
+ import { fitRow, getFitWidth, resolveFit, stripSgr, TITLE_MIN, visibleWidth, } from "./width.js";
15
+ /**
16
+ * A view's TTY-only title preamble: bold view name + its dim Things deep link,
17
+ * then a blank line — e.g. `Anytime (things:///show?id=anytime)`. The id is the
18
+ * app's documented show id (identical to the command name for these views).
19
+ * Suppressed off a TTY so `things inbox | grep …` stays clean, and never part
20
+ * of `--json`; the caller gates on both.
21
+ */
22
+ export function viewHeaderLines(view) {
23
+ const title = view.charAt(0).toUpperCase() + view.slice(1);
24
+ return [`${bold(title)} ${dim(`(things:///show?id=${view})`)}`, ""];
25
+ }
26
+ /** Re-export the canonical ANSI-stripper (impl in ./width.ts) — the legend and `--json` legend path use it. */
27
+ export const stripAnsi = stripSgr;
28
+ /** Left-column width for the legend's glyph samples (short marks align; long textual samples overflow). */
29
+ const LEGEND_GUTTER = 10;
30
+ /**
31
+ * The `things legend` layout: the visual language grouped into sections
32
+ * (`── To-dos ──`, …), each row the glyph as it actually renders (color on a
33
+ * TTY) followed by its meaning. Content comes straight from glyphs.ts's LEGEND
34
+ * table, so it can never drift from what the list renderers emit.
35
+ */
36
+ export function renderLegend() {
37
+ const lines = [];
38
+ for (const group of LEGEND_GROUPS) {
39
+ const entries = LEGEND.filter((e) => e.group === group);
40
+ if (entries.length === 0)
41
+ continue;
42
+ if (lines.length > 0)
43
+ lines.push("");
44
+ lines.push(bold(`── ${group} ──`));
45
+ for (const e of entries) {
46
+ const pad = " ".repeat(Math.max(0, LEGEND_GUTTER - visibleWidth(e.glyph)));
47
+ lines.push(`${e.glyph}${pad} ${e.meaning}`);
48
+ }
49
+ }
50
+ return lines;
51
+ }
52
+ /** Wraps a `#a #b` tag form in the row's dim styling (incl. its leading space). */
53
+ const styleTags = (form) => ` ${dim(form)}`;
54
+ /**
55
+ * One item line:
56
+ * `<uuid-prefix> <box> [★|⏾] [logged-date] [‹chip›] <title> [‹n›] [⍾] [≡] [≔] (container) #tags [⚑ deadline]`.
57
+ * Repeating templates seat ↻ INSIDE the box (`[↻]`/`(↻)`) rather than as a
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
66
+ * a SHORTENED uuid prefix (every command accepts unique prefixes >= 6
67
+ * chars); `uuidWidth` is the display length from uuidDisplayWidth — never
68
+ * below 8 so a copied prefix stays unique across the whole database, not
69
+ * just the rendered list. Tags follow the title (`#`-prefixed, green — GUI
70
+ * color), after the count chip (projects), notes marker, and container.
71
+ * Heading-nested to-dos label their parent PROJECT (via headingProject),
72
+ * never the heading — GUI behavior. Colors engage on a TTY only
73
+ * (../style.ts); `--json` always carries full uuids.
74
+ */
75
+ export function formatItem(item, uuidWidth = 0, opts = {}) {
76
+ const todayIso = localToday(opts.now);
77
+ const asTitle = opts.projectTitle === true && item.type === "project";
78
+ const box = item.type === "project" ? projectCircle(item) : todoBox(item);
79
+ const meta = [];
80
+ if (opts.mark != null)
81
+ meta.push(opts.mark);
82
+ // ↻ now lives INSIDE the box for templates (glyphs.ts) — no separate mark.
83
+ if (item.status !== "open" && item.stopped !== null)
84
+ meta.push(loggedDate(item.stopped, todayIso));
85
+ if (opts.hideDateChip === true) {
86
+ // rows under a day header — the header carries the date
87
+ }
88
+ else if (item.status === "open" && item.startDate !== null && item.startDate > todayIso)
89
+ meta.push(dateChip(item.startDate, todayIso));
90
+ // Repeating templates chip their app-materialized next occurrence.
91
+ else if (item.repeating.isTemplate && item.repeating.nextOccurrence != null)
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)}›`));
99
+ // List rows mute their tags (the GUI's gray pills); tags go green only on
100
+ // the opened resource (todo show / the project|area header row).
101
+ const tags = item.tags.length > 0 ? ` ${dim(`#${item.tags.map((t) => t.title).join(" #")}`)}` : "";
102
+ // Closed rows drop the deadline flag — a months-old red "n days ago" on a
103
+ // logged item is noise (the GUI doesn't flag logbook rows either). Raw
104
+ // template rows drop it via the sentinel guard: their deadline column
105
+ // carries app-internal 4001-01-01 sentinels (upcoming's synthesized
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;
114
+ const rule = item.repeating.rule;
115
+ const deadline = item.status === "open" && item.deadline !== null && item.deadline < "4000"
116
+ ? ` ${deadlineToken(item.deadline, todayIso, compact)}`
117
+ : // The GUI's bare flag on no-date repeating templates: the rule WILL
118
+ // assign each occurrence a deadline (fixed rules always do; after-
119
+ // completion only with a start offset), date unknown until spawned.
120
+ item.repeating.isTemplate &&
121
+ rule !== undefined &&
122
+ (rule.type === "fixed" || rule.startOffsetDays < 0)
123
+ ? ` ${bold(dim("⚑"))}`
124
+ : "";
125
+ const container = item.type === "to-do" ? (item.project ?? item.headingProject ?? null) : null;
126
+ const context = container !== null
127
+ ? container.uuid === opts.suppressProject
128
+ ? ""
129
+ : ` (${container.title})`
130
+ : item.area
131
+ ? item.area.uuid === opts.suppressArea
132
+ ? ""
133
+ : ` (${item.area.title})`
134
+ : "";
135
+ // width 0 (the default) means "no column" — show the full uuid untouched.
136
+ const shownUuid = uuidWidth > 0 ? uuidCol(item.uuid, uuidWidth) : item.uuid;
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
+ };
163
+ // GUI indicator order after the title: bell, document, checklist.
164
+ const tail = [
165
+ ...(item.type === "project" ? [countChip(item)] : []),
166
+ ...(item.reminder !== null ? [dim(REMINDER_MARK)] : []),
167
+ ...(item.notes !== "" ? [dim(NOTES_MARK)] : []),
168
+ ...(item.type === "to-do" && item.checklistItemsCount > 0 ? [dim(CHECKLIST_MARK)] : []),
169
+ ];
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);
198
+ }
199
+ /**
200
+ * Row prefix in today-aware views: yellow ★ for Today members, cyan ⏾ for
201
+ * effective This-Evening members (raw evening assignment counts only while
202
+ * startDate is exactly today — the UI's daily expiry), null otherwise.
203
+ */
204
+ export function todayMark(item, now) {
205
+ if (!isTodayMember(item, now))
206
+ return null;
207
+ const evening = item.todaySection === "evening" && item.startDate === localToday(now);
208
+ return evening ? eveningMoon() : todayStar();
209
+ }
210
+ /** Minimum displayed-prefix length: shorter prefixes collide across the DB. */
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;
280
+ /**
281
+ * Display width for a list's uuid column: the shortest prefix that is
282
+ * unique WITHIN the list, floored at UUID_DISPLAY_MIN (list-local
283
+ * uniqueness at 2–3 chars would still collide database-wide).
284
+ */
285
+ export function uuidDisplayWidth(items) {
286
+ if (items.length === 0)
287
+ return UUID_DISPLAY_MIN;
288
+ const sorted = items.map((i) => i.uuid).toSorted();
289
+ let needed = 1;
290
+ for (let i = 1; i < sorted.length; i++) {
291
+ const a = sorted[i - 1] ?? "";
292
+ const b = sorted[i] ?? "";
293
+ let common = 0;
294
+ while (common < a.length && common < b.length && a[common] === b[common])
295
+ common++;
296
+ needed = Math.max(needed, common + 1);
297
+ }
298
+ return Math.max(UUID_DISPLAY_MIN, needed);
299
+ }
300
+ /**
301
+ * Fit a uuid into the shared id column: truncate to `width` when longer, pad
302
+ * to it when shorter, so ids line up under one another regardless of prefix
303
+ * length. `width` is the value from uuidDisplayWidth.
304
+ */
305
+ export function uuidCol(uuid, width) {
306
+ return uuid.length > width ? uuid.slice(0, width) : uuid.padEnd(width);
307
+ }
308
+ export function renderList(items) {
309
+ const w = uuidDisplayWidth(items);
310
+ return items.length === 0 ? ["(empty)"] : items.map((i) => formatItem(i, w));
311
+ }
312
+ /**
313
+ * The `things today` split. The membership glyph lives in the SECTION HEADER,
314
+ * not on every row — a yellow ★ in the Today header (which also carries the
315
+ * sidebar badge split) and a blue ⏾ in the This Evening header — so the rows
316
+ * drop the redundant per-item marker (the same convention that suppresses a
317
+ * `(project)` context inside that project's own view). Every OTHER view keeps
318
+ * the per-row ★/⏾, where the marker still carries information.
319
+ *
320
+ * This Evening mirrors the GUI: it renders ONLY when evening items exist —
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.
335
+ */
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("");
355
+ lines.push(`${bold("──")} ${eveningMoon()} ${bold("This Evening ──")}`);
356
+ for (const i of view.evening)
357
+ lines.push(formatItem(i, w));
358
+ const hidden = eveningTotal - view.evening.length;
359
+ if (hidden > 0) {
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
+ }
374
+ }
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
+ }
381
+ return lines;
382
+ }
383
+ /**
384
+ * Search rows: a standard list row, plus — for a project surfaced by a HEADING
385
+ * title match — a muted `(via heading "…")` suffix crediting the heading whose
386
+ * text matched (the parent project row stands in for the heading; the GUI has
387
+ * no bare heading row). Ordinary title/notes matches render as plain rows.
388
+ */
389
+ export function renderSearch(items) {
390
+ if (items.length === 0)
391
+ return ["(empty)"];
392
+ const w = uuidDisplayWidth(items);
393
+ return items.map((i) => {
394
+ const via = i.matchedVia;
395
+ const row = formatItem(i, w);
396
+ return via === undefined ? row : `${row} ${dim(`(via heading "${via.title}")`)}`;
397
+ });
398
+ }
399
+ /**
400
+ * The sidebar mirror for `things projects`: loose projects first (the GUI
401
+ * lists them above the areas), then a `── ⬡ Area ──` header per area with
402
+ * its projects beneath (the redundant `(Area)` suffix suppressed). Items
403
+ * arrive from projectsView already in sidebar order — this only inserts the
404
+ * headers. Denser than renderSections on purpose: no title styling and no
405
+ * blank line per project (every row here IS a project). With `hints`,
406
+ * default-hidden later projects are never silent: each group trails a muted
407
+ * `…n later projects` count (a later-only area still gets its header), and
408
+ * the output ends with the flag that reveals them.
409
+ */
410
+ export function renderProjectsSidebar(items, hints) {
411
+ const total = hints?.groups.reduce((n, g) => n + g.hidden, 0) ?? 0;
412
+ if (items.length === 0 && total === 0 && (hints === undefined || hints.groups.length === 0))
413
+ return ["(empty)"];
414
+ const w = uuidDisplayWidth(items);
415
+ const byGroup = new Map();
416
+ for (const item of items) {
417
+ const key = item.area?.uuid ?? null;
418
+ byGroup.set(key, [...(byGroup.get(key) ?? []), item]);
419
+ }
420
+ // hints (when present) carry the full sidebar group order, including
421
+ // later-only groups the visible items can't reveal.
422
+ const groups = hints?.groups ??
423
+ [...byGroup.keys()].map((key) => ({
424
+ area: key === null ? null : (items.find((i) => i.area?.uuid === key)?.area ?? null),
425
+ hidden: 0,
426
+ }));
427
+ const lines = [];
428
+ for (const group of groups) {
429
+ const rows = byGroup.get(group.area?.uuid ?? null) ?? [];
430
+ // The loose block only exists when it has content; areas mirror the
431
+ // sidebar and render even when empty.
432
+ if (group.area === null && rows.length === 0 && group.hidden === 0)
433
+ continue;
434
+ if (group.area !== null) {
435
+ if (lines.length > 0)
436
+ lines.push("");
437
+ lines.push(`${bold("──")} ${areaMark()} ${bold(`${group.area.title} ──`)}`);
438
+ }
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.
443
+ if (group.hidden > 0)
444
+ lines.push(dim(`… ${group.hidden} later project${group.hidden === 1 ? "" : "s"}`));
445
+ else if (group.area !== null && rows.length === 0)
446
+ lines.push(dim("(no projects)"));
447
+ }
448
+ if (total > 0)
449
+ lines.push("", disclosureHint(total, "later project", [{ command: "things projects --show-later" }]));
450
+ return lines;
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);
454
+ /**
455
+ * Upcoming rows under GUI-style date headers (empty periods are simply
456
+ * absent), with the trailing Repeating To-Dos section: templates with no
457
+ * set next occurrence, carrying their waiting/paused/ended status word and
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).
465
+ */
466
+ export function renderUpcoming(items, now) {
467
+ if (items.length === 0)
468
+ return ["(empty)"];
469
+ const todayIso = localToday(now);
470
+ const w = uuidDisplayWidth(items);
471
+ const fmtOpts = now === undefined ? {} : { now };
472
+ const dated = items.filter((i) => groupDate(i) !== null);
473
+ const resting = items.filter((i) => i.startDate === null && i.repeating.isTemplate);
474
+ const lines = [];
475
+ let openHeader = null;
476
+ for (const item of dated) {
477
+ const bucket = upcomingBucket(groupDate(item) ?? "", todayIso);
478
+ if (bucket.label !== openHeader) {
479
+ if (lines.length > 0)
480
+ lines.push("");
481
+ lines.push(bold(`── ${bucket.label} ──`));
482
+ openHeader = bucket.label;
483
+ }
484
+ lines.push(formatItem(item, w, { ...fmtOpts, hideDateChip: bucket.isDay }));
485
+ }
486
+ if (resting.length > 0) {
487
+ if (lines.length > 0)
488
+ lines.push("");
489
+ lines.push(bold("── Repeating To-Dos ──"));
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));
494
+ }
495
+ return lines;
496
+ }
497
+ /**
498
+ * Logbook rows under GUI-style date headings, month granularity throughout
499
+ * — `── July ──` within the current year, `── March 2025 ──` beyond (finer
500
+ * than the GUI's bare per-year buckets, deliberately). Truncation past the
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).
505
+ */
506
+ export function renderLogbook(items, now) {
507
+ if (items.length === 0)
508
+ return ["(empty)"];
509
+ const w = uuidDisplayWidth(items);
510
+ const currentYear = localToday(now).slice(0, 4);
511
+ const lines = [];
512
+ let openHeading = null;
513
+ for (const item of items) {
514
+ const s = item.stopped;
515
+ const heading = s === null
516
+ ? "no logged date"
517
+ : `${FULL_MONTHS[s.getMonth()]}${String(s.getFullYear()) === currentYear ? "" : ` ${s.getFullYear()}`}`;
518
+ if (heading !== openHeading) {
519
+ if (lines.length > 0)
520
+ lines.push("");
521
+ lines.push(bold(`── ${heading} ──`));
522
+ openHeading = heading;
523
+ }
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 }) }));
527
+ }
528
+ return lines;
529
+ }
530
+ /**
531
+ * Sidebar-grouped views (anytime/someday), rendered the way the GUI reads:
532
+ * the area-less block headerless first, then one `── <area> ──` header per
533
+ * area; inside a section, loose to-dos first, then each project GROUP — a
534
+ * blank line, the project's bold+underlined title row, then its members.
535
+ * Container names implied by the grouping are not repeated on member rows
536
+ * (an area header covers its rows; a project title row covers the to-dos
537
+ * beneath it — a clustered child whose project row is absent, e.g. under a
538
+ * tag filter, keeps its `(project)` suffix). `star` prefixes each item line
539
+ * with the Today-membership mark (★, or ⏾ for This-Evening members).
540
+ */
541
+ export function renderSections(sections, star = false) {
542
+ const all = sections.flatMap((s) => s.items);
543
+ if (all.length === 0)
544
+ return ["(empty)"];
545
+ const w = uuidDisplayWidth(all);
546
+ const lines = [];
547
+ const blank = () => {
548
+ if (lines.length > 0 && lines.at(-1) !== "")
549
+ lines.push("");
550
+ };
551
+ for (const section of sections) {
552
+ if (section.area !== null) {
553
+ blank();
554
+ lines.push(`${bold("──")} ${areaMark()} ${bold(`${section.area.title} ──`)}`);
555
+ }
556
+ // The uuid of the project whose title row is directly above (its member
557
+ // rows drop their redundant `(project)` suffix).
558
+ let openProject = null;
559
+ for (const item of section.items) {
560
+ const mark = star ? todayMark(item) : null;
561
+ if (item.type === "project") {
562
+ openProject = item.uuid;
563
+ blank();
564
+ lines.push(formatItem(item, w, {
565
+ projectTitle: true,
566
+ suppressArea: section.area?.uuid ?? null,
567
+ mark,
568
+ }));
569
+ }
570
+ else {
571
+ lines.push(formatItem(item, w, {
572
+ suppressProject: openProject,
573
+ suppressArea: section.area?.uuid ?? null,
574
+ mark,
575
+ }));
576
+ }
577
+ }
578
+ }
579
+ return lines;
580
+ }
581
+ /** Single-quote a title for a copy-pasteable drill-down command. */
582
+ export function quoteTitle(title) {
583
+ return `'${title.replace(/'/g, "'\\''")}'`;
584
+ }
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
+ }
610
+ /** Muted per-block truncation line: `… N more — \`drill-down\``. */
611
+ function blockMoreLine(total, shown, drill) {
612
+ return dim(` … ${total - shown} more${drill === null ? "" : ` — \`${drill}\``}`);
613
+ }
614
+ /**
615
+ * Type-aware variant for blocks that mix project rows and to-dos (someday's
616
+ * loose/area blocks): the hidden remainder is split by type — `… 5 more
617
+ * projects, 14 more to-dos` — omitting a type with nothing hidden and
618
+ * pluralizing per count. The counts come from the block metadata (projects
619
+ * always list first, so the hidden split is derivable from `totalProjects`).
620
+ */
621
+ function mixedMoreLine(projects, todos, drill) {
622
+ const parts = [
623
+ ...(projects > 0 ? [`${projects} more project${projects === 1 ? "" : "s"}`] : []),
624
+ ...(todos > 0 ? [`${todos} more to-do${todos === 1 ? "" : "s"}`] : []),
625
+ ];
626
+ return dim(` … ${parts.join(", ")}${drill === null ? "" : ` — \`${drill}\``}`);
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
+ }
643
+ /**
644
+ * Muted bottom line for a truncated grouped view: `── more per group — see
645
+ * more: \`<base> <bigger flags>\` · \`<base> --all\` ──`, where the bigger-flags
646
+ * command doubles exactly the caps that actually truncated.
647
+ */
648
+ function groupedBottomLine(base, escalations, allBase = base) {
649
+ const seeMore = escalations.length > 0 ? `see more: \`${base} ${escalations.join(" ")}\` · ` : "";
650
+ return dim(`── more per group — ${seeMore}\`${allBase} --all\` ──`);
651
+ }
652
+ /**
653
+ * The anytime preview: the FULL block skeleton — every area header and every
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.
661
+ */
662
+ export function renderAnytimePreview(sections, grouped, limits, base) {
663
+ const all = sections.flatMap((s) => s.items);
664
+ if (all.length === 0)
665
+ return ["(empty)"];
666
+ const w = uuidDisplayWidth(all);
667
+ const lines = [];
668
+ let areaHit = false;
669
+ let projectHit = false;
670
+ const blank = () => {
671
+ if (lines.length > 0 && lines.at(-1) !== "")
672
+ lines.push("");
673
+ };
674
+ for (const section of sections) {
675
+ if (section.area !== null) {
676
+ blank();
677
+ lines.push(`${bold("──")} ${areaMark()} ${bold(`${section.area.title} ──`)}`);
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.
681
+ const { direct, projects } = splitSectionBlocks(section);
682
+ const suppressArea = section.area?.uuid ?? null;
683
+ const areaBlock = areaBlockFor(grouped, section.area);
684
+ for (const item of direct) {
685
+ lines.push(formatItem(item, w, { suppressArea, mark: todayMark(item) }));
686
+ }
687
+ if (areaBlock !== undefined && areaBlock.total > areaBlock.shown) {
688
+ areaHit = true;
689
+ const drill = section.area === null ? null : `things area show ${quoteTitle(section.area.title)}`;
690
+ lines.push(blockMoreLine(areaBlock.total, areaBlock.shown, drill));
691
+ }
692
+ for (const { project, items: children } of projects) {
693
+ blank();
694
+ lines.push(formatItem(project, w, { projectTitle: true, suppressArea, mark: todayMark(project) }));
695
+ for (const item of children) {
696
+ lines.push(formatItem(item, w, {
697
+ suppressProject: project.uuid,
698
+ suppressArea,
699
+ mark: todayMark(item),
700
+ }));
701
+ }
702
+ const projectBlock = areaBlock?.children?.find((c) => c.kind === "project" && c.ref === project.uuid);
703
+ if (projectBlock !== undefined && projectBlock.total > projectBlock.shown) {
704
+ projectHit = true;
705
+ lines.push(blockMoreLine(projectBlock.total, projectBlock.shown, `things project show ${quoteTitle(project.title)}`));
706
+ }
707
+ }
708
+ }
709
+ if (areaHit || projectHit) {
710
+ const escalations = [
711
+ ...(areaHit && limits.area !== null ? [`--area-limit ${limits.area * 2}`] : []),
712
+ ...(projectHit && limits.project !== null ? [`--project-limit ${limits.project * 2}`] : []),
713
+ ];
714
+ lines.push("", groupedBottomLine(base, escalations));
715
+ }
716
+ return lines;
717
+ }
718
+ /**
719
+ * The someday preview, mirroring the GUI (side-by-side, 2026-07-12): inside
720
+ * each group the project rows render as PLAIN items — `(~)` circle, count
721
+ * chip, no header styling, no surrounding blank lines — listed before the
722
+ * direct to-dos; `limits.area` caps each group's combined list. With
723
+ * `showActive` (the --show-active-project-items toggle) the someday to-dos
724
+ * living inside active projects append as a separate trailing
725
+ * `── From active projects ──` section — a flat run of project-header blocks
726
+ * (no area grouping), each capped at `limits.project` (null = every item).
727
+ * When the toggle is off and such items exist, a muted bottom hint counts
728
+ * them and names the flag.
729
+ */
730
+ export function renderSomedayPreview(sections, grouped, limits, base, showActive, hiddenActiveItems) {
731
+ const all = sections.flatMap((s) => s.items);
732
+ const lines = [];
733
+ let areaHit = false;
734
+ let projectHit = false;
735
+ const blank = () => {
736
+ if (lines.length > 0 && lines.at(-1) !== "")
737
+ lines.push("");
738
+ };
739
+ if (all.length === 0) {
740
+ lines.push("(empty)");
741
+ }
742
+ else {
743
+ const w = uuidDisplayWidth(all);
744
+ const trailing = [];
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.
748
+ const { own, children } = partitionSomedaySection(section);
749
+ trailing.push(...children);
750
+ if (section.area !== null) {
751
+ blank();
752
+ lines.push(`${bold("──")} ${areaMark()} ${bold(`${section.area.title} ──`)}`);
753
+ }
754
+ const suppressArea = section.area?.uuid ?? null;
755
+ for (const item of own)
756
+ lines.push(formatItem(item, w, { suppressArea }));
757
+ const ownBlock = areaBlockFor(grouped, section.area);
758
+ if (ownBlock !== undefined && ownBlock.total > ownBlock.shown) {
759
+ areaHit = true;
760
+ const drill = section.area === null ? null : `things area show ${quoteTitle(section.area.title)}`;
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));
767
+ }
768
+ }
769
+ if (trailing.length > 0) {
770
+ blank();
771
+ lines.push(bold("── From active projects ──"));
772
+ for (const group of trailing) {
773
+ blank();
774
+ lines.push(`${dim(uuidCol(group.project.uuid, w))} ${underline(projectTitleAccent(group.project.title))}`);
775
+ for (const item of group.items) {
776
+ lines.push(formatItem(item, w, { suppressProject: group.project.uuid }));
777
+ }
778
+ const projectBlock = projectBlockFor(grouped, group.project.uuid);
779
+ if (projectBlock !== undefined && projectBlock.total > projectBlock.shown) {
780
+ projectHit = true;
781
+ lines.push(blockMoreLine(projectBlock.total, projectBlock.shown, `things project show ${quoteTitle(group.project.title)}`));
782
+ }
783
+ }
784
+ }
785
+ }
786
+ if (areaHit || projectHit) {
787
+ const escalations = [
788
+ ...(areaHit && limits.area !== null ? [`--area-limit ${limits.area * 2}`] : []),
789
+ ...(projectHit && limits.project !== null
790
+ ? [`--show-active-project-items ${limits.project * 2}`]
791
+ : []),
792
+ ];
793
+ // The bare flag keeps the active-projects section visible under --all.
794
+ const allBase = showActive ? `${base} --show-active-project-items` : base;
795
+ lines.push("", groupedBottomLine(base, escalations, allBase));
796
+ }
797
+ if (!showActive && hiddenActiveItems > 0) {
798
+ blank();
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` }]));
803
+ }
804
+ return lines;
805
+ }
806
+ //# sourceMappingURL=render.js.map