@sous-io/sous 0.1.0 → 0.2.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 (206) hide show
  1. package/README.md +121 -35
  2. package/bin/run.js +10 -1
  3. package/docs/markdown/README.md +27 -0
  4. package/docs/markdown/_sidebar.md +18 -0
  5. package/docs/markdown/commands.md +308 -0
  6. package/docs/markdown/config-discovery.md +74 -0
  7. package/docs/markdown/config-inspection.md +69 -0
  8. package/docs/markdown/config-layers.md +92 -0
  9. package/docs/markdown/config-variables.md +79 -0
  10. package/docs/markdown/configuration.md +71 -0
  11. package/docs/markdown/design-principles.md +59 -0
  12. package/docs/markdown/repositories-authoring.md +408 -0
  13. package/docs/markdown/repositories-consuming.md +580 -0
  14. package/docs/markdown/repositories-file-formats.md +1084 -0
  15. package/docs/markdown/repositories-variables.md +387 -0
  16. package/docs/markdown/repositories.md +303 -0
  17. package/docs/markdown/skill-categories.md +58 -0
  18. package/package.json +73 -9
  19. package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/SKILL.tpl.md +20 -20
  20. package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/examples/about-something.md +2 -2
  21. package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/examples/do-something.md +1 -1
  22. package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/references/advanced-patterns.md +6 -6
  23. package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/references/commands.md +5 -5
  24. package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/references/frontmatter.md +3 -3
  25. package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-liquid-templates/SKILL.tpl.md +40 -25
  26. package/recipes/core/sous-skills/skills/about-sous/SKILL.tpl.md +70 -0
  27. package/recipes/core/sous-skills/skills/about-sous-configuration/SKILL.tpl.md +75 -0
  28. package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/create-skill/SKILL.tpl.md +8 -9
  29. package/recipes/core/sous-skills/sous.recipe.yaml +45 -0
  30. package/sous.config.schema.json +337 -0
  31. package/src/base-command.ts +220 -67
  32. package/src/commands/build.ts +150 -73
  33. package/src/commands/clear.ts +23 -15
  34. package/src/commands/compile.ts +74 -16
  35. package/src/commands/config/get.ts +110 -0
  36. package/src/commands/config/show.ts +32 -0
  37. package/src/commands/config/validate.ts +53 -0
  38. package/src/commands/help.ts +46 -0
  39. package/src/commands/launch.ts +36 -14
  40. package/src/commands/lock/rebuild.ts +241 -0
  41. package/src/commands/lock/show.ts +115 -0
  42. package/src/commands/namespace/list.ts +117 -0
  43. package/src/commands/namespace/show.ts +110 -0
  44. package/src/commands/prune.ts +3 -11
  45. package/src/commands/recipe/list.ts +95 -0
  46. package/src/commands/recipe/show.ts +301 -0
  47. package/src/commands/repo/add.ts +145 -0
  48. package/src/commands/repo/gc.ts +172 -0
  49. package/src/commands/repo/init.ts +136 -0
  50. package/src/commands/repo/link.ts +500 -0
  51. package/src/commands/repo/list.ts +179 -0
  52. package/src/commands/repo/release.ts +619 -0
  53. package/src/commands/repo/remove.ts +193 -0
  54. package/src/commands/repo/search.ts +189 -0
  55. package/src/commands/repo/submit.ts +133 -0
  56. package/src/commands/repo/unlink.ts +147 -0
  57. package/src/commands/subscription/add.ts +285 -0
  58. package/src/commands/subscription/list.ts +129 -0
  59. package/src/commands/subscription/remove.ts +181 -0
  60. package/src/commands/vars/ask.ts +374 -0
  61. package/src/commands/vars/index.ts +79 -0
  62. package/src/commands/vars/list.ts +67 -0
  63. package/src/commands/vars/show.ts +77 -0
  64. package/src/config-command.ts +30 -0
  65. package/src/lib/build-service.ts +206 -54
  66. package/src/lib/config-discovery.ts +220 -27
  67. package/src/lib/config-inspect.ts +145 -0
  68. package/src/lib/config-kernel.mjs +377 -0
  69. package/src/lib/config-schema.ts +361 -0
  70. package/src/lib/env-file.ts +328 -0
  71. package/src/lib/env-local.ts +18 -1
  72. package/src/lib/errors.ts +32 -0
  73. package/src/lib/include-resolver.ts +108 -15
  74. package/src/lib/interactive.ts +165 -0
  75. package/src/lib/markdown-compiler.ts +118 -37
  76. package/src/lib/package-info.ts +25 -0
  77. package/src/lib/pid-service.ts +32 -21
  78. package/src/lib/refs/find.ts +589 -0
  79. package/src/lib/refs/index.ts +12 -0
  80. package/src/lib/refs/pick.ts +147 -0
  81. package/src/lib/refs/scopes.ts +61 -0
  82. package/src/lib/repos/catalog-display.ts +116 -0
  83. package/src/lib/repos/catalog-inputs.ts +160 -0
  84. package/src/lib/repos/catalog.ts +722 -0
  85. package/src/lib/repos/core-recipe.ts +105 -0
  86. package/src/lib/repos/defaults.ts +175 -0
  87. package/src/lib/repos/formats/common.ts +389 -0
  88. package/src/lib/repos/formats/index-file.ts +215 -0
  89. package/src/lib/repos/formats/links-map.ts +96 -0
  90. package/src/lib/repos/formats/lockfile.ts +167 -0
  91. package/src/lib/repos/formats/patterns.ts +57 -0
  92. package/src/lib/repos/formats/recipe-manifest.ts +395 -0
  93. package/src/lib/repos/formats/repo-manifest.ts +88 -0
  94. package/src/lib/repos/formats/store-entry.ts +84 -0
  95. package/src/lib/repos/freshness.ts +208 -0
  96. package/src/lib/repos/git-clone.ts +312 -0
  97. package/src/lib/repos/identity.ts +89 -0
  98. package/src/lib/repos/index.ts +58 -0
  99. package/src/lib/repos/links.ts +353 -0
  100. package/src/lib/repos/load-manifest.ts +236 -0
  101. package/src/lib/repos/lock-service.ts +453 -0
  102. package/src/lib/repos/locked-namespace-resolver.ts +90 -0
  103. package/src/lib/repos/locked-recipes.ts +254 -0
  104. package/src/lib/repos/managed-layer.ts +422 -0
  105. package/src/lib/repos/namespace-resolver.ts +370 -0
  106. package/src/lib/repos/providers/base.ts +206 -0
  107. package/src/lib/repos/providers/git.ts +233 -0
  108. package/src/lib/repos/providers/github.ts +294 -0
  109. package/src/lib/repos/providers/gitlab.ts +263 -0
  110. package/src/lib/repos/providers/http.ts +102 -0
  111. package/src/lib/repos/providers/index-cache.ts +382 -0
  112. package/src/lib/repos/providers/index.ts +106 -0
  113. package/src/lib/repos/providers/local.ts +391 -0
  114. package/src/lib/repos/providers/provider.ts +401 -0
  115. package/src/lib/repos/recipe-config-layers.ts +287 -0
  116. package/src/lib/repos/recipe-targets.ts +223 -0
  117. package/src/lib/repos/ref-search.ts +46 -0
  118. package/src/lib/repos/ref.ts +513 -0
  119. package/src/lib/repos/reference-report.ts +122 -0
  120. package/src/lib/repos/release/bump.ts +161 -0
  121. package/src/lib/repos/release/git-state.ts +305 -0
  122. package/src/lib/repos/release/index-builder.ts +635 -0
  123. package/src/lib/repos/release/index.ts +16 -0
  124. package/src/lib/repos/release/plan.ts +512 -0
  125. package/src/lib/repos/release/submit-service.ts +496 -0
  126. package/src/lib/repos/release/tags.ts +243 -0
  127. package/src/lib/repos/release/validate.ts +463 -0
  128. package/src/lib/repos/resolver.ts +789 -0
  129. package/src/lib/repos/scaffold/index.ts +238 -0
  130. package/src/lib/repos/scaffold/templates.ts +413 -0
  131. package/src/lib/repos/seed.ts +414 -0
  132. package/src/lib/repos/store/contract.ts +64 -0
  133. package/src/lib/repos/store/hash.ts +114 -0
  134. package/src/lib/repos/store/recipe-store.ts +599 -0
  135. package/src/lib/repos/store/settings.ts +58 -0
  136. package/src/lib/repos/subscription-service.ts +2678 -0
  137. package/src/lib/repos/trust.ts +447 -0
  138. package/src/lib/settings.ts +546 -189
  139. package/src/lib/sous-home.ts +104 -0
  140. package/src/lib/state.ts +52 -20
  141. package/src/lib/vars/ask.ts +1152 -0
  142. package/src/lib/vars/definition-source.ts +252 -0
  143. package/src/lib/vars/display.ts +233 -0
  144. package/src/lib/vars/index.ts +18 -0
  145. package/src/lib/vars/ladder.ts +282 -0
  146. package/src/lib/vars/mappings.ts +265 -0
  147. package/src/lib/vars/names.ts +94 -0
  148. package/src/lib/vars/preanswers.ts +395 -0
  149. package/src/lib/vars/question-plan.ts +218 -0
  150. package/src/lib/vars/report.ts +228 -0
  151. package/src/lib/vars/safe-regex.ts +235 -0
  152. package/src/lib/vars/validate.ts +312 -0
  153. package/src/lib/watch-loop.ts +148 -0
  154. package/src/templating/init-liquid-engine.ts +58 -16
  155. package/src/utils/choice-prompt.ts +143 -0
  156. package/src/utils/command-errors.ts +186 -0
  157. package/src/utils/command-help.ts +45 -0
  158. package/src/utils/confirm-prompt.ts +110 -0
  159. package/src/utils/flags.ts +153 -0
  160. package/src/utils/formatting.ts +540 -55
  161. package/src/utils/prompts.ts +35 -1
  162. package/src/utils/sous-directory.ts +245 -0
  163. package/src/utils/table.ts +603 -0
  164. package/src/utils/value-prompt.ts +119 -0
  165. package/bin/xcv +0 -5
  166. package/shared-prompts/_partials/resume-task.md +0 -51
  167. package/shared-prompts/_partials/sub-agent-delegation.md +0 -32
  168. package/shared-prompts/_partials/update-task-file.md +0 -52
  169. package/shared-prompts/memories/automated-browser-tasks/INDEX.tpl.md +0 -52
  170. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/SKILL.tpl.md +0 -102
  171. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/examples/auth-failure-handling.mjs +0 -81
  172. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/examples/chained-workflow.mjs +0 -126
  173. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/examples/simple-fetch.mjs +0 -92
  174. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/architecture.md +0 -61
  175. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/auth-and-sessions.md +0 -65
  176. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/ctx-api.md +0 -96
  177. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/installation.md +0 -104
  178. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/script-conventions.md +0 -243
  179. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/chrome-state.mjs +0 -148
  180. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/debug.mjs +0 -383
  181. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/debug.spec.mjs +0 -267
  182. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/eslint.config.mjs +0 -56
  183. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/harness.mjs +0 -169
  184. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/keyring.mjs +0 -59
  185. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/logger.mjs +0 -25
  186. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/params.mjs +0 -140
  187. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/run.mjs +0 -140
  188. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/settings.tpl.mjs +0 -1
  189. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/utils.mjs +0 -185
  190. package/shared-prompts/skills/automated-browser-tasks/create-automated-browser-task/SKILL.tpl.md +0 -52
  191. package/shared-prompts/skills/automated-browser-tasks/running-automated-browser-tasks/SKILL.tpl.md +0 -59
  192. package/shared-prompts/skills/automated-browser-tasks/update-automated-browser-task/SKILL.tpl.md +0 -47
  193. package/shared-prompts/skills/control-flow/approve/SKILL.tpl.md +0 -26
  194. package/shared-prompts/skills/control-flow/opine/SKILL.tpl.md +0 -58
  195. package/shared-prompts/skills/control-flow/repeat/SKILL.tpl.md +0 -27
  196. package/shared-prompts/skills/control-flow/research/SKILL.tpl.md +0 -34
  197. package/shared-prompts/skills/sous-skills/about-sous/SKILL.tpl.md +0 -51
  198. package/shared-prompts/skills/task-files/about-task-files/SKILL.tpl.md +0 -122
  199. package/shared-prompts/skills/task-files/continue-task-in-new-branch/SKILL.tpl.md +0 -80
  200. package/shared-prompts/skills/task-files/go/SKILL.tpl.md +0 -14
  201. package/shared-prompts/skills/task-files/resume-task/SKILL.tpl.md +0 -13
  202. package/shared-prompts/skills/task-files/start-task/SKILL.tpl.md +0 -93
  203. package/shared-prompts/skills/task-files/update/SKILL.tpl.md +0 -14
  204. package/shared-prompts/skills/task-files/update-task-file/SKILL.tpl.md +0 -13
  205. /package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/references/substitutions.md +0 -0
  206. /package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-liquid-templates/references/liquid-filters.md +0 -0
@@ -0,0 +1,603 @@
1
+ /**
2
+ * The responsive table renderer every sous listing prints through.
3
+ *
4
+ * A caller describes its columns once (what they hold, which ones matter, which
5
+ * one may soak up leftover room) and the renderer fits them to the terminal it
6
+ * actually has: columns shrink toward their minimums, long cells wrap or are
7
+ * truncated on the side that keeps the useful half, and the least important
8
+ * columns step aside entirely when the window is too narrow to hold everything.
9
+ *
10
+ * The rendered lines come back without indentation, so a caller that indents its
11
+ * output keeps doing that itself; tell the renderer how far it indents with the
12
+ * `indent` option so the indentation comes out of the width budget.
13
+ */
14
+
15
+ import { color } from "@oclif/color";
16
+ import {
17
+ DEFAULT_WRAP_COLUMNS,
18
+ displayWidth,
19
+ terminalColumns,
20
+ wrapText,
21
+ } from "./formatting.js";
22
+
23
+ /** What a column holds, which decides how it aligns and where it truncates. */
24
+ export type ColumnKind = "text" | "path" | "url" | "number";
25
+
26
+ /** What a column does with a cell too long for it. */
27
+ export type ColumnOverflow = "wrap" | "truncate";
28
+
29
+ /** Which end of a cell is given up when it is truncated. */
30
+ export type TruncateSide = "start" | "middle" | "end";
31
+
32
+ /** Where a cell sits inside its column. */
33
+ export type ColumnAlign = "left" | "right" | "center";
34
+
35
+ /** How willingly a column is hidden when the terminal is too narrow. */
36
+ export type ColumnPriority = "low" | "medium" | "high";
37
+
38
+ /** One column of a table. */
39
+ export interface TableColumn {
40
+ /** The property each row is read from. */
41
+ key: string;
42
+ /** The heading shown above the column. */
43
+ header: string;
44
+ /** Whether a long cell wraps onto more lines or is cut. Defaults to wrapping. */
45
+ overflow?: ColumnOverflow;
46
+ /** Which end a cut takes from. Defaults by kind: the middle of a path or a URL, the end of anything else. */
47
+ truncate?: TruncateSide;
48
+ /** What the column holds. Defaults to plain text. */
49
+ kind?: ColumnKind;
50
+ /** Where a cell sits in the column. Defaults to the right for a number, the left for everything else. */
51
+ align?: ColumnAlign;
52
+ /** This column's share of any width left over once every column has its natural width. Defaults to none. */
53
+ flex?: number;
54
+ /** The narrowest this column is ever squeezed to. Defaults to the width of its heading. */
55
+ minWidth?: number;
56
+ /** How willingly the column is hidden. A high column is never hidden; a low one goes first. Defaults to high. */
57
+ priority?: ColumnPriority;
58
+ }
59
+
60
+ /** Everything about a table that is not a column. */
61
+ export interface TableOptions<Row extends Record<string, unknown>> {
62
+ /**
63
+ * The number of columns to lay the table out in. Defaults to the terminal's
64
+ * width. Passing a width also allows columns to be hidden, because a caller
65
+ * that names a width is describing a window it knows the size of.
66
+ */
67
+ width?: number;
68
+ /** How far the caller indents the table, taken out of the width budget. Defaults to none. */
69
+ indent?: number;
70
+ /** The spaces left between two columns. Defaults to two. */
71
+ gap?: number;
72
+ /** Whether to print the heading row and the rule under it. Defaults to true. */
73
+ header?: boolean;
74
+ /** The stream the width is read from. Defaults to stdout. */
75
+ stream?: { columns?: number; isTTY?: boolean };
76
+ /**
77
+ * An extra line printed under a row, already colored by the caller; return
78
+ * nothing to print none. This is how a command shows a detail that does not
79
+ * deserve a column of its own.
80
+ */
81
+ rowNote?: (row: Row, index: number) => string | undefined;
82
+ }
83
+
84
+ /** The color escape sequences sous emits, as a source string the cell slicer reuses. */
85
+ const SGR_SOURCE = "\\u001B\\[[0-9;]*m";
86
+
87
+ /** The sequence that puts the terminal back to its plain colors. */
88
+ const RESET = "\u001B[0m";
89
+
90
+ /** How much of a column a cut takes out. */
91
+ const ELLIPSIS = "…";
92
+
93
+ /** Which priority yields first when a column has to go. */
94
+ const PRIORITY_RANK: Record<ColumnPriority, number> = { low: 0, medium: 1, high: 2 };
95
+
96
+ /**
97
+ * Lays a table out to fit the width it was given and returns the lines to
98
+ * print, colored for a terminal and without indentation or trailing spaces.
99
+ *
100
+ * @param columns - The columns, in the order they are shown.
101
+ * @param rows - One object per row; each column reads the property named by its key.
102
+ * @param options - The width to fit, the gap, and whether to show the heading.
103
+ * @returns The rendered lines, plus a closing note when columns had to be hidden.
104
+ *
105
+ * @example
106
+ * renderTable([{ key: "name", header: "Name" }], [{ name: "workflow" }]);
107
+ * // -> ["Name", "----", "workflow"]
108
+ */
109
+ export function renderTable<Row extends Record<string, unknown>>(
110
+ columns: TableColumn[],
111
+ rows: Row[],
112
+ options: TableOptions<Row> = {}
113
+ ): string[] {
114
+ if (columns.length === 0) return [];
115
+
116
+ const gap = options.gap ?? 2;
117
+ const indentWidth = options.indent ?? 0;
118
+ const showHeader = options.header ?? true;
119
+ const stream = options.stream ?? process.stdout;
120
+
121
+ // A width the caller named describes a window of known size, so columns may be
122
+ // hidden to fit it. Without one, only a real terminal is measured; piped and
123
+ // recorded output always gets the full table at the default width, so a script
124
+ // reading it never loses a column to the size of somebody's window.
125
+ const measured =
126
+ options.width ?? (stream.isTTY === true ? terminalColumns(stream) : DEFAULT_WRAP_COLUMNS);
127
+ const mayHide = options.width !== undefined || stream.isTTY === true;
128
+ const budget = Math.max(1, Math.floor(measured) - indentWidth);
129
+
130
+ const { widths, visible, hidden } = fitColumns(columns, rows, {
131
+ budget,
132
+ gap,
133
+ showHeader,
134
+ mayHide,
135
+ });
136
+
137
+ const lines: string[] = [];
138
+
139
+ if (showHeader) {
140
+ const headers = visible.map((column, index) =>
141
+ padTo(truncateToWidth(column.header, widths[index]!, "end"), widths[index]!, alignOf(column))
142
+ );
143
+ lines.push(color.cyan(joinCells(headers, gap)));
144
+ lines.push(
145
+ color.gray(joinCells(widths.map((width) => "-".repeat(width)), gap))
146
+ );
147
+ }
148
+
149
+ rows.forEach((row, rowIndex) => {
150
+ for (const line of renderRow(row, visible, widths, gap)) lines.push(line);
151
+ const note = options.rowNote?.(row, rowIndex);
152
+ if (note !== undefined && note !== "") lines.push(note);
153
+ });
154
+
155
+ if (hidden.length > 0) {
156
+ const names = hidden.map((column) => column.header).join(", ");
157
+ lines.push(
158
+ `Hidden at this width: ${names}. Widen the terminal to see ` +
159
+ `${hidden.length === 1 ? "it" : "them"}.`
160
+ );
161
+ }
162
+
163
+ return lines;
164
+ }
165
+
166
+ // --- Laying the columns out ----------------------------------------------------------------------
167
+
168
+ /** What the fitting pass needs to know about the window it is fitting to. */
169
+ interface FitInput {
170
+ budget: number;
171
+ gap: number;
172
+ showHeader: boolean;
173
+ mayHide: boolean;
174
+ }
175
+
176
+ /** The outcome of the fitting pass: what is shown, how wide, and what was left out. */
177
+ interface FitResult {
178
+ widths: number[];
179
+ visible: TableColumn[];
180
+ hidden: TableColumn[];
181
+ }
182
+
183
+ /**
184
+ * Decides how wide every column is, and which columns do not fit at all.
185
+ *
186
+ * Each column starts at its natural width (the widest cell it holds, and its
187
+ * heading when one is shown). If the total is too wide, the least important
188
+ * columns give width back first, in proportion to how far each sits above its
189
+ * own minimum, and the more important ones only once those run out. If the minimums
190
+ * alone still do not fit, the least important column is hidden and the whole
191
+ * pass runs again. When nothing may be hidden and the minimums still do not fit,
192
+ * every column is squeezed toward a single character rather than letting the
193
+ * table run off the edge of the window.
194
+ *
195
+ * @param columns - Every column the caller asked for.
196
+ * @param rows - The rows, measured to find each column's natural width.
197
+ * @param input - The width budget, the gap, and whether columns may be hidden.
198
+ */
199
+ function fitColumns<Row extends Record<string, unknown>>(
200
+ columns: TableColumn[],
201
+ rows: Row[],
202
+ input: FitInput
203
+ ): FitResult {
204
+ let visible = [...columns];
205
+
206
+ for (;;) {
207
+ const floors = visible.map((column) => minimumWidth(column, input.showHeader));
208
+ const targets = visible.map((column, index) =>
209
+ Math.max(naturalWidth(column, rows, input.showHeader), floors[index]!)
210
+ );
211
+ const flexes = visible.map((column) => Math.max(0, column.flex ?? 0));
212
+ const ranks = visible.map((column) => PRIORITY_RANK[column.priority ?? "high"]);
213
+ const cellBudget = Math.max(
214
+ visible.length,
215
+ input.budget - input.gap * (visible.length - 1)
216
+ );
217
+
218
+ const fitted = distribute(targets, floors, flexes, ranks, cellBudget);
219
+ if (fitted.shortBy === 0) {
220
+ return {
221
+ widths: fitted.widths,
222
+ visible,
223
+ hidden: columns.filter((column) => !visible.includes(column)),
224
+ };
225
+ }
226
+
227
+ const victim = hideableColumn(visible);
228
+ if (!input.mayHide || victim === undefined) {
229
+ // Nothing may step aside, so everything is squeezed instead: a cramped
230
+ // table still reads, a table wider than the window does not.
231
+ const squeezed = distribute(
232
+ targets,
233
+ targets.map(() => 1),
234
+ flexes,
235
+ ranks,
236
+ cellBudget
237
+ );
238
+ return {
239
+ widths: squeezed.widths,
240
+ visible,
241
+ hidden: columns.filter((column) => !visible.includes(column)),
242
+ };
243
+ }
244
+
245
+ visible = visible.filter((column) => column !== victim);
246
+ if (visible.length === 0) return { widths: [], visible, hidden: columns };
247
+ }
248
+ }
249
+
250
+ /**
251
+ * Shares the width budget out among the columns: slack goes to the columns that
252
+ * asked to flex, and a shortfall is taken from the columns that matter least
253
+ * first, spread among them in proportion to how far each sits above its own
254
+ * floor. A high column only gives width up once every less important column has
255
+ * given all of its own, which is what keeps an identifier from being cut in
256
+ * half while a sentence beside it keeps its full width.
257
+ *
258
+ * @param targets - The width each column would like, its minimum included.
259
+ * @param floors - The narrowest each column may become.
260
+ * @param flexes - Each column's share of any leftover width.
261
+ * @param ranks - Each column's importance, lowest first.
262
+ * @param budget - The columns' share of the window, gaps already taken out.
263
+ * @returns The widths, and how much width could not be found (zero when it fit).
264
+ */
265
+ function distribute(
266
+ targets: number[],
267
+ floors: number[],
268
+ flexes: number[],
269
+ ranks: number[],
270
+ budget: number
271
+ ): { widths: number[]; shortBy: number } {
272
+ const widths = [...targets];
273
+ const total = sum(widths);
274
+
275
+ if (total <= budget) {
276
+ grow(widths, flexes, budget - total);
277
+ return { widths, shortBy: 0 };
278
+ }
279
+
280
+ let deficit = total - budget;
281
+
282
+ for (const band of [PRIORITY_RANK.low, PRIORITY_RANK.medium, PRIORITY_RANK.high]) {
283
+ if (deficit <= 0) break;
284
+ const members: number[] = [];
285
+ ranks.forEach((rank, index) => {
286
+ if (rank === band) members.push(index);
287
+ });
288
+ deficit -= takeFrom(widths, floors, members, deficit);
289
+ }
290
+
291
+ return { widths, shortBy: Math.max(0, deficit) };
292
+ }
293
+
294
+ /**
295
+ * Takes width off one band of columns, spread in proportion to how much room
296
+ * each has above its floor, and never below that floor.
297
+ *
298
+ * @param widths - The widths so far, changed in place.
299
+ * @param floors - The narrowest each column may become.
300
+ * @param members - Which columns this band holds.
301
+ * @param wanted - How much width to find.
302
+ * @returns How much was actually found.
303
+ */
304
+ function takeFrom(
305
+ widths: number[],
306
+ floors: number[],
307
+ members: number[],
308
+ wanted: number
309
+ ): number {
310
+ const room = members.map((index) => widths[index]! - floors[index]!);
311
+ const totalRoom = sum(room);
312
+ if (totalRoom <= 0 || wanted <= 0) return 0;
313
+
314
+ const target = Math.min(wanted, totalRoom);
315
+ let taken = 0;
316
+
317
+ members.forEach((index, slot) => {
318
+ const cut = Math.min(room[slot]!, Math.floor((target * room[slot]!) / totalRoom));
319
+ widths[index] = widths[index]! - cut;
320
+ taken += cut;
321
+ });
322
+
323
+ // Rounding always leaves a little to find; take it one column at a time from
324
+ // whichever still has the most room above its floor.
325
+ while (taken < target) {
326
+ let best = -1;
327
+ let bestRoom = 0;
328
+ for (const index of members) {
329
+ const left = widths[index]! - floors[index]!;
330
+ if (left >= bestRoom && left > 0) {
331
+ best = index;
332
+ bestRoom = left;
333
+ }
334
+ }
335
+ if (best < 0) break;
336
+ widths[best] = widths[best]! - 1;
337
+ taken += 1;
338
+ }
339
+
340
+ return taken;
341
+ }
342
+
343
+ /**
344
+ * Hands leftover width to the columns that asked to flex, largest share first.
345
+ *
346
+ * @param widths - The widths so far, changed in place.
347
+ * @param flexes - Each column's share.
348
+ * @param slack - The width left to give away.
349
+ */
350
+ function grow(widths: number[], flexes: number[], slack: number): void {
351
+ const totalFlex = sum(flexes);
352
+ if (slack <= 0 || totalFlex <= 0) return;
353
+
354
+ let given = 0;
355
+ for (let index = 0; index < widths.length; index += 1) {
356
+ const share = Math.floor((slack * flexes[index]!) / totalFlex);
357
+ widths[index] = widths[index]! + share;
358
+ given += share;
359
+ }
360
+
361
+ let rest = slack - given;
362
+ for (let index = 0; index < widths.length && rest > 0; index += 1) {
363
+ if (flexes[index]! <= 0) continue;
364
+ widths[index] = widths[index]! + 1;
365
+ rest -= 1;
366
+ }
367
+ }
368
+
369
+ /**
370
+ * The column that steps aside first: the least important one, and the rightmost
371
+ * of those when several share the same importance. A column of high importance
372
+ * is never hidden.
373
+ *
374
+ * @param columns - The columns still being shown.
375
+ * @returns The column to hide, or undefined when every one of them must stay.
376
+ */
377
+ function hideableColumn(columns: TableColumn[]): TableColumn | undefined {
378
+ let victim: TableColumn | undefined;
379
+ let victimRank = Number.POSITIVE_INFINITY;
380
+
381
+ for (const column of columns) {
382
+ const rank = PRIORITY_RANK[column.priority ?? "high"];
383
+ if (rank >= PRIORITY_RANK.high) continue;
384
+ if (rank <= victimRank) {
385
+ victim = column;
386
+ victimRank = rank;
387
+ }
388
+ }
389
+
390
+ return victim;
391
+ }
392
+
393
+ /**
394
+ * How wide a column would like to be: its widest cell, and its heading when the
395
+ * table shows one.
396
+ *
397
+ * @param column - The column to measure.
398
+ * @param rows - Every row the table will print.
399
+ * @param showHeader - Whether the heading row counts toward the width.
400
+ */
401
+ function naturalWidth<Row extends Record<string, unknown>>(
402
+ column: TableColumn,
403
+ rows: Row[],
404
+ showHeader: boolean
405
+ ): number {
406
+ let widest = showHeader ? displayWidth(column.header) : 0;
407
+ for (const row of rows) widest = Math.max(widest, displayWidth(cellText(row, column)));
408
+ return Math.max(1, widest);
409
+ }
410
+
411
+ /**
412
+ * The narrowest a column may be squeezed to: what it asked for, or the width of
413
+ * its heading when it asked for nothing and a heading is shown.
414
+ *
415
+ * @param column - The column to measure.
416
+ * @param showHeader - Whether the heading row is being printed.
417
+ */
418
+ function minimumWidth(column: TableColumn, showHeader: boolean): number {
419
+ if (column.minWidth !== undefined) return Math.max(1, column.minWidth);
420
+ return showHeader ? Math.max(1, displayWidth(column.header)) : 1;
421
+ }
422
+
423
+ /** The text one row shows in one column. */
424
+ function cellText<Row extends Record<string, unknown>>(row: Row, column: TableColumn): string {
425
+ const value = row[column.key];
426
+ return value === undefined || value === null ? "" : String(value);
427
+ }
428
+
429
+ /** Where a column's cells sit, taking the kind's default when nothing was said. */
430
+ function alignOf(column: TableColumn): ColumnAlign {
431
+ if (column.align !== undefined) return column.align;
432
+ return column.kind === "number" ? "right" : "left";
433
+ }
434
+
435
+ /** Which end a column gives up when it is cut, taking the kind's default. */
436
+ function truncateSideOf(column: TableColumn): TruncateSide {
437
+ if (column.truncate !== undefined) return column.truncate;
438
+ return column.kind === "path" || column.kind === "url" ? "middle" : "end";
439
+ }
440
+
441
+ // --- Rendering -----------------------------------------------------------------------------------
442
+
443
+ /**
444
+ * Renders one row, which is as tall as its tallest wrapped cell; every other
445
+ * cell in the row is padded with blank lines under it.
446
+ *
447
+ * @param row - The row to render.
448
+ * @param columns - The columns being shown.
449
+ * @param widths - The width decided for each of them.
450
+ * @param gap - The spaces between two columns.
451
+ */
452
+ function renderRow<Row extends Record<string, unknown>>(
453
+ row: Row,
454
+ columns: TableColumn[],
455
+ widths: number[],
456
+ gap: number
457
+ ): string[] {
458
+ const cells = columns.map((column, index) => {
459
+ const width = widths[index]!;
460
+ const text = cellText(row, column);
461
+ return (column.overflow ?? "wrap") === "wrap"
462
+ ? wrapCell(text, width)
463
+ : [truncateToWidth(text, width, truncateSideOf(column))];
464
+ });
465
+
466
+ const height = Math.max(1, ...cells.map((lines) => lines.length));
467
+ const rendered: string[] = [];
468
+
469
+ for (let line = 0; line < height; line += 1) {
470
+ rendered.push(
471
+ joinCells(
472
+ columns.map((column, index) =>
473
+ padTo(cells[index]![line] ?? "", widths[index]!, alignOf(column))
474
+ ),
475
+ gap
476
+ )
477
+ );
478
+ }
479
+
480
+ return rendered;
481
+ }
482
+
483
+ /** Joins already-padded cells with the gap, leaving no trailing spaces behind. */
484
+ function joinCells(cells: string[], gap: number): string {
485
+ return cells.join(" ".repeat(gap)).trimEnd();
486
+ }
487
+
488
+ /**
489
+ * Wraps a cell to its column, breaking on spaces where it can and inside a word
490
+ * when the word alone is wider than the column.
491
+ *
492
+ * @param text - The cell's text.
493
+ * @param width - The column's width.
494
+ */
495
+ function wrapCell(text: string, width: number): string[] {
496
+ const lines: string[] = [];
497
+ // A table cell hangs nothing: the column itself is the indentation, so a
498
+ // wrapped cell starts at the column's own left edge.
499
+ for (const line of wrapText(text, width, { hangingIndent: 0 })) {
500
+ if (displayWidth(line) <= width) {
501
+ lines.push(line);
502
+ continue;
503
+ }
504
+ for (let start = 0; start < displayWidth(line); start += width) {
505
+ lines.push(sliceVisible(line, start, start + width));
506
+ }
507
+ }
508
+ return lines;
509
+ }
510
+
511
+ /**
512
+ * Cuts a cell down to a width, marking the cut with a single character so the
513
+ * reader can see that something was left out.
514
+ *
515
+ * @param text - The cell's text.
516
+ * @param width - The width to fit into.
517
+ * @param side - Which end of the text to give up.
518
+ */
519
+ function truncateToWidth(text: string, width: number, side: TruncateSide): string {
520
+ const visible = displayWidth(text);
521
+ if (visible <= width) return text;
522
+ if (width <= 1) return ELLIPSIS.slice(0, Math.max(0, width));
523
+
524
+ if (side === "start") return `${ELLIPSIS}${sliceVisible(text, visible - (width - 1), visible)}`;
525
+ if (side === "end") return `${sliceVisible(text, 0, width - 1)}${ELLIPSIS}`;
526
+
527
+ const head = Math.ceil((width - 1) / 2);
528
+ const tail = width - 1 - head;
529
+ return `${sliceVisible(text, 0, head)}${ELLIPSIS}${sliceVisible(text, visible - tail, visible)}`;
530
+ }
531
+
532
+ /**
533
+ * Pads a cell out to its column's width, in the direction the column aligns.
534
+ *
535
+ * @param text - The cell's text, already short enough for the column.
536
+ * @param width - The column's width.
537
+ * @param align - Where the text sits.
538
+ */
539
+ function padTo(text: string, width: number, align: ColumnAlign): string {
540
+ const room = width - displayWidth(text);
541
+ if (room <= 0) return text;
542
+ if (align === "right") return `${" ".repeat(room)}${text}`;
543
+ if (align === "center") {
544
+ const left = Math.floor(room / 2);
545
+ return `${" ".repeat(left)}${text}${" ".repeat(room - left)}`;
546
+ }
547
+ return `${text}${" ".repeat(room)}`;
548
+ }
549
+
550
+ /**
551
+ * Takes the visible characters between two columns of a string, carrying every
552
+ * color code along so a cut never leaves the terminal stuck in a color.
553
+ *
554
+ * @param text - The text to cut.
555
+ * @param start - The first visible column to keep.
556
+ * @param end - The column to stop before.
557
+ */
558
+ function sliceVisible(text: string, start: number, end: number): string {
559
+ const pattern = new RegExp(SGR_SOURCE, "g");
560
+ let out = "";
561
+ let visible = 0;
562
+ let cursor = 0;
563
+ let colored = false;
564
+
565
+ for (const match of text.matchAll(pattern)) {
566
+ const plain = text.slice(cursor, match.index);
567
+ const taken = takeVisible(plain, visible, start, end);
568
+ out += taken.text;
569
+ visible += plain.length;
570
+ out += match[0];
571
+ colored = true;
572
+ cursor = match.index + match[0].length;
573
+ }
574
+
575
+ const rest = text.slice(cursor);
576
+ out += takeVisible(rest, visible, start, end).text;
577
+
578
+ return colored ? `${out}${RESET}` : out;
579
+ }
580
+
581
+ /**
582
+ * The part of one uncolored run that falls inside the wanted columns.
583
+ *
584
+ * @param plain - The run of characters, with no color codes in it.
585
+ * @param offset - How many visible characters came before this run.
586
+ * @param start - The first visible column wanted.
587
+ * @param end - The column to stop before.
588
+ */
589
+ function takeVisible(
590
+ plain: string,
591
+ offset: number,
592
+ start: number,
593
+ end: number
594
+ ): { text: string } {
595
+ const from = Math.max(0, start - offset);
596
+ const to = Math.min(plain.length, end - offset);
597
+ return { text: from >= to ? "" : plain.slice(from, to) };
598
+ }
599
+
600
+ /** Adds a list of numbers up. */
601
+ function sum(values: number[]): number {
602
+ return values.reduce((total, value) => total + value, 0);
603
+ }