pi-skill-stacks 0.4.1
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.
- package/LICENSE +21 -0
- package/README.md +111 -0
- package/extensions/dialogs.ts +182 -0
- package/extensions/frame.ts +39 -0
- package/extensions/header.ts +262 -0
- package/extensions/index.ts +255 -0
- package/extensions/overlay.ts +524 -0
- package/package.json +44 -0
- package/src/core.ts +188 -0
- package/src/markdown.ts +229 -0
- package/src/overlay-model.ts +330 -0
- package/src/store.ts +258 -0
package/src/core.ts
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// Pure logic for skill stacks: merging stack definitions, deciding which
|
|
2
|
+
// skills get excluded, and diffing the settings.json `skills` array without
|
|
3
|
+
// touching entries the user wrote by hand.
|
|
4
|
+
//
|
|
5
|
+
// Mechanism (pi core, dist/core/package-manager.js `isEnabledByOverrides`):
|
|
6
|
+
// entries in the settings `skills` array that start with `!` exclude
|
|
7
|
+
// auto-discovered skills. Patterns are matched relative to the discovery
|
|
8
|
+
// baseDir, so `!skills/<dir>/SKILL.md` excludes `~/.agents/skills/<dir>/`
|
|
9
|
+
// (baseDir `~/.agents`) and `~/.pi/agent/skills/<dir>/` (baseDir
|
|
10
|
+
// `~/.pi/agent`) alike. Nested skills get their full relative path.
|
|
11
|
+
|
|
12
|
+
export type StackMap = Record<string, string[]>;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Skill name → SKILL.md path relative to the discovery baseDir, e.g.
|
|
16
|
+
* `skills/firecrawl-map/SKILL.md` or `skills/group/nested/SKILL.md`.
|
|
17
|
+
*/
|
|
18
|
+
export type DiscoveredSkills = ReadonlyMap<string, string>;
|
|
19
|
+
|
|
20
|
+
export interface SkillsSettingPlan {
|
|
21
|
+
/** The new value for the settings.json `skills` array. */
|
|
22
|
+
skills: string[];
|
|
23
|
+
/** The exclusion patterns this extension now owns (subset of `skills`). */
|
|
24
|
+
managed: string[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface StackStatus {
|
|
28
|
+
name: string;
|
|
29
|
+
/** Discovered members only; names that resolve to no skill dir are not counted. */
|
|
30
|
+
size: number;
|
|
31
|
+
enabled: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface StacksSummary {
|
|
35
|
+
stackCount: number;
|
|
36
|
+
offStacks: string[];
|
|
37
|
+
totalCount: number;
|
|
38
|
+
activeCount: number;
|
|
39
|
+
/** Per-stack status in definition order, for the header section body. */
|
|
40
|
+
stacks: StackStatus[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const sortNames = (names: Iterable<string>) =>
|
|
44
|
+
[...names].sort((a, b) => a.localeCompare(b));
|
|
45
|
+
|
|
46
|
+
/** Project stacks add to the global set; a same-named project stack replaces the global one. */
|
|
47
|
+
export const mergeStacks = (global: StackMap, project: StackMap | undefined): StackMap => ({
|
|
48
|
+
...global,
|
|
49
|
+
...(project ?? {}),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
/** Stack members that don't resolve to a discovered skill, keyed by stack. Empty stacks are omitted. */
|
|
53
|
+
export function missingSkillNames(stacks: StackMap, discovered: DiscoveredSkills) {
|
|
54
|
+
const missing: Record<string, string[]> = {};
|
|
55
|
+
for (const [stack, skills] of Object.entries(stacks)) {
|
|
56
|
+
const absent = skills.filter((name) => !discovered.has(name));
|
|
57
|
+
if (absent.length > 0) missing[stack] = absent;
|
|
58
|
+
}
|
|
59
|
+
return missing;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A skill is excluded iff it appears in at least one stack and no enabled
|
|
64
|
+
* stack contains it. Skills in no stack are never excluded.
|
|
65
|
+
*/
|
|
66
|
+
export function computeExcludedSkills(stacks: StackMap, disabledStacks: string[]) {
|
|
67
|
+
const disabled = new Set(disabledStacks);
|
|
68
|
+
const keptByEnabledStack = new Set<string>();
|
|
69
|
+
const inSomeStack = new Set<string>();
|
|
70
|
+
for (const [stack, skills] of Object.entries(stacks)) {
|
|
71
|
+
for (const name of skills) {
|
|
72
|
+
inSomeStack.add(name);
|
|
73
|
+
if (!disabled.has(stack)) keptByEnabledStack.add(name);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const excluded = new Set<string>();
|
|
77
|
+
for (const name of inSomeStack) {
|
|
78
|
+
if (!keptByEnabledStack.has(name)) excluded.add(name);
|
|
79
|
+
}
|
|
80
|
+
return excluded;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** `!skills/<...>/SKILL.md` for a baseDir-relative skill path. */
|
|
84
|
+
export const exclusionPatternFor = (relativeSkillPath: string) => `!${relativeSkillPath}`;
|
|
85
|
+
|
|
86
|
+
/** Exclusion patterns for the excluded skills we can resolve on disk, sorted for stable output. */
|
|
87
|
+
export function desiredExclusions(
|
|
88
|
+
stacks: StackMap,
|
|
89
|
+
disabledStacks: string[],
|
|
90
|
+
discovered: DiscoveredSkills,
|
|
91
|
+
) {
|
|
92
|
+
const patterns: string[] = [];
|
|
93
|
+
for (const name of computeExcludedSkills(stacks, disabledStacks)) {
|
|
94
|
+
const path = discovered.get(name);
|
|
95
|
+
if (path) patterns.push(exclusionPatternFor(path));
|
|
96
|
+
}
|
|
97
|
+
return patterns.sort((a, b) => a.localeCompare(b));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Split previously managed exclusions into the ones this persist may rewrite
|
|
102
|
+
* and the ones it must leave alone. An exclusion is out of scope when its
|
|
103
|
+
* skill is on disk but in none of the stacks visible here: it belongs to a
|
|
104
|
+
* stack we can't see (a project stack from another cwd), and "skills in no
|
|
105
|
+
* stack are never touched" applies. Patterns for skills that no longer exist
|
|
106
|
+
* stay in scope so they get cleaned up.
|
|
107
|
+
*/
|
|
108
|
+
export function scopeManagedExclusions(
|
|
109
|
+
managed: string[],
|
|
110
|
+
stacks: StackMap,
|
|
111
|
+
discovered: DiscoveredSkills,
|
|
112
|
+
) {
|
|
113
|
+
const visibleSkills = new Set(Object.values(stacks).flat());
|
|
114
|
+
const nameByPattern = new Map<string, string>();
|
|
115
|
+
for (const [name, path] of discovered) nameByPattern.set(exclusionPatternFor(path), name);
|
|
116
|
+
const inScope: string[] = [];
|
|
117
|
+
const retained: string[] = [];
|
|
118
|
+
for (const pattern of managed) {
|
|
119
|
+
const name = nameByPattern.get(pattern);
|
|
120
|
+
if (name !== undefined && !visibleSkills.has(name)) retained.push(pattern);
|
|
121
|
+
else inScope.push(pattern);
|
|
122
|
+
}
|
|
123
|
+
return { inScope, retained };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Diff the settings `skills` array: drop the exclusions we wrote previously,
|
|
128
|
+
* append the desired ones, and never touch or claim user-written entries.
|
|
129
|
+
*/
|
|
130
|
+
export function planSkillsSetting(
|
|
131
|
+
currentSkills: string[],
|
|
132
|
+
managedExclusions: string[],
|
|
133
|
+
desired: string[],
|
|
134
|
+
): SkillsSettingPlan {
|
|
135
|
+
const previouslyManaged = new Set(managedExclusions);
|
|
136
|
+
const kept = currentSkills.filter((entry) => !previouslyManaged.has(entry));
|
|
137
|
+
const keptSet = new Set(kept);
|
|
138
|
+
const skills = [...kept];
|
|
139
|
+
const managed: string[] = [];
|
|
140
|
+
for (const pattern of desired) {
|
|
141
|
+
if (keptSet.has(pattern)) continue; // user wrote it by hand; not ours
|
|
142
|
+
skills.push(pattern);
|
|
143
|
+
managed.push(pattern);
|
|
144
|
+
}
|
|
145
|
+
return { skills, managed };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The global `disabledStacks` list after a persist. Entries for stacks that
|
|
150
|
+
* were visible here are replaced by `visibleDisabled`; entries for stacks we
|
|
151
|
+
* can't see (project stacks from another cwd) are preserved so their on/off
|
|
152
|
+
* state survives toggling from elsewhere.
|
|
153
|
+
*/
|
|
154
|
+
export function nextDisabledStacks(
|
|
155
|
+
globalDisabled: string[],
|
|
156
|
+
visibleStackNames: Iterable<string>,
|
|
157
|
+
visibleDisabled: string[],
|
|
158
|
+
) {
|
|
159
|
+
const visible = new Set(visibleStackNames);
|
|
160
|
+
const unseen = globalDisabled.filter((name) => !visible.has(name));
|
|
161
|
+
return sortNames(new Set([...unseen, ...visibleDisabled]));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Counts for the compact `[Skills]` header line. Only discovered skills are counted. */
|
|
165
|
+
export function summarizeStacks(
|
|
166
|
+
stacks: StackMap,
|
|
167
|
+
disabledStacks: string[],
|
|
168
|
+
discovered: DiscoveredSkills,
|
|
169
|
+
): StacksSummary {
|
|
170
|
+
const stackNames = Object.keys(stacks);
|
|
171
|
+
const excluded = computeExcludedSkills(stacks, disabledStacks);
|
|
172
|
+
let activeCount = 0;
|
|
173
|
+
for (const name of discovered.keys()) {
|
|
174
|
+
if (!excluded.has(name)) activeCount += 1;
|
|
175
|
+
}
|
|
176
|
+
const disabled = new Set(disabledStacks);
|
|
177
|
+
return {
|
|
178
|
+
stackCount: stackNames.length,
|
|
179
|
+
offStacks: disabledStacks.filter((name) => stackNames.includes(name)),
|
|
180
|
+
totalCount: discovered.size,
|
|
181
|
+
activeCount,
|
|
182
|
+
stacks: Object.entries(stacks).map(([name, skills]) => ({
|
|
183
|
+
name,
|
|
184
|
+
size: skills.filter((skill) => discovered.has(skill)).length,
|
|
185
|
+
enabled: !disabled.has(name),
|
|
186
|
+
})),
|
|
187
|
+
};
|
|
188
|
+
}
|
package/src/markdown.ts
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
// Minimal markdown → styled-plain-text renderer for the overlay's skill
|
|
2
|
+
// viewer. Covers the subset skill files actually use — ATX headings, lists,
|
|
3
|
+
// fenced code, blockquotes, thematic breaks, frontmatter, and inline
|
|
4
|
+
// `code` / **bold** / [links] — and falls through to plain text for anything
|
|
5
|
+
// else. Output lines are ANSI-styled and wrapped ANSI-aware, so callers can
|
|
6
|
+
// pad/truncate them with the usual pi-tui helpers.
|
|
7
|
+
|
|
8
|
+
import type { ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
10
|
+
|
|
11
|
+
/** The slice of pi's Theme the renderer needs (OverlayTheme satisfies this). */
|
|
12
|
+
export interface MarkdownStyler {
|
|
13
|
+
fg(color: ThemeColor, text: string): string;
|
|
14
|
+
bold(text: string): string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const HEADING = /^(#{1,6})\s+(.*)$/;
|
|
18
|
+
const LIST = /^(\s*)(?:([-*+])|(\d+[.)]))\s+(.*)$/;
|
|
19
|
+
const QUOTE = /^>\s?(.*)$/;
|
|
20
|
+
const HR = /^\s*(?:-{3,}|\*{3,}|_{3,})\s*$/;
|
|
21
|
+
const FENCE = /^\s*(```|~~~)/;
|
|
22
|
+
const INLINE = /(`[^`\n]+`)|(\*\*[^*\n]+\*\*)|(\[[^\]\n]*\]\([^)\n]*\))/g;
|
|
23
|
+
/** A line that starts a table row (GFM). Escaped `\|` is not supported. */
|
|
24
|
+
const TABLE_ROW = /^\s*\|/;
|
|
25
|
+
|
|
26
|
+
export function renderMarkdown(text: string, width: number, styler: MarkdownStyler): string[] {
|
|
27
|
+
if (text === "") return [];
|
|
28
|
+
const w = Math.max(1, Math.floor(width));
|
|
29
|
+
const lines: string[] = [];
|
|
30
|
+
const source = text.replace(/\r\n?/g, "\n").split("\n");
|
|
31
|
+
|
|
32
|
+
let start = 0;
|
|
33
|
+
if (source[0]?.trimEnd() === "---") {
|
|
34
|
+
let end = 1;
|
|
35
|
+
while (end < source.length && source[end]!.trimEnd() !== "---") end += 1;
|
|
36
|
+
if (end < source.length) {
|
|
37
|
+
for (let k = 0; k <= end; k += 1) pushDim(lines, source[k]!, w, styler);
|
|
38
|
+
start = end + 1;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let inFence = false;
|
|
43
|
+
for (let i = start; i < source.length; i += 1) {
|
|
44
|
+
const raw = source[i]!;
|
|
45
|
+
if (inFence) {
|
|
46
|
+
if (FENCE.test(raw)) inFence = false;
|
|
47
|
+
else pushDim(lines, raw, w, styler);
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (FENCE.test(raw)) {
|
|
51
|
+
inFence = true;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (raw.trim() === "") {
|
|
55
|
+
pushBlank(lines);
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
const heading = HEADING.exec(raw);
|
|
59
|
+
if (heading) {
|
|
60
|
+
pushBlank(lines);
|
|
61
|
+
pushWrapped(lines, styler.fg("accent", styler.bold(heading[2]!)), w);
|
|
62
|
+
pushBlank(lines);
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (TABLE_ROW.test(raw) && isDivider(source[i + 1] ?? "")) {
|
|
66
|
+
let end = i + 2;
|
|
67
|
+
while (end < source.length && TABLE_ROW.test(source[end]!)) end += 1;
|
|
68
|
+
renderTable(
|
|
69
|
+
lines,
|
|
70
|
+
parseCells(raw),
|
|
71
|
+
source.slice(i + 2, end).map(parseCells),
|
|
72
|
+
w,
|
|
73
|
+
styler,
|
|
74
|
+
);
|
|
75
|
+
i = end - 1;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (HR.test(raw)) {
|
|
79
|
+
pushBlank(lines);
|
|
80
|
+
lines.push(styler.fg("dim", "─".repeat(w)));
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const list = LIST.exec(raw);
|
|
84
|
+
if (list) {
|
|
85
|
+
const indent = list[1] ?? "";
|
|
86
|
+
const marker = list[2] ? "• " : `${list[3]} `;
|
|
87
|
+
pushWrapped(lines, `${indent}${marker}${inline(list[4]!, styler)}`, w, indent.length + marker.length);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
const quote = QUOTE.exec(raw);
|
|
91
|
+
if (quote) {
|
|
92
|
+
pushWrapped(lines, styler.fg("muted", `> ${quote[1]}`), w, 2);
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
pushWrapped(lines, inline(raw, styler), w);
|
|
96
|
+
}
|
|
97
|
+
while (lines.length > 0 && lines.at(-1) === "") lines.pop();
|
|
98
|
+
return lines;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** `` `code` `` → accent, `**bold**` → bold, `[text](url)` → accent text without the url. */
|
|
102
|
+
function inline(text: string, styler: MarkdownStyler): string {
|
|
103
|
+
let out = "";
|
|
104
|
+
let last = 0;
|
|
105
|
+
for (const match of text.matchAll(INLINE)) {
|
|
106
|
+
out += text.slice(last, match.index);
|
|
107
|
+
if (match[1]) out += styler.fg("accent", match[1].slice(1, -1));
|
|
108
|
+
else if (match[2]) out += styler.bold(match[2]!.slice(2, -2));
|
|
109
|
+
else {
|
|
110
|
+
const label = /^\[([^\]]*)\]/.exec(match[0])?.[1] ?? "";
|
|
111
|
+
if (label) out += styler.fg("accent", label);
|
|
112
|
+
}
|
|
113
|
+
last = match.index + match[0].length;
|
|
114
|
+
}
|
|
115
|
+
return out + text.slice(last);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function pushBlank(lines: string[]) {
|
|
119
|
+
if (lines.length > 0 && lines.at(-1) !== "") lines.push("");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Wrap `text`, indenting continuation lines by `hangIndent` columns. */
|
|
123
|
+
function pushWrapped(lines: string[], text: string, width: number, hangIndent = 0) {
|
|
124
|
+
const parts = wrapTextWithAnsi(text, Math.max(1, width - hangIndent));
|
|
125
|
+
lines.push(parts[0] ?? "");
|
|
126
|
+
for (const part of parts.slice(1)) lines.push(" ".repeat(hangIndent) + part);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Style each already-wrapped part so styling survives across continuation lines. */
|
|
130
|
+
function pushDim(lines: string[], text: string, width: number, styler: MarkdownStyler) {
|
|
131
|
+
for (const part of wrapTextWithAnsi(text, width)) lines.push(styler.fg("dim", part));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ---- tables ----
|
|
135
|
+
|
|
136
|
+
/** GFM delimiter row: every `|`-separated cell is just dashes with optional colons. */
|
|
137
|
+
const isDivider = (line: string) => {
|
|
138
|
+
const cells = line.trim().replace(/^\|/, "").replace(/\|$/, "").split("|");
|
|
139
|
+
return cells.length > 0 && cells.every((cell) => /^\s*:?-+:?\s*$/.test(cell));
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
/** Split a table row into trimmed cells, dropping the edge pipes. */
|
|
143
|
+
const parseCells = (line: string) => {
|
|
144
|
+
const trimmed = line.trim().replace(/^\|/, "").replace(/\|$/, "");
|
|
145
|
+
return trimmed.split("|").map((cell) => cell.trim());
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
/** Render a table as aligned columns: bold header, dim rule, cells wrap in-column. */
|
|
149
|
+
function renderTable(
|
|
150
|
+
lines: string[],
|
|
151
|
+
header: string[],
|
|
152
|
+
body: string[][],
|
|
153
|
+
width: number,
|
|
154
|
+
styler: MarkdownStyler,
|
|
155
|
+
) {
|
|
156
|
+
const cols = Math.max(header.length, ...body.map((row) => row.length), 1);
|
|
157
|
+
const cell = (row: string[], c: number) => row[c] ?? "";
|
|
158
|
+
const widths: number[] = [];
|
|
159
|
+
for (let c = 0; c < cols; c += 1) {
|
|
160
|
+
widths.push(Math.max(3, visibleWidth(cell(header, c)), ...body.map((row) => visibleWidth(cell(row, c)))));
|
|
161
|
+
}
|
|
162
|
+
const gap = " │ ".length * (cols - 1);
|
|
163
|
+
const avail = Math.max(0, width - gap);
|
|
164
|
+
const floors: number[] = [];
|
|
165
|
+
for (let c = 0; c < cols; c += 1) {
|
|
166
|
+
// a column is never squeezed below its longest word (words would break mid-word)
|
|
167
|
+
floors.push(Math.min(12, Math.max(3, longestWord([cell(header, c), ...body.map((row) => cell(row, c))]))));
|
|
168
|
+
}
|
|
169
|
+
const final = fitColumns(widths, floors, avail);
|
|
170
|
+
|
|
171
|
+
const row = (cells: string[], styled: boolean) => {
|
|
172
|
+
const wrapped = cells.map((text, c) =>
|
|
173
|
+
wrapTextWithAnsi(styled ? styler.bold(inline(text, styler)) : inline(text, styler), final[c]!),
|
|
174
|
+
);
|
|
175
|
+
const height = Math.max(...wrapped.map((parts) => parts.length), 1);
|
|
176
|
+
for (let line = 0; line < height; line += 1) {
|
|
177
|
+
const parts = wrapped.map((column, c) => {
|
|
178
|
+
const text = column[line] ?? "";
|
|
179
|
+
return text + " ".repeat(Math.max(0, final[c]! - visibleWidth(text)));
|
|
180
|
+
});
|
|
181
|
+
lines.push(parts.join(" │ ").trimEnd());
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
pushBlank(lines);
|
|
186
|
+
row(header, true);
|
|
187
|
+
lines.push(styler.fg("dim", final.map((w) => "─".repeat(w)).join("─┼─")));
|
|
188
|
+
for (const entry of body) row(entry, false);
|
|
189
|
+
pushBlank(lines);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Width of the longest word across a column's cells. */
|
|
193
|
+
const longestWord = (texts: string[]) =>
|
|
194
|
+
Math.max(...texts.flatMap((text) => text.split(/\s+/).map((word) => word.length)), 1);
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Fit column widths into `avail`: every column keeps its longest word intact,
|
|
198
|
+
* leftover space is shared out in proportion to how much each column wanted.
|
|
199
|
+
*/
|
|
200
|
+
function fitColumns(natural: number[], floors: number[], avail: number): number[] {
|
|
201
|
+
const total = natural.reduce((sum, w) => sum + w, 0);
|
|
202
|
+
if (total <= avail) return natural;
|
|
203
|
+
const floorTotal = floors.reduce((sum, w) => sum + w, 0);
|
|
204
|
+
if (floorTotal >= avail) {
|
|
205
|
+
// nothing to distribute; squeeze the floors so lines still fit (words will break)
|
|
206
|
+
const shrunk = [...floors];
|
|
207
|
+
let sum = floorTotal;
|
|
208
|
+
while (sum > avail && shrunk.some((w) => w > 1)) {
|
|
209
|
+
const widest = Math.max(...shrunk);
|
|
210
|
+
shrunk[shrunk.indexOf(widest)] = widest - 1;
|
|
211
|
+
sum -= 1;
|
|
212
|
+
}
|
|
213
|
+
return shrunk;
|
|
214
|
+
}
|
|
215
|
+
const spare = avail - floorTotal;
|
|
216
|
+
const demand = natural.map((w, i) => w - floors[i]!);
|
|
217
|
+
const demandTotal = demand.reduce((sum, d) => sum + d, 0);
|
|
218
|
+
const widths = natural.map((_, i) => floors[i]! + Math.floor((demand[i]! * spare) / demandTotal));
|
|
219
|
+
let sum = widths.reduce((sum, w) => sum + w, 0);
|
|
220
|
+
const order = natural.map((_, i) => i).sort((a, b) => natural[b]! - natural[a]!);
|
|
221
|
+
for (let i = 0; sum < avail; i += 1) {
|
|
222
|
+
const j = order[i % widths.length]!;
|
|
223
|
+
if (widths[j]! < natural[j]!) {
|
|
224
|
+
widths[j]! += 1;
|
|
225
|
+
sum += 1;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return widths;
|
|
229
|
+
}
|