pi-fold 4.1.5 → 4.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.
- package/README.md +6 -5
- package/extensions/active-context.ts +74 -9
- package/extensions/index.js +1 -1
- package/extensions/lib/folding.ts +7 -3
- package/extensions/lib/live-settings.ts +2 -0
- package/extensions/lib/persistence.ts +134 -7
- package/extensions/lib/policy.ts +20 -0
- package/extensions/lib/status-widget.ts +323 -40
- package/extensions/settings.ts +73 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -37,14 +37,14 @@ In an interactive session one compact, full-height bar sits directly above Pi's
|
|
|
37
37
|
|
|
38
38
|
<picture>
|
|
39
39
|
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/shaneconner/fold/main/media/fold-bar-dark.svg">
|
|
40
|
-
<img alt="A single full-height usage bar. Batlow colours group consolidated folds, span folds, tool folds,
|
|
40
|
+
<img alt="A single full-height usage bar. Batlow colours group consolidated folds, span folds, tool folds, marked content, raw context and pinned content in that order. The suffix shows percentage, commit point, and fold, mark, and pin counts." src="https://raw.githubusercontent.com/shaneconner/fold/main/media/fold-bar-light.svg">
|
|
41
41
|
</picture>
|
|
42
42
|
|
|
43
|
-
The forty-column bar uses full-height half-blocks for eighty steps of fill, following the provider's measured count against the serving budget. Within that fill, colours show the estimated visible composition, grouped
|
|
43
|
+
The forty-column bar uses full-height half-blocks for eighty steps of fill, following the provider's measured count against the serving budget. Within that fill, colours show the estimated visible composition, grouped by distance from compression: **Consolidated Folds, Span Folds, Tool Folds, Marked, Raw, Pinned**. The first three are compressed now, deepest first; marked content folds at the next commit; raw content is eligible later; pinned content is held against every commit until released. The six shades, from consolidated to pinned, are read off a colour map, [Crameri's Batlow](https://www.fabiocrameri.ch/colourmaps/) end to end by default, so the bar reads as a gradient from most compressed to most settled. `/fold-settings` offers Batlow, viridis, plasma, magma and cividis, and where on the chosen map the two ends sit, so a slice of a map or a reversed one is a keypress away; the bar repaints on each step. Whatever the choice, each shade's lightness is moved away from the background, hue held, until it clears 4.5:1 on dark and light terminals, and label colours match the bar. Each nonempty category keeps at least one half-cell when the measured fill has room; remaining width follows relative mass. This visibility floor makes small compressed categories visible, so individual category widths are not token-exact. At very low occupancy, the largest shares take the available space. There are no individual fold edges, scores, or height differences.
|
|
44
44
|
|
|
45
|
-
The suffix reads `34% · commit at 55% · N Folds (X Cons., Y Span, Z Tool, X
|
|
45
|
+
The suffix reads `34% · commit at 55% · N Folds (X Cons., Y Span, Z Tool, X Mark, Y Pin)`, naming only the kinds with something to count, in the bar's order; a fresh session reads `0 Folds`. `N Folds` counts visible compressed folds; `Mark` counts pending marks and `Pin` counts protected entries, not additional compressed folds. A tool fold is lossless compression of tool-result context, not a count of tool calls. `/fold-status` retains the detailed token and nesting figures.
|
|
46
46
|
|
|
47
|
-
|
|
47
|
+
Two dotted marks stand in the band's edge cells: one at `minTarget`, where a commit aims to land, and one at `maxTarget`, where automatic folding is triggered. Each is drawn over the fill's own colour in a deeper tone of that same hue, so the fill continues behind the mark; on the empty track they stand in muted ink. Neither takes a colour of its own or a warning colour. Before a measurement the row says `not measured yet`; after a commit it identifies the old reading until the provider measures again. Crossing the target is routine runtime work, not a user action item. On reload, saved session context supplies the fold counts and composition without a new model message; the next real context event replaces that read-only view. Unavailable composition is stated as `mapping`; if folding stops, `FOLDING STOPPED` replaces the bar. Non-truecolor terminals use the theme's own colours.
|
|
48
48
|
|
|
49
49
|
## Results
|
|
50
50
|
|
|
@@ -80,7 +80,7 @@ Four papers with figure sources, redacted per-request ledgers and campaign logs
|
|
|
80
80
|
|
|
81
81
|
The one thing worth adding is a long-term memory store. Rotation and retention are different jobs: folding gets stale material out of the window, a store keeps what the session learned. A handoff summary tries to do both in one record, written once under time pressure, bounded by the window it has to fit in. Folding is the rotation half and only that half.
|
|
82
82
|
|
|
83
|
-
|
|
83
|
+
Seven options, and every other name is refused.
|
|
84
84
|
|
|
85
85
|
| Option | Default | Effect |
|
|
86
86
|
| --- | --- | --- |
|
|
@@ -90,6 +90,7 @@ Six options, and every other name is refused.
|
|
|
90
90
|
| `toolFoldThreshold` | `0.50` | The tool-call diet: a share in `[0, 1)` naming the oldest fraction of the window whose tool results are clipped in view. `0` turns it off. |
|
|
91
91
|
| `preCommitNotice` | `false` | The notice switch. False is the shape that won the campaign above: no carrier reaches the projection and every fold ships the runtime's own words. `true` states the window's status to the agent once per approach to a commit: where the commit fires, how far away it is, what is staged, what that frees, what is pinned and what the pin costs, with the verbs listed and no argument made for using any of them. |
|
|
92
92
|
| `noticeLeadShare` | `0.10` | How far below the commit trigger that notice speaks, as a share of the serving budget. A distance rather than a point, so it follows a band that moves. Read only when `preCommitNotice` is on. |
|
|
93
|
+
| `palette` | `{ map: "batlow", start: 0, end: 1 }` | The fold bar's colours, and nothing about folding. `map` is one of `batlow`, `viridis`, `plasma`, `magma`, `cividis`; `start` and `end` are positions on it in `[0, 1]` for the most compressed and the pinned shade, which may not coincide and may run backwards. Set whole or not at all. |
|
|
93
94
|
|
|
94
95
|
Four commands ship for the human: `/fold-status`, `/fold` (commit every staged mark now), `/fold-editor` (the interactive map of the window) and `/fold-settings` (edit the options above; each change is saved to `~/.config/pi-fold/settings.json` and takes effect in the running session).
|
|
95
96
|
|
|
@@ -64,7 +64,10 @@ import {
|
|
|
64
64
|
stringIds,
|
|
65
65
|
toolPayload,
|
|
66
66
|
} from "./lib/measurement.ts";
|
|
67
|
-
import {
|
|
67
|
+
import {
|
|
68
|
+
emptyFoldBarMass, renderFoldBar, resolveFoldBarPalette,
|
|
69
|
+
type FoldBarKind, type FoldBarModel, type FoldBarPalette,
|
|
70
|
+
} from "./lib/status-widget.ts";
|
|
68
71
|
import type {
|
|
69
72
|
NativeCompactionCompletionReceipt,
|
|
70
73
|
NativeCompactionDecisionReceipt,
|
|
@@ -82,6 +85,7 @@ import {
|
|
|
82
85
|
makeStateCheckpoint,
|
|
83
86
|
makeStateDelta,
|
|
84
87
|
MAX_ACTIVE_FOLD_RECORDS,
|
|
88
|
+
MAX_DELTAS_BETWEEN_CHECKPOINTS,
|
|
85
89
|
materializeStatePersistence,
|
|
86
90
|
foldProvenance,
|
|
87
91
|
normalizeFoldsForPersistedRecords,
|
|
@@ -115,7 +119,7 @@ import {
|
|
|
115
119
|
ESTIMATED_BYTES_PER_TOKEN,
|
|
116
120
|
IMAGE_ESTIMATED_TOKENS,
|
|
117
121
|
entryTypeNamespace,
|
|
118
|
-
|
|
122
|
+
chapterSpanCap,
|
|
119
123
|
DEFAULT_TOOL_FOLD_THRESHOLD,
|
|
120
124
|
MAX_PINNED_SHARE,
|
|
121
125
|
PEEK_DEFAULT_MAX_BYTES,
|
|
@@ -303,6 +307,12 @@ export function registerActiveContext(pi: any, options: {
|
|
|
303
307
|
* rather than a point, so it tracks a band that moves. Read only when preCommitNotice
|
|
304
308
|
* is true. */
|
|
305
309
|
noticeLeadShare?: number;
|
|
310
|
+
/** THE FOLD BAR'S COLOURS (2026-09-06, Shane): which colour map the status bar shades
|
|
311
|
+
* its five folded-to-raw categories from and where on that map the two ends of the
|
|
312
|
+
* ramp sit. A display preference: it moves nothing about folding, and it is here only
|
|
313
|
+
* because the settings file is handed to registration whole. Absent means Batlow end
|
|
314
|
+
* to end. */
|
|
315
|
+
palette?: FoldBarPalette;
|
|
306
316
|
}): {
|
|
307
317
|
projectionCandidates: (ctx: any) => Array<Record<string, unknown>>;
|
|
308
318
|
} {
|
|
@@ -320,6 +330,7 @@ export function registerActiveContext(pi: any, options: {
|
|
|
320
330
|
let preCommitNotice = resolvedPreCommitNotice(options.preCommitNotice);
|
|
321
331
|
let noticeLeadShare = resolveNoticeLeadShare(options.noticeLeadShare);
|
|
322
332
|
let toolFoldThreshold = resolvedToolFoldThreshold(options.toolFoldThreshold);
|
|
333
|
+
let palette = resolveFoldBarPalette(options.palette);
|
|
323
334
|
// THE RENAMED OPTION IS REFUSED BY NAME, not ignored (2026-08-30). postFoldNotice was
|
|
324
335
|
// public for three days and named a carrier that no longer exists in that shape: it
|
|
325
336
|
// fired on a fold count, after the cut, and asked for briefs. Silently accepting the
|
|
@@ -422,6 +433,14 @@ export function registerActiveContext(pi: any, options: {
|
|
|
422
433
|
state: null as ActiveContextState | null,
|
|
423
434
|
persisted: null as ActiveContextState | null,
|
|
424
435
|
persistedWireVersion: 0 as 0 | 1 | 2,
|
|
436
|
+
/** Set when the branch HELD durable state this build could not read. Distinct from a
|
|
437
|
+
* session that never had any, which is what `persistedWireVersion` 0 also means and
|
|
438
|
+
* which legitimately writes the first checkpoint. See `persist`. */
|
|
439
|
+
lineageUnreadable: false,
|
|
440
|
+
/** Deltas standing after the newest checkpoint in the branch. See
|
|
441
|
+
* `MAX_DELTAS_BETWEEN_CHECKPOINTS`: at the bound, `persist` writes a checkpoint instead
|
|
442
|
+
* of a delta, which is what keeps a load's replay bounded. */
|
|
443
|
+
deltasSinceCheckpoint: 0,
|
|
425
444
|
persistedStateSha256: "",
|
|
426
445
|
persistedFoldRecords: new Map<string, FoldRecordEntry>(),
|
|
427
446
|
persistenceQueue: Promise.resolve<void>(undefined),
|
|
@@ -777,6 +796,7 @@ export function registerActiveContext(pi: any, options: {
|
|
|
777
796
|
staleAfterCommit: input.share !== null && foldBar.staleSince !== null &&
|
|
778
797
|
foldBar.staleSince === measurements.lastProviderMeasurement,
|
|
779
798
|
stopped: ladder.automaticFailure?.message ?? null,
|
|
799
|
+
palette,
|
|
780
800
|
};
|
|
781
801
|
if (!state || !snapshot) return model;
|
|
782
802
|
model.mapped = true;
|
|
@@ -928,6 +948,7 @@ export function registerActiveContext(pi: any, options: {
|
|
|
928
948
|
const nextToolFoldThreshold = resolvedToolFoldThreshold(settings?.toolFoldThreshold);
|
|
929
949
|
const nextPreCommitNotice = resolvedPreCommitNotice(settings?.preCommitNotice);
|
|
930
950
|
const nextNoticeLeadShare = resolveNoticeLeadShare(settings?.noticeLeadShare);
|
|
951
|
+
const nextPalette = resolveFoldBarPalette(settings?.palette);
|
|
931
952
|
const changed: string[] = [];
|
|
932
953
|
for (const field of ["maxTarget", "minTarget", "consolidateAfter", "minFoldChars"] as const) {
|
|
933
954
|
if (nextThresholds[field] !== thresholds[field]) changed.push(field);
|
|
@@ -935,6 +956,7 @@ export function registerActiveContext(pi: any, options: {
|
|
|
935
956
|
if (nextToolFoldThreshold !== toolFoldThreshold) changed.push("toolFoldThreshold");
|
|
936
957
|
if (nextPreCommitNotice !== preCommitNotice) changed.push("preCommitNotice");
|
|
937
958
|
if (nextNoticeLeadShare !== noticeLeadShare) changed.push("noticeLeadShare");
|
|
959
|
+
if (nextPalette.map !== palette.map || nextPalette.start !== palette.start || nextPalette.end !== palette.end) changed.push("palette");
|
|
938
960
|
// A SAVE THAT MOVED NOTHING IS NOT AN EVENT. The screen saves every keystroke that
|
|
939
961
|
// lands, including a row stepped back to where it started.
|
|
940
962
|
if (!changed.length) return;
|
|
@@ -942,6 +964,7 @@ export function registerActiveContext(pi: any, options: {
|
|
|
942
964
|
toolFoldThreshold = nextToolFoldThreshold;
|
|
943
965
|
preCommitNotice = nextPreCommitNotice;
|
|
944
966
|
noticeLeadShare = nextNoticeLeadShare;
|
|
967
|
+
palette = nextPalette;
|
|
945
968
|
dropDerivationMemo();
|
|
946
969
|
// THE STREAM CARRIES IT, because from here on the session's commits fire at a point
|
|
947
970
|
// no earlier record explains, and an archive reading this run later has no other way
|
|
@@ -955,6 +978,7 @@ export function registerActiveContext(pi: any, options: {
|
|
|
955
978
|
tool_fold_threshold: toolFoldThreshold,
|
|
956
979
|
pre_commit_notice: preCommitNotice,
|
|
957
980
|
notice_lead_share: noticeLeadShare,
|
|
981
|
+
palette: { ...palette },
|
|
958
982
|
});
|
|
959
983
|
// The person is standing in the settings screen, so the line they will look at next
|
|
960
984
|
// is the status line. Without this it keeps the old number until a context event.
|
|
@@ -1172,7 +1196,19 @@ export function registerActiveContext(pi: any, options: {
|
|
|
1172
1196
|
}
|
|
1173
1197
|
const durableRestored = restored ?? emptyActiveContextState(sessionId);
|
|
1174
1198
|
persistence.state = durableRestored.prepared ? clearPrepared(durableRestored) : clone(durableRestored);
|
|
1199
|
+
// AN UNREADABLE LINEAGE IS NOT AN EMPTY ONE (2026-09-07). `persistedWireVersion` reads
|
|
1200
|
+
// 0 for both, and `persist` writes a CHECKPOINT whenever it is not 2, so a restore that
|
|
1201
|
+
// threw put a second checkpoint into a branch that already had one. The reader refuses
|
|
1202
|
+
// exactly that ("Duplicate active-context v2 checkpoint"), so the next load threw too,
|
|
1203
|
+
// and wrote a third. ONE TRANSIENT FAILURE OF ANY CAUSE LATCHED THE SESSION DEAD AND
|
|
1204
|
+
// EVERY RELOAD MADE IT STRICTLY WORSE: a real session reached five checkpoints, four of
|
|
1205
|
+
// them from four /reload presses inside eleven minutes, and grew from 859 entries to
|
|
1206
|
+
// 4,393 because each restart also re-cut the same spans and wrote fold records under
|
|
1207
|
+
// the same ids with drifted bytes, 48 of 85 ids left conflicting. The two states are
|
|
1208
|
+
// told apart here, once, rather than at each of `persist`'s callers.
|
|
1209
|
+
persistence.lineageUnreadable = restoreError !== null;
|
|
1175
1210
|
persistence.persistedWireVersion = restoredPersistence?.wireVersion ?? 0;
|
|
1211
|
+
persistence.deltasSinceCheckpoint = restoredPersistence?.deltasSinceCheckpoint ?? 0;
|
|
1176
1212
|
persistence.persistedFoldRecords = restoredPersistence?.records ?? new Map<string, FoldRecordEntry>();
|
|
1177
1213
|
persistence.persistedStateSha256 = restoredPersistence?.stateSha256 ?? semanticStateSha256(durableRestored);
|
|
1178
1214
|
const restoredMessages = ctx.sessionManager.buildSessionContext?.()?.messages;
|
|
@@ -1190,11 +1226,19 @@ export function registerActiveContext(pi: any, options: {
|
|
|
1190
1226
|
try { foldBar.startupSnapshot = snapshotForEvent(ctx, restoredMessages); }
|
|
1191
1227
|
catch { /* Keep the honest mapping fallback if the host cannot supply a view. */ }
|
|
1192
1228
|
}
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
)
|
|
1229
|
+
// The warning names the CONSEQUENCE and not only the cause: "state was ignored" read as
|
|
1230
|
+
// graceful degradation, and the session it described could no longer fold at all. The
|
|
1231
|
+
// suspension carries no ctx of its own so the canonical `context.suspend` record is
|
|
1232
|
+
// emitted for a headless host without a second UI line saying the same thing twice.
|
|
1233
|
+
if (restoreError) {
|
|
1234
|
+
safeNotify(
|
|
1235
|
+
ctx,
|
|
1236
|
+
"Active-context state could not be read, so folding is suspended for this session and " +
|
|
1237
|
+
`Pi native context remains authoritative: ${String(restoreError)}`,
|
|
1238
|
+
"warning",
|
|
1239
|
+
);
|
|
1240
|
+
suspendAutomatic(restoreError, "restore", undefined);
|
|
1241
|
+
}
|
|
1198
1242
|
if (measurementRestoreError) safeNotify(
|
|
1199
1243
|
ctx,
|
|
1200
1244
|
`Malformed provider measurement receipt was ignored; automatic context remains unmeasured: ${String(measurementRestoreError)}`,
|
|
@@ -1205,6 +1249,18 @@ export function registerActiveContext(pi: any, options: {
|
|
|
1205
1249
|
const persist = (ctx?: any): Promise<void> => {
|
|
1206
1250
|
const operation = persistence.persistenceQueue.then(async () => {
|
|
1207
1251
|
if (!persistence.state || !persistence.persisted) return;
|
|
1252
|
+
// THE ONE WRITE THAT MUST NOT HAPPEN. Folding is suspended the moment a restore fails,
|
|
1253
|
+
// so nothing automatic reaches this; a manual action still can, and one appended state
|
|
1254
|
+
// entry is all it takes to latch the session. Raising rather than returning keeps the
|
|
1255
|
+
// law of the discarded commit: a write that cannot land says so instead of vanishing,
|
|
1256
|
+
// and the manual path already restores the state it entered with and reports.
|
|
1257
|
+
if (persistence.lineageUnreadable) {
|
|
1258
|
+
throw new Error(
|
|
1259
|
+
"Active-context durable state is unreadable for this session, so no state may be " +
|
|
1260
|
+
"written: a checkpoint here would be the second in this branch and would make every " +
|
|
1261
|
+
"later load fail",
|
|
1262
|
+
);
|
|
1263
|
+
}
|
|
1208
1264
|
let next = clone(persistence.state);
|
|
1209
1265
|
const persistedFoldIds = new Set(persistence.persisted.folds.map((fold) => fold.id));
|
|
1210
1266
|
const arrivingFoldIds = next.folds.filter((fold) => !persistedFoldIds.has(fold.id)).map((fold) => fold.id);
|
|
@@ -1272,7 +1328,15 @@ export function registerActiveContext(pi: any, options: {
|
|
|
1272
1328
|
throw new Error("Active-context session changed after fold-record persistence");
|
|
1273
1329
|
}
|
|
1274
1330
|
}
|
|
1275
|
-
|
|
1331
|
+
// A CHECKPOINT ENDS THE RUN THE READER WOULD OTHERWISE WALK (2026-09-20). A delta is
|
|
1332
|
+
// the cheap write and stays the common one; at the bound the run is closed instead, so
|
|
1333
|
+
// the next load replays at most `MAX_DELTAS_BETWEEN_CHECKPOINTS` deltas however long the
|
|
1334
|
+
// session runs. A session that is already past the bound when this build first reads it
|
|
1335
|
+
// closes its run on this write, so it pays the long load once and never again.
|
|
1336
|
+
const runIsLong = persistence.deltasSinceCheckpoint >= MAX_DELTAS_BETWEEN_CHECKPOINTS;
|
|
1337
|
+
const wire = persistence.persistedWireVersion === 2 && !runIsLong
|
|
1338
|
+
? makeStateDelta(persistence.persisted, next)
|
|
1339
|
+
: makeStateCheckpoint(next);
|
|
1276
1340
|
// THE WIRE CARRIES BOTH DIGESTS IT JUST DERIVED (2026-08-28). `makeStateDelta` writes
|
|
1277
1341
|
// `baseStateSha256` from `persistence.persisted` and `stateSha256` from `next`, and
|
|
1278
1342
|
// nothing mutates either state between that call and here, so re-hashing two whole
|
|
@@ -1286,6 +1350,7 @@ export function registerActiveContext(pi: any, options: {
|
|
|
1286
1350
|
await pi.appendEntry(stateEntryType, wire);
|
|
1287
1351
|
persistence.persisted = clone(next);
|
|
1288
1352
|
persistence.persistedWireVersion = 2;
|
|
1353
|
+
persistence.deltasSinceCheckpoint = wire.kind === "checkpoint" ? 0 : persistence.deltasSinceCheckpoint + 1;
|
|
1289
1354
|
persistence.persistedStateSha256 = wire.stateSha256;
|
|
1290
1355
|
persistence.state = lifecycle.shuttingDown ? null : clone(next);
|
|
1291
1356
|
if (!sessionIdentityStillValid(ctx, sessionId, generationAtStart)) {
|
|
@@ -4132,7 +4197,7 @@ export function registerActiveContext(pi: any, options: {
|
|
|
4132
4197
|
last: rollback.last ? clone(rollback.last) : null,
|
|
4133
4198
|
},
|
|
4134
4199
|
},
|
|
4135
|
-
foldSpanCap:
|
|
4200
|
+
foldSpanCap: chapterSpanCap(thresholds.minFoldChars),
|
|
4136
4201
|
projectionBudgetTokens: currentCapacity(ctx).budgetTokens,
|
|
4137
4202
|
projectionCharsPerToken: projectionCharsPerToken(),
|
|
4138
4203
|
projectionEstimatorErrorShare: estimatorErrorShare(),
|
package/extensions/index.js
CHANGED
|
@@ -11,7 +11,7 @@ export {
|
|
|
11
11
|
|
|
12
12
|
const PUBLIC_OPTIONS = Object.freeze([
|
|
13
13
|
"thresholds", "providerInputBudget", "blacklistAutoFoldTools", "toolFoldThreshold",
|
|
14
|
-
"preCommitNotice", "noticeLeadShare",
|
|
14
|
+
"preCommitNotice", "noticeLeadShare", "palette",
|
|
15
15
|
]);
|
|
16
16
|
|
|
17
17
|
const GENERATOR_DELETED = "the model brief generator is deleted (2026-08-14): across 15 sealed " +
|
|
@@ -51,7 +51,7 @@ import {
|
|
|
51
51
|
CONTEXT_STATUS_RESPONSE_BYTES,
|
|
52
52
|
EXPAND_LEASE_GENERATIONS,
|
|
53
53
|
MAX_EXPAND_LEASES,
|
|
54
|
-
|
|
54
|
+
chapterSpanCap,
|
|
55
55
|
PEEK_DEFAULT_MAX_BYTES,
|
|
56
56
|
PEEK_HEAD_SHARE,
|
|
57
57
|
STATUS_DIET_INDEX_ROWS,
|
|
@@ -95,6 +95,10 @@ export function selectAutomaticChapter(
|
|
|
95
95
|
// The maturity floor: a stale span may end only where the batch lane has already had
|
|
96
96
|
// its chance, so a chapter can never claim a tool batch one turn before it qualifies.
|
|
97
97
|
const matureEnd = staleSpanMatureEnd(snapshot.messages);
|
|
98
|
+
// THE CAP FOLLOWS THE FLOOR: a multi-unit span may not pass it, and it sits at least
|
|
99
|
+
// one floor above `minFoldChars` so the accumulate-until-over walk below can always
|
|
100
|
+
// land between the two (policy.ts, chapterSpanCap).
|
|
101
|
+
const spanCap = chapterSpanCap(snapshot.thresholds.minFoldChars);
|
|
98
102
|
for (let unitIndex = 0; unitIndex < units.length; unitIndex += 1) {
|
|
99
103
|
const first = units[unitIndex];
|
|
100
104
|
if (first.end > matureEnd) break;
|
|
@@ -131,12 +135,12 @@ export function selectAutomaticChapter(
|
|
|
131
135
|
if (claimed.size && refs.some((ref) => claimed.has(objectRefKey(ref)))) break;
|
|
132
136
|
if (refsProtected(refs, state, snapshot)) break;
|
|
133
137
|
const size = bytes(encodedFoldSource(snapshot, state, parts, "chapter"));
|
|
134
|
-
const biteSized = size <=
|
|
138
|
+
const biteSized = size <= spanCap || endIndex === unitIndex;
|
|
135
139
|
// ACCUMULATE UNTIL OVER, never cut to hit the number. The walk grows the span one
|
|
136
140
|
// whole unit at a time and records the first one that carries it past the floor,
|
|
137
141
|
// so a fold is always at least minFoldChars and always ends on a message boundary.
|
|
138
142
|
if (size >= snapshot.thresholds.minFoldChars && biteSized) best = { kind: "chapter", parts, sourceRefs: refs };
|
|
139
|
-
if (size >
|
|
143
|
+
if (size > spanCap) break;
|
|
140
144
|
}
|
|
141
145
|
if (best) return best;
|
|
142
146
|
}
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
// deployment) still agrees on the property.
|
|
26
26
|
|
|
27
27
|
import type { ActiveContextThresholds } from "./policy.ts";
|
|
28
|
+
import type { FoldBarPalette } from "./status-widget.ts";
|
|
28
29
|
|
|
29
30
|
/**
|
|
30
31
|
* What the settings screen hands the runtime: exactly the settings FILE's shape, where an
|
|
@@ -37,6 +38,7 @@ export interface LiveFoldSettings {
|
|
|
37
38
|
toolFoldThreshold?: number;
|
|
38
39
|
preCommitNotice?: boolean;
|
|
39
40
|
noticeLeadShare?: number;
|
|
41
|
+
palette?: FoldBarPalette;
|
|
40
42
|
}
|
|
41
43
|
|
|
42
44
|
/** Refuses by throwing, exactly as registration does, and leaves the runtime untouched. */
|
|
@@ -68,6 +68,53 @@ export const STATE_DELTA_V2_KEYS = [
|
|
|
68
68
|
export const MAX_ACTIVE_FOLD_RECORD_BYTES = 256 * 1024;
|
|
69
69
|
export const MAX_ACTIVE_STATE_EVENT_BYTES = 512 * 1024;
|
|
70
70
|
export const MAX_ACTIVE_FOLD_RECORDS = 4_096;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* HOW MANY DELTAS MAY STAND BETWEEN CHECKPOINTS (2026-09-20).
|
|
74
|
+
*
|
|
75
|
+
* The v2 ledger was one checkpoint and then deltas for the life of the session, and
|
|
76
|
+
* `materializeStatePersistence` replays every one of them on every load. Each delta rebuilds
|
|
77
|
+
* the WHOLE state, so a load costs the delta count times the state size: quadratic in
|
|
78
|
+
* session length. Shane's noloss session 01a0ad40 reached 2,207 deltas and took 129.6s to
|
|
79
|
+
* restore, against 0.6s for pi to parse the same 93MB of entries; 01a0ba4e took 56.1s on
|
|
80
|
+
* 1,147 deltas. Doubling the entries quadrupled the time (3,987 entries 9.3s, 7,974 entries
|
|
81
|
+
* 38.8s) and the per-delta cost rose with it (7.5ms to 57.0ms), because what each delta
|
|
82
|
+
* re-walks is the state, not the delta. The load is also not paid once: `session_start`,
|
|
83
|
+
* `session_tree`, `session_compact` and a branch switch each pay it again, and
|
|
84
|
+
* `doubleEscapeAction: "tree"` puts it behind a keypress.
|
|
85
|
+
*
|
|
86
|
+
* A CHECKPOINT STATES THE WHOLE STATE, so a replay may start at the LAST one rather than the
|
|
87
|
+
* only one and never walk the run before it. That bounds a load at this many deltas instead
|
|
88
|
+
* of at the session's lifetime. Nothing is weakened by starting later: a checkpoint carries
|
|
89
|
+
* its own digest and the deltas after it still chain, so the final state is proven by the
|
|
90
|
+
* same equalities as before. What a later start gives up is re-proving revisions that were
|
|
91
|
+
* already proven when they were written, and the fingerprint map loses the revisions before
|
|
92
|
+
* the checkpoint, which its one reader already treats as absent (see `projectionFingerprints`).
|
|
93
|
+
*
|
|
94
|
+
* THE BOUND IS A TRADE, AND 50 IS WHERE IT WAS MEASURED. A checkpoint names every standing
|
|
95
|
+
* fold by id and digest, so it costs far more bytes than a delta: 237.9KB against a 2.55KB
|
|
96
|
+
* delta at 375 folds, 248.9KB against 3.34KB at 213. The load after a checkpoint is 0.34s and
|
|
97
|
+
* 0.20s for those two sessions, and each replayed delta adds about 85ms at 375 folds, so the
|
|
98
|
+
* bound sets both the worst load and the write overhead:
|
|
99
|
+
*
|
|
100
|
+
* K=100 8.8s worst load +93% state-ledger bytes (+5.6% of the session file)
|
|
101
|
+
* K= 50 4.4s worst load +187% state-ledger bytes (+11% of the session file)
|
|
102
|
+
* K= 25 2.2s worst load +374% state-ledger bytes (+22% of the session file)
|
|
103
|
+
*
|
|
104
|
+
* The state ledger is a small part of a session (5.9MB of 01a0ad40's 93MB), which is why the
|
|
105
|
+
* percentages look worse against the ledger than against the file. 50 takes the worst load
|
|
106
|
+
* under five seconds and the average near two, for a tenth of the file, and a session already
|
|
107
|
+
* past the bound heals itself on its next durable write.
|
|
108
|
+
*
|
|
109
|
+
* WHAT WOULD MAKE THE BOUND STOP MATTERING is the per-delta cost itself, which is still the
|
|
110
|
+
* whole state: 23.8% of a profiled load is `structuredClone` and 34% is `stableStringify`
|
|
111
|
+
* under `semanticStateSha256`, both walking every standing fold on every delta. Neither is
|
|
112
|
+
* reachable without giving the reader one canonical fold object per id for the life of a
|
|
113
|
+
* materialization, so that the clone stops being per-delta and a serializer memo can hit at
|
|
114
|
+
* all. That is a change to the aliasing barrier gate 158 guards and is deliberately NOT made
|
|
115
|
+
* here; until it is, this constant is what bounds the load.
|
|
116
|
+
*/
|
|
117
|
+
export const MAX_DELTAS_BETWEEN_CHECKPOINTS = 50;
|
|
71
118
|
export const MAX_ACTIVE_FOLD_PARTS = 1_024;
|
|
72
119
|
export const MAX_ACTIVE_EXPANDED = 1_024;
|
|
73
120
|
export const MAX_ACTIVE_PROTECTED = 1_024;
|
|
@@ -269,15 +316,54 @@ export function directFoldOwners(folds: readonly ActiveFold[]): Map<string, stri
|
|
|
269
316
|
return owner;
|
|
270
317
|
}
|
|
271
318
|
|
|
319
|
+
/**
|
|
320
|
+
* A FOLD THIS MATERIALIZATION HAS ALREADY PROVEN, AND WILL NOT PROVE AGAIN (2026-09-20).
|
|
321
|
+
*
|
|
322
|
+
* The replay's cost was never the deltas, it was re-proving the whole forest at each one.
|
|
323
|
+
* `stateFromFoldRefs` runs per delta and ends in `parseActiveContextState`, so a session with
|
|
324
|
+
* 2,207 deltas and 400 standing folds recomputed 400 content addresses and flattened and
|
|
325
|
+
* hashed 400 fold subtrees 2,207 times over. Profiling a 1,147-delta load put 48.2% of all
|
|
326
|
+
* time inside `validateFoldForest` and 14.8% on `foldIdFor` alone.
|
|
327
|
+
*
|
|
328
|
+
* WHAT THE MARK ASSERTS, and why each half of it is already true:
|
|
329
|
+
*
|
|
330
|
+
* The content address. `parseFoldRecordEntry` refuses a record unless
|
|
331
|
+
* `fold.id === foldIdFor(fold.kind, fold.parts)`, and every fold reaching a marked forest
|
|
332
|
+
* came out of `records` past that check, matched to its ref on `recordSha256`. The only
|
|
333
|
+
* field `assignFoldParents` writes is `parentId`, which `foldIdFor` does not read. So the
|
|
334
|
+
* recomputation could not reach a different verdict. This is the argument the 2026-08-28
|
|
335
|
+
* `foldRecordRef` change already made in `persist`, applied on the reading side.
|
|
336
|
+
*
|
|
337
|
+
* The source digest. This one is NOT implied by the record, so it is not skipped: it is
|
|
338
|
+
* paid ONCE PER DISTINCT FOLD PER MATERIALIZATION instead of once per delta. A fold's
|
|
339
|
+
* flattened refs are a pure function of its id, because the id pins kind and parts and each
|
|
340
|
+
* child id pins its own subtree in turn, so a second computation over the same id is
|
|
341
|
+
* arithmetic already done. `stateFromFoldRefs` marks only ids already proven against THIS
|
|
342
|
+
* records map and leaves a newly arriving fold unmarked, so its digest is still checked, and
|
|
343
|
+
* still inside `validateFoldForest` after the ownership loop that proves its children are
|
|
344
|
+
* present. Error ordering is unchanged.
|
|
345
|
+
*
|
|
346
|
+
* KEYED ON OBJECT IDENTITY, like gate 160's VALIDATED_FORESTS, so nothing outside this module
|
|
347
|
+
* can present itself as proven. It carries none of gate 160's stated cost: a fold mutated in
|
|
348
|
+
* place is never marked, because the mark is applied to the objects `assignFoldParents` has
|
|
349
|
+
* just built and is read before `clone` hands the forest on.
|
|
350
|
+
*/
|
|
351
|
+
const RECORD_PROVEN_FOLDS = new WeakSet<ActiveFold>();
|
|
352
|
+
const PROVEN_SOURCE_DIGESTS = new WeakMap<Map<string, FoldRecordEntry>, Set<string>>();
|
|
353
|
+
|
|
272
354
|
export function validateFoldForest(folds: ActiveFold[]): ActiveFold[] {
|
|
273
355
|
const rawValues = denseOwnArrayValues(folds);
|
|
274
356
|
if (!rawValues || rawValues.some((fold) => !validFoldShape(fold))) {
|
|
275
357
|
throw new Error("Invalid active-context fold forest shape");
|
|
276
358
|
}
|
|
277
359
|
const values = clone(rawValues) as ActiveFold[];
|
|
360
|
+
// READ OFF THE CALLER'S OBJECTS, BEFORE THE CLONE ABOVE COSTS US THE IDENTITY. `clone`
|
|
361
|
+
// preserves order, so the verdict for `values[i]` is the mark on `rawValues[i]`.
|
|
362
|
+
const proven = rawValues.map((fold) => RECORD_PROVEN_FOLDS.has(fold as ActiveFold));
|
|
278
363
|
const byId = new Map<string, ActiveFold>();
|
|
279
|
-
for (
|
|
280
|
-
|
|
364
|
+
for (let index = 0; index < values.length; index += 1) {
|
|
365
|
+
const fold = values[index];
|
|
366
|
+
if ((!proven[index] && fold.id !== foldIdFor(fold.kind, fold.parts)) || byId.has(fold.id)) {
|
|
281
367
|
throw new Error("Invalid active-context fold");
|
|
282
368
|
}
|
|
283
369
|
if (fold.kind === "tool-result" &&
|
|
@@ -304,7 +390,9 @@ export function validateFoldForest(folds: ActiveFold[]): ActiveFold[] {
|
|
|
304
390
|
for (const fold of values) {
|
|
305
391
|
if ((parent.get(fold.id) ?? null) !== fold.parentId) throw new Error(`Fold ${fold.id} parent drift`);
|
|
306
392
|
}
|
|
307
|
-
for (
|
|
393
|
+
for (let index = 0; index < values.length; index += 1) {
|
|
394
|
+
if (proven[index]) continue;
|
|
395
|
+
const fold = values[index];
|
|
308
396
|
const refs = flattenFoldRefs(fold, { folds: values });
|
|
309
397
|
if (fold.sourceSha256 !== sha256Value(refs)) throw new Error(`Fold ${fold.id} source digest drift`);
|
|
310
398
|
}
|
|
@@ -890,13 +978,28 @@ export function stateFromFoldRefs(
|
|
|
890
978
|
if (!record || record.recordSha256 !== ref.sha256) throw new Error(`Missing active-context fold record ${ref.id}`);
|
|
891
979
|
return record.fold;
|
|
892
980
|
});
|
|
981
|
+
const parented = assignFoldParents(folds);
|
|
982
|
+
// WHAT THIS MATERIALIZATION HAS ALREADY PROVEN IS MARKED; THE REST IS STILL PROVEN BELOW.
|
|
983
|
+
// See RECORD_PROVEN_FOLDS. A fold standing across deltas is marked from its second
|
|
984
|
+
// appearance on, so its content address and source digest are computed once for the whole
|
|
985
|
+
// replay rather than once per delta. A fold arriving now is deliberately left unmarked: its
|
|
986
|
+
// digest is checked by `validateFoldForest`, after the loop that proves its children are
|
|
987
|
+
// present, exactly where it was checked before.
|
|
988
|
+
let provenIds = PROVEN_SOURCE_DIGESTS.get(records);
|
|
989
|
+
if (!provenIds) {
|
|
990
|
+
provenIds = new Set<string>();
|
|
991
|
+
PROVEN_SOURCE_DIGESTS.set(records, provenIds);
|
|
992
|
+
}
|
|
993
|
+
for (const fold of parented) {
|
|
994
|
+
if (provenIds.has(fold.id)) RECORD_PROVEN_FOLDS.add(fold);
|
|
995
|
+
}
|
|
893
996
|
const state: ActiveContextState = {
|
|
894
997
|
version: 1,
|
|
895
998
|
sessionId: wire.sessionId,
|
|
896
999
|
revision: wire.revision,
|
|
897
1000
|
// `parseActiveContextState` at the end of this function validates the forest, so
|
|
898
1001
|
// validating it here as well was the same walk twice on one persist.
|
|
899
|
-
folds:
|
|
1002
|
+
folds: parented,
|
|
900
1003
|
expanded: clone(wire.expanded),
|
|
901
1004
|
protected: clone(wire.protected),
|
|
902
1005
|
tokensSinceToolFold: wire.tokensSinceToolFold ?? 0,
|
|
@@ -907,7 +1010,10 @@ export function stateFromFoldRefs(
|
|
|
907
1010
|
...(wire.advisory === undefined ? {} : { advisory: clone(wire.advisory) }),
|
|
908
1011
|
...(wire.prepared === null || wire.prepared === undefined ? {} : { prepared: clone(wire.prepared) }),
|
|
909
1012
|
};
|
|
910
|
-
|
|
1013
|
+
const parsed = parseActiveContextState(state, wire.sessionId, false);
|
|
1014
|
+
// RECORDED ONLY ONCE THE FOREST HAS PASSED, so a throw leaves nothing marked as proven.
|
|
1015
|
+
for (const fold of parented) provenIds.add(fold.id);
|
|
1016
|
+
return parsed;
|
|
911
1017
|
}
|
|
912
1018
|
|
|
913
1019
|
export interface ProjectionFingerprint {
|
|
@@ -933,6 +1039,12 @@ export interface MaterializedStatePersistence {
|
|
|
933
1039
|
records: Map<string, FoldRecordEntry>;
|
|
934
1040
|
stateSha256: string;
|
|
935
1041
|
projectionFingerprints: ProjectionFingerprints;
|
|
1042
|
+
/**
|
|
1043
|
+
* HOW MANY DELTAS STAND AFTER THE LAST CHECKPOINT in the branch just read. `persist` writes
|
|
1044
|
+
* a checkpoint instead of a delta once this reaches `MAX_DELTAS_BETWEEN_CHECKPOINTS`, which
|
|
1045
|
+
* is what stops the run, and therefore the load, from growing without bound.
|
|
1046
|
+
*/
|
|
1047
|
+
deltasSinceCheckpoint: number;
|
|
936
1048
|
}
|
|
937
1049
|
|
|
938
1050
|
export function materializeStatePersistence(
|
|
@@ -985,6 +1097,7 @@ export function materializeStatePersistence(
|
|
|
985
1097
|
let stateStart = -1;
|
|
986
1098
|
let checkpointIndex = -1;
|
|
987
1099
|
let v2Seen = false;
|
|
1100
|
+
let deltasSinceCheckpoint = 0;
|
|
988
1101
|
const rememberProjection = (): void => {
|
|
989
1102
|
fingerprintSources.set(state.revision, state);
|
|
990
1103
|
};
|
|
@@ -1001,11 +1114,25 @@ export function materializeStatePersistence(
|
|
|
1001
1114
|
if (version !== 2) throw new Error("Invalid active-context persisted state version");
|
|
1002
1115
|
const kind = ownValue(entry.data, "kind");
|
|
1003
1116
|
if (kind === "checkpoint") {
|
|
1004
|
-
|
|
1117
|
+
// THE LAST CHECKPOINT WINS, AND A SECOND ONE IS NO LONGER FATAL (2026-09-20). This
|
|
1118
|
+
// raised "Duplicate active-context v2 checkpoint" on sight of a second, which made the
|
|
1119
|
+
// ledger append-only in the strongest sense: one checkpoint, then deltas forever, and a
|
|
1120
|
+
// replay that walked all of them on every load. Restarting the replay at the newest
|
|
1121
|
+
// checkpoint is what makes `MAX_DELTAS_BETWEEN_CHECKPOINTS` possible, and it reaches
|
|
1122
|
+
// the same final state by the same digest equalities.
|
|
1123
|
+
//
|
|
1124
|
+
// It also un-latches the failure gate 173 describes. A branch that took a second
|
|
1125
|
+
// checkpoint from a failed restore used to be unreadable forever, and every reload made
|
|
1126
|
+
// it worse; such a branch now reads from its newest checkpoint. That is a second line of
|
|
1127
|
+
// defence, not the first: the `lineageUnreadable` guard in `persist` still refuses to
|
|
1128
|
+
// write the state entry that starts the damage, and gate 173 still pins it.
|
|
1005
1129
|
checkpointIndex = index;
|
|
1006
1130
|
stateStart = index;
|
|
1131
|
+
deltasSinceCheckpoint = 0;
|
|
1007
1132
|
} else if (checkpointIndex < 0) {
|
|
1008
1133
|
throw new Error("Active-context v2 delta precedes migration checkpoint");
|
|
1134
|
+
} else {
|
|
1135
|
+
deltasSinceCheckpoint += 1;
|
|
1009
1136
|
}
|
|
1010
1137
|
v2Seen = true;
|
|
1011
1138
|
}
|
|
@@ -1142,7 +1269,7 @@ export function materializeStatePersistence(
|
|
|
1142
1269
|
writtenSha256 = wire.stateSha256 === calculated ? null : wire.stateSha256;
|
|
1143
1270
|
rememberProjection();
|
|
1144
1271
|
}
|
|
1145
|
-
return { state, wireVersion, records, stateSha256, projectionFingerprints };
|
|
1272
|
+
return { state, wireVersion, records, stateSha256, projectionFingerprints, deltasSinceCheckpoint };
|
|
1146
1273
|
}
|
|
1147
1274
|
|
|
1148
1275
|
export function materializeActiveContextState(
|
package/extensions/lib/policy.ts
CHANGED
|
@@ -343,6 +343,26 @@ export const CONTEXT_STATUS_RESPONSE_BYTES = 24_000;
|
|
|
343
343
|
export const MAX_UNMARKED_CANDIDATES = 3;
|
|
344
344
|
|
|
345
345
|
export const MAX_FOLD_SPAN_CHARS = 16_000;
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* THE LADDER'S CHAPTER CAP FOLLOWS THE FLOOR (2026-09-07).
|
|
349
|
+
*
|
|
350
|
+
* `selectAutomaticChapter` accumulates whole units until a span reaches `minFoldChars` and
|
|
351
|
+
* refuses a multi-unit span past this cap, so the cap has to sit at least one unit above
|
|
352
|
+
* the floor or the walk can never satisfy both. At the shipped 8,000 the 16,000 constant
|
|
353
|
+
* does. /fold-settings offers floors up to 32,000, and at any floor past the constant every
|
|
354
|
+
* multi-unit chapter was refused by arithmetic: only a single oversized unit or a tool batch
|
|
355
|
+
* could fold, and a prose session with the floor at 32,000 held its raw material forever
|
|
356
|
+
* while the status row announced a commit (session 01a07498, 2026-09-07: fourteen sizable
|
|
357
|
+
* units in view, none over 32,000, the frontier staging nothing). Twice the floor is the
|
|
358
|
+
* smallest cap under which a walk that crosses the floor by one unit shorter than the floor
|
|
359
|
+
* always lands inside it; the constant stays as the cap's own floor so every default reads
|
|
360
|
+
* exactly as before, and a manual span is never bounded by either (Shane, 2026-08-22).
|
|
361
|
+
*/
|
|
362
|
+
export function chapterSpanCap(minFoldChars: number): number {
|
|
363
|
+
return Math.max(MAX_FOLD_SPAN_CHARS, 2 * minFoldChars);
|
|
364
|
+
}
|
|
365
|
+
|
|
346
366
|
export const PEEK_DEFAULT_MAX_BYTES = 16_000;
|
|
347
367
|
export const PEEK_HEAD_SHARE = 0.6;
|
|
348
368
|
|
|
@@ -2,11 +2,254 @@
|
|
|
2
2
|
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
3
3
|
|
|
4
4
|
export const FOLD_BAR_WIDTH = 40;
|
|
5
|
-
//
|
|
6
|
-
|
|
5
|
+
// GROUPED BY DISTANCE FROM COMPRESSION, left to right (Shane 2026-09-06). Consolidated,
|
|
6
|
+
// span and tool folds are compressed now, deepest first. Marked content is raw now and
|
|
7
|
+
// folds at the next commit. Raw content is raw now and eligible later. Pinned content is
|
|
8
|
+
// raw and HELD against every commit until released, so it is the farthest from
|
|
9
|
+
// compression of all, and it sits at the fresh edge of the bar.
|
|
10
|
+
export const FOLD_BAR_KINDS = ["consolidated", "span", "tool", "marked", "raw", "pinned"] as const;
|
|
7
11
|
export type FoldBarKind = typeof FOLD_BAR_KINDS[number];
|
|
8
12
|
export type FoldBarMass = Record<FoldBarKind, number>;
|
|
9
|
-
export const emptyFoldBarMass = (): FoldBarMass => ({ consolidated: 0, span: 0, tool: 0,
|
|
13
|
+
export const emptyFoldBarMass = (): FoldBarMass => ({ consolidated: 0, span: 0, tool: 0, marked: 0, raw: 0, pinned: 0 });
|
|
14
|
+
|
|
15
|
+
// ALL SIX KINDS TAKE THEIR SHADE FROM THE MAP, in that order (Shane 2026-09-06, second
|
|
16
|
+
// pass). A pin sat off the ramp in the theme accent for one build; Shane put it back at
|
|
17
|
+
// the far end, because a pin is the MOST settled state of all, decided and held, and the
|
|
18
|
+
// map's last shade reads as separate from the rest of the fill on its own.
|
|
19
|
+
|
|
20
|
+
// THE COLOUR MAPS, sampled at 64 positions by lab/generate_colour_maps.py: Batlow from
|
|
21
|
+
// Crameri's own 256-step table, the other four from matplotlib. Five shades are read off
|
|
22
|
+
// the chosen map by linear interpolation between a start and an end position, so a
|
|
23
|
+
// person can use the whole map, a slice of it, or run it backwards. No palette library
|
|
24
|
+
// ships: these tables are the whole dependency.
|
|
25
|
+
export const COLOUR_MAP_NAMES = ["batlow", "viridis", "plasma", "magma", "cividis"] as const;
|
|
26
|
+
export type ColourMapName = typeof COLOUR_MAP_NAMES[number];
|
|
27
|
+
const COLOUR_MAPS: Record<ColourMapName, readonly string[]> = {
|
|
28
|
+
batlow: [
|
|
29
|
+
"#011959", "#051F5A", "#08265B", "#0B2B5C", "#0D315D", "#0E365E", "#0F3B5F", "#103F60",
|
|
30
|
+
"#114360", "#124761", "#134B61", "#154F62", "#175362", "#195662", "#1C5A62", "#1F5D61",
|
|
31
|
+
"#236061", "#27635F", "#2C665D", "#31695B", "#376B59", "#3C6D56", "#426F52", "#49714F",
|
|
32
|
+
"#4F734C", "#557548", "#5C7744", "#627941", "#697B3D", "#707D3A", "#777F36", "#7E8133",
|
|
33
|
+
"#858330", "#8D852E", "#94872C", "#9C892B", "#A48B2C", "#AC8C2D", "#B48E30", "#BC8F34",
|
|
34
|
+
"#C49138", "#CB923E", "#D29343", "#D9954A", "#E09651", "#E69858", "#EC9A61", "#F19C69",
|
|
35
|
+
"#F59F73", "#F8A17C", "#FAA486", "#FCA78F", "#FDAB98", "#FDAEA1", "#FDB1AA", "#FDB3B3",
|
|
36
|
+
"#FDB6BB", "#FDB9C4", "#FDBCCC", "#FCBFD5", "#FCC2DE", "#FBC6E7", "#FBC9F1", "#FACCFA",
|
|
37
|
+
],
|
|
38
|
+
viridis: [
|
|
39
|
+
"#440154", "#46075A", "#470D60", "#471365", "#48186B", "#481E6F", "#482374", "#482878",
|
|
40
|
+
"#472D7C", "#46327F", "#453782", "#433C84", "#424186", "#404688", "#3E4A89", "#3C4F8A",
|
|
41
|
+
"#3A538B", "#38578C", "#365C8D", "#34608D", "#32648E", "#31688E", "#2F6C8E", "#2D708E",
|
|
42
|
+
"#2C748E", "#2A778E", "#297B8E", "#277F8E", "#26838E", "#24878E", "#238A8D", "#218E8D",
|
|
43
|
+
"#20928C", "#1F968B", "#1F9A8A", "#1F9D89", "#1FA187", "#21A586", "#23A984", "#26AC81",
|
|
44
|
+
"#2AB07F", "#2FB47C", "#35B779", "#3BBB75", "#42BE71", "#4AC26D", "#52C569", "#5AC864",
|
|
45
|
+
"#63CB5F", "#6CCE59", "#76D054", "#80D34D", "#8AD547", "#94D841", "#9FDA3A", "#A9DC33",
|
|
46
|
+
"#B4DD2C", "#BFDF25", "#CAE01F", "#D4E21A", "#DFE318", "#E9E41A", "#F3E61E", "#FDE725",
|
|
47
|
+
],
|
|
48
|
+
plasma: [
|
|
49
|
+
"#0D0887", "#19068C", "#220690", "#2A0593", "#320597", "#39049A", "#40049C", "#46039F",
|
|
50
|
+
"#4D02A1", "#5402A3", "#5A01A5", "#6001A6", "#6700A7", "#6D00A8", "#7301A8", "#7902A8",
|
|
51
|
+
"#7F03A8", "#8506A7", "#8B0AA5", "#910EA3", "#9613A1", "#9C179E", "#A11C9B", "#A62098",
|
|
52
|
+
"#AB2594", "#B02991", "#B42E8D", "#B93289", "#BD3785", "#C13B82", "#C5407E", "#C9457A",
|
|
53
|
+
"#CD4976", "#D14E73", "#D4526F", "#D8576C", "#DB5C68", "#DE6065", "#E16561", "#E46A5E",
|
|
54
|
+
"#E76F5A", "#EA7457", "#ED7953", "#EF7E50", "#F2834C", "#F48849", "#F68D45", "#F89342",
|
|
55
|
+
"#F9993E", "#FA9E3B", "#FCA437", "#FDAA34", "#FDB031", "#FEB62E", "#FEBC2B", "#FDC328",
|
|
56
|
+
"#FDC926", "#FCD025", "#FBD624", "#F9DD25", "#F7E425", "#F5EB27", "#F2F227", "#F0F921",
|
|
57
|
+
],
|
|
58
|
+
magma: [
|
|
59
|
+
"#000004", "#02010A", "#040312", "#06051A", "#0A0822", "#0F0B2B", "#130D34", "#180F3E",
|
|
60
|
+
"#1D1148", "#231151", "#29115B", "#301164", "#37106C", "#3E0F72", "#450F76", "#4B117A",
|
|
61
|
+
"#52137C", "#58157E", "#5F187F", "#651A80", "#6B1D81", "#721F81", "#782281", "#7E2482",
|
|
62
|
+
"#852681", "#8B2981", "#922B81", "#982D80", "#9F2F7F", "#A5317E", "#AC337C", "#B3357A",
|
|
63
|
+
"#B93878", "#C03A76", "#C63D74", "#CD3F71", "#D3436E", "#D9466B", "#DF4A68", "#E44F65",
|
|
64
|
+
"#E95462", "#ED5A5F", "#F1605D", "#F4675C", "#F66E5C", "#F8765C", "#FA7E5E", "#FB8560",
|
|
65
|
+
"#FC8D63", "#FD9567", "#FD9C6B", "#FEA470", "#FEAB75", "#FEB37A", "#FEBA80", "#FEC286",
|
|
66
|
+
"#FEC98D", "#FED193", "#FED89A", "#FDDFA1", "#FDE7A8", "#FCEEB0", "#FCF5B7", "#FCFDBF",
|
|
67
|
+
],
|
|
68
|
+
cividis: [
|
|
69
|
+
"#00224E", "#002554", "#00285B", "#002B63", "#002E6A", "#003070", "#063370", "#123670",
|
|
70
|
+
"#1B386F", "#223B6E", "#283E6E", "#2D416D", "#32446D", "#37476C", "#3C496C", "#404C6C",
|
|
71
|
+
"#444F6C", "#48526C", "#4C556C", "#50576C", "#545A6D", "#575D6D", "#5B606E", "#5F636F",
|
|
72
|
+
"#62666F", "#666870", "#696B71", "#6D6E72", "#707173", "#747475", "#777776", "#7A7A78",
|
|
73
|
+
"#7E7D78", "#828078", "#868379", "#898678", "#8D8978", "#918C78", "#958F77", "#999277",
|
|
74
|
+
"#9D9576", "#A19875", "#A59C74", "#A99F73", "#ADA272", "#B2A570", "#B6A96F", "#BAAC6D",
|
|
75
|
+
"#BEAF6B", "#C2B369", "#C7B667", "#CBBA64", "#CFBD62", "#D4C15F", "#D8C45C", "#DDC859",
|
|
76
|
+
"#E1CC55", "#E5CF51", "#EAD34D", "#EFD748", "#F3DB42", "#F8DF3C", "#FDE234", "#FEE838",
|
|
77
|
+
],
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/** Which map the bar reads and where on it the two ends of the ramp sit. `start` is the
|
|
81
|
+
* position of the most compressed shade and `end` the position of raw; a start above
|
|
82
|
+
* its end runs the map backwards. Both are positions in [0, 1] and may not coincide,
|
|
83
|
+
* because six shades read at one position are one shade. */
|
|
84
|
+
export interface FoldBarPalette { map: ColourMapName; start: number; end: number; }
|
|
85
|
+
export const DEFAULT_FOLD_BAR_PALETTE: Readonly<FoldBarPalette> = Object.freeze({ map: "batlow", start: 0, end: 1 });
|
|
86
|
+
|
|
87
|
+
export class FoldBarPaletteError extends Error {}
|
|
88
|
+
|
|
89
|
+
/** One resolver for every path a palette arrives by: registration, the settings file and
|
|
90
|
+
* a live edit. Absent means the package default; anything present is validated whole. */
|
|
91
|
+
export function resolveFoldBarPalette(value: unknown): FoldBarPalette {
|
|
92
|
+
if (value === undefined) return { ...DEFAULT_FOLD_BAR_PALETTE };
|
|
93
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
94
|
+
throw new FoldBarPaletteError("palette must be an object { map, start, end }");
|
|
95
|
+
}
|
|
96
|
+
const candidate = value as Record<string, unknown>;
|
|
97
|
+
for (const key of Object.keys(candidate)) {
|
|
98
|
+
if (key !== "map" && key !== "start" && key !== "end") {
|
|
99
|
+
throw new FoldBarPaletteError(`palette has no ${key} field: the fields are map, start, end`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const { map, start, end } = candidate;
|
|
103
|
+
if (typeof map !== "string" || !(COLOUR_MAP_NAMES as readonly string[]).includes(map)) {
|
|
104
|
+
throw new FoldBarPaletteError(`palette.map must be one of ${COLOUR_MAP_NAMES.join(", ")}`);
|
|
105
|
+
}
|
|
106
|
+
for (const [name, position] of [["start", start], ["end", end]] as const) {
|
|
107
|
+
if (typeof position !== "number" || !Number.isFinite(position) || position < 0 || position > 1) {
|
|
108
|
+
throw new FoldBarPaletteError(`palette.${name} must be a position on the map from 0 to 1`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (start === end) {
|
|
112
|
+
throw new FoldBarPaletteError("palette.start and palette.end must differ: six shades read at one position are one shade");
|
|
113
|
+
}
|
|
114
|
+
return { map: map as ColourMapName, start: start as number, end: end as number };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// sRGB <-> linear <-> OKLab, enough to hold a hue while moving its lightness.
|
|
118
|
+
const hexToRgb = (hex: string): number[] => [1, 3, 5].map((at) => parseInt(hex.slice(at, at + 2), 16) / 255);
|
|
119
|
+
const rgbToHex = (rgb: number[]): string =>
|
|
120
|
+
"#" + rgb.map((v) => Math.round(Math.max(0, Math.min(1, v)) * 255).toString(16).padStart(2, "0").toUpperCase()).join("");
|
|
121
|
+
const toLinear = (c: number): number => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
|
|
122
|
+
const fromLinear = (c: number): number => (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055);
|
|
123
|
+
const luminanceOf = (hex: string): number => {
|
|
124
|
+
const [r, g, b] = hexToRgb(hex).map(toLinear);
|
|
125
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
126
|
+
};
|
|
127
|
+
/** WCAG contrast ratio between two colours, the measure gate 165 has always applied. */
|
|
128
|
+
export function contrastRatio(a: string, b: string): number {
|
|
129
|
+
const [la, lb] = [luminanceOf(a), luminanceOf(b)];
|
|
130
|
+
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
|
|
131
|
+
}
|
|
132
|
+
const linearToOklab = ([r, g, b]: number[]): number[] => {
|
|
133
|
+
const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
|
|
134
|
+
const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
|
|
135
|
+
const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
|
|
136
|
+
return [
|
|
137
|
+
0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s,
|
|
138
|
+
1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s,
|
|
139
|
+
0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s,
|
|
140
|
+
];
|
|
141
|
+
};
|
|
142
|
+
const oklabToLinear = ([L, a, b]: number[]): number[] => {
|
|
143
|
+
const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3;
|
|
144
|
+
const m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3;
|
|
145
|
+
const s = (L - 0.0894841775 * a - 1.2914855480 * b) ** 3;
|
|
146
|
+
return [
|
|
147
|
+
4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
|
|
148
|
+
-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
|
|
149
|
+
-0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s,
|
|
150
|
+
];
|
|
151
|
+
};
|
|
152
|
+
/** An OKLab colour brought into the sRGB gamut by shrinking its chroma, hue held. */
|
|
153
|
+
const inGamut = (L: number, a: number, b: number): number[] => {
|
|
154
|
+
const inside = (rgb: number[]): boolean => rgb.every((v) => v >= -1e-6 && v <= 1 + 1e-6);
|
|
155
|
+
let rgb = oklabToLinear([L, a, b]);
|
|
156
|
+
if (inside(rgb)) return rgb.map(fromLinear);
|
|
157
|
+
let lo = 0, hi = 1;
|
|
158
|
+
for (let i = 0; i < 16; i += 1) {
|
|
159
|
+
const mid = (lo + hi) / 2;
|
|
160
|
+
if (inside(oklabToLinear([L, a * mid, b * mid]))) lo = mid; else hi = mid;
|
|
161
|
+
}
|
|
162
|
+
rgb = oklabToLinear([L, a * lo, b * lo]);
|
|
163
|
+
return rgb.map((v) => fromLinear(Math.max(0, Math.min(1, v))));
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
/** The two reference backgrounds the readability floor is measured against. */
|
|
167
|
+
export const READABILITY = Object.freeze({ dark: "#202122", light: "#FFFFFF", contrast: 4.5 });
|
|
168
|
+
|
|
169
|
+
/** THE READABILITY CLAMP. A map's dark end vanishes on a dark terminal and its pale end
|
|
170
|
+
* on a light one, and the old build carried two hand-adjusted hex tables to cover that
|
|
171
|
+
* for one map at one range. With the map and the range a choice, the adjustment runs
|
|
172
|
+
* here: lightness moves away from the background, hue held and chroma shrunk only as far
|
|
173
|
+
* as the gamut needs, until the shade clears 4.5:1 against the reference background. A
|
|
174
|
+
* shade that already clears it is returned untouched. */
|
|
175
|
+
export function readableOn(hex: string, dark: boolean): string {
|
|
176
|
+
const background = dark ? READABILITY.dark : READABILITY.light;
|
|
177
|
+
if (contrastRatio(hex, background) >= READABILITY.contrast) return hex.toUpperCase();
|
|
178
|
+
const [L, a, b] = linearToOklab(hexToRgb(hex).map(toLinear));
|
|
179
|
+
// Lightness is monotone in luminance at held hue, so the boundary is found by bisection
|
|
180
|
+
// between the shade's own lightness and the far end, which always clears the floor.
|
|
181
|
+
let fails = L;
|
|
182
|
+
let passes = dark ? 1 : 0;
|
|
183
|
+
for (let i = 0; i < 24; i += 1) {
|
|
184
|
+
const mid = (fails + passes) / 2;
|
|
185
|
+
if (contrastRatio(rgbToHex(inGamut(mid, a, b)), background) >= READABILITY.contrast) passes = mid;
|
|
186
|
+
else fails = mid;
|
|
187
|
+
}
|
|
188
|
+
return rgbToHex(inGamut(passes, a, b));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** The glyph a guide draws. Marks, not breaks: the fill's own colour stays behind them.
|
|
192
|
+
* DOTTED, NOT A LINE (Shane 2026-09-06, sixth pass): a full-height one-pixel stroke
|
|
193
|
+
* inside a solid colour is exactly what the boundary between two folds of that colour
|
|
194
|
+
* looks like, so the edge strokes "read as fold segments"; a mark that does not span
|
|
195
|
+
* the cell's height cannot be a boundary. The dotted vertical is centred in its cell,
|
|
196
|
+
* so each guide stands just inside the band's edge: the aim in the cell whose left edge
|
|
197
|
+
* is the aim share, the commit in the cell whose right edge is the commit share, a
|
|
198
|
+
* quarter cell in from each line. Earlier passes: a text-ink hairline "too pronounced",
|
|
199
|
+
* a notch "confusing", brackets off their lines because a font insets them. The ink is
|
|
200
|
+
* the fill's own hue toned toward the background, muted on the track. */
|
|
201
|
+
export const GUIDE_GLYPHS = Object.freeze({ aim: "┊", commit: "┊" });
|
|
202
|
+
/** How far a bracket's ink is pulled from the shade under it toward the reference
|
|
203
|
+
* background. Shane 2026-09-06, fourth pass: black brackets were too heavy, and no one
|
|
204
|
+
* fixed accent is legible over a map that runs from navy to pale pink (the theme's teal
|
|
205
|
+
* vanished over the teal span shade, violet over the blue, white over the pink). The
|
|
206
|
+
* fill's own hue, darkened on a dark theme and lightened on a light one, is legible over
|
|
207
|
+
* every shade because the readability clamp holds each shade at 4.5:1 against that
|
|
208
|
+
* background: at this depth the floor is 3:1 over the darkest shade a map can carry. */
|
|
209
|
+
export const GUIDE_TONE = 0.75;
|
|
210
|
+
/** A guide's ink over a shade: the same hue, most of the way to the reference
|
|
211
|
+
* background, so a bracket over salmon is a dark salmon and one over navy a deeper navy.
|
|
212
|
+
* Never a hue of its own, so no guide competes with a category. */
|
|
213
|
+
export function guideInk(under: string, dark: boolean): string {
|
|
214
|
+
const background = hexToRgb(dark ? READABILITY.dark : READABILITY.light);
|
|
215
|
+
return rgbToHex(hexToRgb(under).map((v, i) => v * (1 - GUIDE_TONE) + background[i] * GUIDE_TONE));
|
|
216
|
+
}
|
|
217
|
+
/** How far the dim ink is pulled toward the reference background to stand in for the
|
|
218
|
+
* ░ texture it replaces: a quarter-coverage glyph reads as about a quarter of its ink. */
|
|
219
|
+
export const TRACK_DEPTH = 0.7;
|
|
220
|
+
/** The empty track's one shade, or null when the theme's dim ink is not an RGB escape. */
|
|
221
|
+
export function trackShade(theme: FoldBarTheme, dark: boolean): string | null {
|
|
222
|
+
const match = /^\x1b\[38;2;(\d+);(\d+);(\d+)m$/.exec(theme.getFgAnsi?.("dim") ?? "");
|
|
223
|
+
if (!match) return null;
|
|
224
|
+
const dim = match.slice(1, 4).map((v) => Number(v) / 255);
|
|
225
|
+
const background = hexToRgb(dark ? READABILITY.dark : READABILITY.light);
|
|
226
|
+
return rgbToHex(dim.map((v, i) => v * (1 - TRACK_DEPTH) + background[i] * TRACK_DEPTH));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** The shade a map holds at a position in [0, 1], interpolated between its samples. */
|
|
230
|
+
export function sampleColourMap(map: ColourMapName, position: number): string {
|
|
231
|
+
const table = COLOUR_MAPS[map];
|
|
232
|
+
const at = Math.max(0, Math.min(1, position)) * (table.length - 1);
|
|
233
|
+
const lo = Math.floor(at), hi = Math.min(table.length - 1, lo + 1), t = at - lo;
|
|
234
|
+
const a = hexToRgb(table[lo]), b = hexToRgb(table[hi]);
|
|
235
|
+
return rgbToHex(a.map((v, i) => v * (1 - t) + b[i] * t));
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const shadeMemo = new Map<string, Record<FoldBarKind, string>>();
|
|
239
|
+
/** The six shades for a palette on a background: read off the map at evenly spaced
|
|
240
|
+
* positions from start to end, then brought up to the readability floor. Memoized on
|
|
241
|
+
* the palette and background, since the bar renders many times per choice. */
|
|
242
|
+
export function foldBarShades(palette: FoldBarPalette, dark: boolean): Record<FoldBarKind, string> {
|
|
243
|
+
const key = `${palette.map}:${palette.start}:${palette.end}:${dark ? "dark" : "light"}`;
|
|
244
|
+
const held = shadeMemo.get(key);
|
|
245
|
+
if (held) return held;
|
|
246
|
+
const shades = Object.fromEntries(FOLD_BAR_KINDS.map((kind, i) => {
|
|
247
|
+
const position = palette.start + (palette.end - palette.start) * (i / (FOLD_BAR_KINDS.length - 1));
|
|
248
|
+
return [kind, readableOn(sampleColourMap(palette.map, position), dark)];
|
|
249
|
+
})) as Record<FoldBarKind, string>;
|
|
250
|
+
shadeMemo.set(key, shades);
|
|
251
|
+
return shades;
|
|
252
|
+
}
|
|
10
253
|
|
|
11
254
|
export interface FoldBarModel {
|
|
12
255
|
brand: string;
|
|
@@ -26,27 +269,17 @@ export interface FoldBarModel {
|
|
|
26
269
|
weighed: boolean;
|
|
27
270
|
staleAfterCommit: boolean;
|
|
28
271
|
stopped: string | null;
|
|
272
|
+
/** Absent means the package default, exactly as it does in the settings file. */
|
|
273
|
+
palette?: FoldBarPalette;
|
|
29
274
|
}
|
|
30
275
|
export interface FoldBarTheme {
|
|
31
276
|
fg(color: "dim" | "muted" | "text" | "warning" | "error" | "accent", text: string): string;
|
|
32
277
|
bold(text: string): string;
|
|
33
278
|
getColorMode?(): "truecolor" | "256color";
|
|
34
|
-
getFgAnsi?(color: "text" | "muted" | "accent"): string;
|
|
279
|
+
getFgAnsi?(color: "text" | "muted" | "accent" | "dim"): string;
|
|
35
280
|
name?: string;
|
|
36
281
|
}
|
|
37
282
|
|
|
38
|
-
// Batlow samples at 0, .2, .4 | .6, .8, 1, in the requested category order.
|
|
39
|
-
// Static foreground lightness adjustments retain readable labels on dark/white
|
|
40
|
-
// reference backgrounds. No palette generator or new dependency ships.
|
|
41
|
-
const ON_DARK: Record<FoldBarKind, string> = {
|
|
42
|
-
consolidated: "#6C89C7", span: "#5B919F", tool: "#729261",
|
|
43
|
-
pinned: "#B38E2F", marked: "#FBA689", raw: "#FACCFA",
|
|
44
|
-
};
|
|
45
|
-
const ON_LIGHT: Record<FoldBarKind, string> = {
|
|
46
|
-
consolidated: "#011959", span: "#185562", tool: "#577647",
|
|
47
|
-
pinned: "#927012", marked: "#AA624A", raw: "#8F6891",
|
|
48
|
-
};
|
|
49
|
-
|
|
50
283
|
export function lightBackground(theme: FoldBarTheme): boolean {
|
|
51
284
|
try {
|
|
52
285
|
const match = /38;2;(\d+);(\d+);(\d+)/.exec(theme.getFgAnsi?.("text") ?? "");
|
|
@@ -65,7 +298,9 @@ const truecolor = (hex: string, text: string): string => {
|
|
|
65
298
|
/** Two half-cell samples per terminal column. The measured fill never grows to fit
|
|
66
299
|
* inventory. Each nonempty CATEGORY gets one half-cell when there is room, then quotas
|
|
67
300
|
* follow relative mass. This is a visibility floor, not a per-fold width or token-exact
|
|
68
|
-
* proportion.
|
|
301
|
+
* proportion. A guide reserves the RIGHT half of its column only: the hairline is drawn
|
|
302
|
+
* at that column's right edge, which is the exact share the guide names, over whatever
|
|
303
|
+
* colour the left half holds, so a guide costs one half-cell and erases no category.
|
|
69
304
|
*/
|
|
70
305
|
export function foldBarCells(model: FoldBarModel, width = FOLD_BAR_WIDTH): Array<FoldBarKind | "empty" | "unknown" | "tick"> {
|
|
71
306
|
const samples = 2 * width;
|
|
@@ -73,7 +308,7 @@ export function foldBarCells(model: FoldBarModel, width = FOLD_BAR_WIDTH): Array
|
|
|
73
308
|
const ticks = foldBarTicks(model, width);
|
|
74
309
|
const total = FOLD_BAR_KINDS.reduce((sum, kind) => sum + model.mass[kind], 0);
|
|
75
310
|
const cells: Array<FoldBarKind | "empty" | "unknown" | "tick"> = Array.from({ length: samples }, (_, i) =>
|
|
76
|
-
ticks.has(Math.floor(i / 2)) ? "tick" : i < filled ? "unknown" : "empty");
|
|
311
|
+
i % 2 === 1 && ticks.has(Math.floor(i / 2)) ? "tick" : i < filled ? "unknown" : "empty");
|
|
77
312
|
const slots = cells.flatMap((kind, i) => kind === "unknown" ? [i] : []);
|
|
78
313
|
if (!model.mapped || total <= 0 || !slots.length) return cells;
|
|
79
314
|
const allocation = FOLD_BAR_KINDS.filter((kind) => model.mass[kind] > 0)
|
|
@@ -99,9 +334,16 @@ export function foldBarCells(model: FoldBarModel, width = FOLD_BAR_WIDTH): Array
|
|
|
99
334
|
}
|
|
100
335
|
return cells;
|
|
101
336
|
}
|
|
337
|
+
/** Each guide stands in the band's own edge cell: the aim in the column whose LEFT edge
|
|
338
|
+
* is the aim share, the commit in the column whose RIGHT edge is the commit share, so
|
|
339
|
+
* the band is the run of cells from one mark to the other inclusive. When both land on
|
|
340
|
+
* one column the commit wins, since it is the one the label also names. */
|
|
102
341
|
export function foldBarTicks(model: FoldBarModel, width = FOLD_BAR_WIDTH): Map<number, "aim" | "commit"> {
|
|
103
|
-
const
|
|
104
|
-
return new Map([
|
|
342
|
+
const column = (value: number): number => Math.max(0, Math.min(width - 1, value));
|
|
343
|
+
return new Map([
|
|
344
|
+
[column(Math.round(model.aimShare * width)), "aim"],
|
|
345
|
+
[column(Math.round(model.commitShare * width) - 1), "commit"],
|
|
346
|
+
]);
|
|
105
347
|
}
|
|
106
348
|
export function foldBarPlainText(model: FoldBarModel): string {
|
|
107
349
|
return renderFoldBar(model, Number.POSITIVE_INFINITY, { fg: (_c, t) => t, bold: (t) => t });
|
|
@@ -115,37 +357,73 @@ export function renderFoldBar(model: FoldBarModel, width: number, theme: FoldBar
|
|
|
115
357
|
if (model.stopped) return cut(`${brand} ${theme.fg("error", theme.bold("FOLDING STOPPED"))}` +
|
|
116
358
|
(model.share === null ? "" : neutral(` · ${Math.round(model.share * 100)}% full`)));
|
|
117
359
|
if (model.share === null) return cut(`${brand} ${muted(`not measured yet · folds automatically at ${Math.round(model.commitShare * 100)}%`)}`);
|
|
118
|
-
const
|
|
360
|
+
const truecolorMode = theme.getColorMode?.() === "truecolor";
|
|
361
|
+
const dark = !lightBackground(theme);
|
|
362
|
+
const shades = foldBarShades(model.palette ?? DEFAULT_FOLD_BAR_PALETTE, dark);
|
|
363
|
+
// Without truecolor the theme's own inks stand in: pinned takes the accent, raw the
|
|
364
|
+
// muted ink, everything compressed or marked the text ink.
|
|
119
365
|
const ink = (kind: FoldBarKind, text: string): string => {
|
|
120
|
-
if (
|
|
121
|
-
|
|
122
|
-
return theme.fg(kind === "raw" ? "muted" : "text", text);
|
|
366
|
+
if (truecolorMode) return truecolor(shades[kind], text);
|
|
367
|
+
return theme.fg(kind === "pinned" ? "accent" : kind === "raw" ? "muted" : "text", text);
|
|
123
368
|
};
|
|
124
369
|
const cells = foldBarCells(model);
|
|
370
|
+
const ticks = foldBarTicks(model);
|
|
371
|
+
// THE TRACK IS SOLID (Shane 2026-09-06, fourth pass): a guide cell on a ░ track was a
|
|
372
|
+
// blank cell with a bracket in it, which read as a hole. With an RGB dim ink the empty
|
|
373
|
+
// track is one shade, the dim ink pulled most of the way to the reference background
|
|
374
|
+
// so it sits where ░ used to, and a bracket on it stands on that same shade. Without
|
|
375
|
+
// an RGB dim ink the ░ texture stays, and the guide cell there is bare.
|
|
376
|
+
const track = trackShade(theme, dark);
|
|
377
|
+
const trackBg = track ? truecolor(track, "").replace("[38;", "[48;") : null;
|
|
125
378
|
const solid = (kind: typeof cells[number]): string => {
|
|
126
|
-
if (kind === "
|
|
127
|
-
if (kind === "empty") return theme.fg("dim", "░");
|
|
379
|
+
if (kind === "empty") return trackBg ? `${trackBg} \x1b[49m` : theme.fg("dim", "░");
|
|
128
380
|
if (kind === "unknown") return muted("█");
|
|
129
|
-
return ink(kind, "█");
|
|
381
|
+
return ink(kind as FoldBarKind, "█");
|
|
130
382
|
};
|
|
131
|
-
const background = (kind: FoldBarKind): string | null => {
|
|
132
|
-
if (
|
|
133
|
-
// Pi exposes the same theme ink in 256-colour mode
|
|
134
|
-
// to background, without assuming an RGB theme or
|
|
135
|
-
|
|
383
|
+
const background = (kind: FoldBarKind | "unknown"): string | null => {
|
|
384
|
+
if (truecolorMode && kind !== "unknown") return truecolor(shades[kind], "").replace("[38;", "[48;");
|
|
385
|
+
// Pi exposes the same theme ink in 256-colour mode, and the accent for a pin there.
|
|
386
|
+
// Convert its foreground escape to background, without assuming an RGB theme or
|
|
387
|
+
// leaking styles into the label.
|
|
388
|
+
const fg = theme.getFgAnsi?.(kind === "pinned" ? "accent" : kind === "raw" || kind === "unknown" ? "muted" : "text") ?? "";
|
|
136
389
|
const extended = /^\x1b\[38;(?:2;\d+;\d+;\d+|5;\d+)m$/.test(fg);
|
|
137
390
|
if (extended) return fg.replace("[38;", "[48;");
|
|
138
391
|
const basic = /^\x1b\[(3[0-7]|9[0-7])m$/.exec(fg);
|
|
139
392
|
return basic ? `\x1b[${Number(basic[1]) + 10}m` : null;
|
|
140
393
|
};
|
|
394
|
+
// THE GUIDES ARE MARKS ON THE AXIS, NOT BREAKS IN THE FILL (Shane 2026-09-06, third
|
|
395
|
+
// pass). A hairline in text ink read as a white bar; a notch cut into the fill read as
|
|
396
|
+
// a boundary between two kinds of content, which a threshold is not. The band is an
|
|
397
|
+
// interval, so a dotted mark stands in each of its edge cells, drawn over the fill's
|
|
398
|
+
// own colour in that colour's own hue pulled toward the background. No hue of its
|
|
399
|
+
// own, since a coloured guide competes with a category; never the warning ink, since
|
|
400
|
+
// folding is automatic and the person has nothing to do about it. On the track the
|
|
401
|
+
// muted ink stands on the track's shade; without truecolor the mark stands on the
|
|
402
|
+
// default background, because the theme's inks are the fill.
|
|
403
|
+
const shadeUnder = (left: typeof cells[number]): string | null =>
|
|
404
|
+
truecolorMode && (FOLD_BAR_KINDS as readonly string[]).includes(left) ? shades[left as FoldBarKind] : null;
|
|
405
|
+
const guide = (column: number, left: typeof cells[number]): string => {
|
|
406
|
+
const glyph = GUIDE_GLYPHS[ticks.get(column) ?? "aim"];
|
|
407
|
+
const under = shadeUnder(left);
|
|
408
|
+
if (under) return `${background(left as FoldBarKind)}${truecolor(guideInk(under, dark), glyph)}\x1b[49m`;
|
|
409
|
+
if (left === "empty" && trackBg) return `${trackBg}${theme.fg("muted", glyph)}\x1b[49m`;
|
|
410
|
+
return theme.fg("muted", glyph);
|
|
411
|
+
};
|
|
141
412
|
let bar = "";
|
|
142
413
|
for (let i = 0; i < cells.length; i += 2) {
|
|
143
414
|
const left = cells[i], right = cells[i + 1];
|
|
415
|
+
if (right === "tick") {
|
|
416
|
+
// The mark over the left half's own colour: the guide stands in the band's edge
|
|
417
|
+
// cell and hides no fill. Off the fill it stands on the track.
|
|
418
|
+
bar += guide(i / 2, left);
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
144
421
|
if (left === right) { bar += solid(left); continue; }
|
|
145
422
|
// One full-height left-half glyph, right colour supplied by the cell background:
|
|
146
423
|
// two colours in one column, never a notch, gap, score, or extra terminal column.
|
|
147
424
|
if (right === "empty") {
|
|
148
|
-
|
|
425
|
+
const half = left === "unknown" ? muted("▌") : ink(left as FoldBarKind, "▌");
|
|
426
|
+
bar += trackBg ? `${trackBg}${half}\x1b[49m` : half;
|
|
149
427
|
} else {
|
|
150
428
|
const bg = background(right as FoldBarKind);
|
|
151
429
|
bar += bg ? `${bg}${ink(left as FoldBarKind, "▌")}\x1b[49m` : solid(left);
|
|
@@ -160,13 +438,18 @@ export function renderFoldBar(model: FoldBarModel, width: number, theme: FoldBar
|
|
|
160
438
|
parts.push(neutral(`${pct}%`), muted(when));
|
|
161
439
|
}
|
|
162
440
|
if (model.mapped) {
|
|
163
|
-
// N counts compressed folds. Pin
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
441
|
+
// N counts compressed folds. Mark/Pin are accompanying states, not added to N, and
|
|
442
|
+
// they are named in the bar's own order. A kind with nothing to count is not named
|
|
443
|
+
// (Shane 2026-09-06): the row's width is the scarce thing, and "0 Tool" tells a
|
|
444
|
+
// person nothing the bar does not already show by its absence.
|
|
445
|
+
const counts = ([
|
|
446
|
+
["consolidated", model.foldConsolidations, "Cons."], ["span", model.foldSpans, "Span"],
|
|
447
|
+
["tool", model.foldTruncations, "Tool"], ["marked", model.stagedMarks, "Mark"],
|
|
448
|
+
["pinned", model.pinnedRefs, "Pin"],
|
|
449
|
+
] as const).filter(([, count]) => count > 0).map(([kind, count, word]) => ink(kind, `${count} ${word}`));
|
|
450
|
+
parts.push(counts.length
|
|
451
|
+
? neutral(`${model.folds} Folds (`) + counts.join(muted(", ")) + neutral(")")
|
|
452
|
+
: neutral(`${model.folds} Folds`));
|
|
170
453
|
if (model.unplacedItems > 0) parts.push(muted(`${model.unplacedItems} not mapped`));
|
|
171
454
|
} else parts.push(muted("mapping"));
|
|
172
455
|
return cut(`${bar} ${parts.join(theme.fg("dim", " · "))}`);
|
package/extensions/settings.ts
CHANGED
|
@@ -29,6 +29,13 @@ import {
|
|
|
29
29
|
type ActiveContextThresholds,
|
|
30
30
|
} from "./lib/policy.ts";
|
|
31
31
|
import { applyLiveSettings, liveSettingsReachable } from "./lib/live-settings.ts";
|
|
32
|
+
import {
|
|
33
|
+
COLOUR_MAP_NAMES,
|
|
34
|
+
DEFAULT_FOLD_BAR_PALETTE,
|
|
35
|
+
FoldBarPaletteError,
|
|
36
|
+
resolveFoldBarPalette,
|
|
37
|
+
type FoldBarPalette,
|
|
38
|
+
} from "./lib/status-widget.ts";
|
|
32
39
|
|
|
33
40
|
export const DEFAULT_FOLD_SETTINGS_PATH = join(
|
|
34
41
|
homedir(),
|
|
@@ -47,6 +54,11 @@ export interface FoldSettingsFile {
|
|
|
47
54
|
toolFoldThreshold?: number;
|
|
48
55
|
preCommitNotice?: boolean;
|
|
49
56
|
noticeLeadShare?: number;
|
|
57
|
+
// THE BAR'S COLOURS, stored whole for the same reason the thresholds are (2026-09-06):
|
|
58
|
+
// start and end may not coincide, which is a cross-field invariant, so a partial object
|
|
59
|
+
// would mean whatever the defaults are at read time. Absent means the package default.
|
|
60
|
+
// It is a display preference and moves nothing about folding.
|
|
61
|
+
palette?: FoldBarPalette;
|
|
50
62
|
}
|
|
51
63
|
|
|
52
64
|
// The four settings, named once. This was a hand-written union until the migration
|
|
@@ -63,11 +75,17 @@ const THRESHOLD_FIELDS = [
|
|
|
63
75
|
// validation and no relationship to any other value on the screen.
|
|
64
76
|
const SCALAR_FIELDS = ["toolFoldThreshold", "preCommitNotice", "noticeLeadShare"] as const;
|
|
65
77
|
|
|
66
|
-
|
|
78
|
+
// The three rows that edit the palette object, each naming the field it moves.
|
|
79
|
+
const PALETTE_FIELDS = { paletteMap: "map", paletteStart: "start", paletteEnd: "end" } as const;
|
|
80
|
+
|
|
81
|
+
export type FoldSettingId = typeof THRESHOLD_FIELDS[number] | typeof SCALAR_FIELDS[number] | keyof typeof PALETTE_FIELDS;
|
|
67
82
|
|
|
68
83
|
function isThresholdField(id: FoldSettingId): id is typeof THRESHOLD_FIELDS[number] {
|
|
69
84
|
return (THRESHOLD_FIELDS as readonly string[]).includes(id);
|
|
70
85
|
}
|
|
86
|
+
function isPaletteField(id: FoldSettingId): id is keyof typeof PALETTE_FIELDS {
|
|
87
|
+
return Object.hasOwn(PALETTE_FIELDS, id);
|
|
88
|
+
}
|
|
71
89
|
|
|
72
90
|
// One edit, applied against the WHOLE draft: the merged thresholds object is
|
|
73
91
|
// re-validated through resolveThresholds so cross-field invariants
|
|
@@ -105,6 +123,20 @@ export function applyFoldSettingsEdit(
|
|
|
105
123
|
}
|
|
106
124
|
return { ok: true, draft: { ...draft, toolFoldThreshold: value } };
|
|
107
125
|
}
|
|
126
|
+
if (isPaletteField(id)) {
|
|
127
|
+
// The palette is re-validated WHOLE through the runtime's own resolver, so the
|
|
128
|
+
// cross-field rule (start and end differ) holds at every saved state.
|
|
129
|
+
const field = PALETTE_FIELDS[id];
|
|
130
|
+
const raw = rawValue.trim();
|
|
131
|
+
const value = field === "map" ? raw : Number(raw);
|
|
132
|
+
try {
|
|
133
|
+
const palette = resolveFoldBarPalette({ ...(draft.palette ?? DEFAULT_FOLD_BAR_PALETTE), [field]: value });
|
|
134
|
+
return { ok: true, draft: { ...draft, palette } };
|
|
135
|
+
} catch (error) {
|
|
136
|
+
if (error instanceof FoldBarPaletteError) return { ok: false, error: error.message };
|
|
137
|
+
throw error;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
108
140
|
try {
|
|
109
141
|
const value = Number(rawValue.trim());
|
|
110
142
|
if ((id === "consolidateAfter" || id === "minFoldChars") && !Number.isSafeInteger(value)) {
|
|
@@ -209,10 +241,10 @@ export function readFoldSettingsFile(path: string = DEFAULT_FOLD_SETTINGS_PATH):
|
|
|
209
241
|
dropped = true;
|
|
210
242
|
}
|
|
211
243
|
for (const key of Object.keys(parsed)) {
|
|
212
|
-
if (key === "thresholds" || (SCALAR_FIELDS as readonly string[]).includes(key)) continue;
|
|
244
|
+
if (key === "thresholds" || key === "palette" || (SCALAR_FIELDS as readonly string[]).includes(key)) continue;
|
|
213
245
|
if (RETIRED_FILE_KEYS.includes(key)) { delete parsed[key]; dropped = true; continue; }
|
|
214
246
|
return refused(`fold settings file has no ${key} field: the surface is ` +
|
|
215
|
-
`thresholds, ${SCALAR_FIELDS.join(", ")}`);
|
|
247
|
+
`thresholds, ${SCALAR_FIELDS.join(", ")}, palette`);
|
|
216
248
|
}
|
|
217
249
|
// The two scalars are validated the same way the editor validates them, through the
|
|
218
250
|
// one path, so a hand-edited file cannot hold a value the screen would refuse.
|
|
@@ -232,6 +264,12 @@ export function readFoldSettingsFile(path: string = DEFAULT_FOLD_SETTINGS_PATH):
|
|
|
232
264
|
}
|
|
233
265
|
Object.assign(scalars, applied.draft);
|
|
234
266
|
}
|
|
267
|
+
// The palette goes through the one resolver the runtime and the editor use, so a
|
|
268
|
+
// hand-edited file cannot hold a map name or a range the screen would refuse.
|
|
269
|
+
if (parsed.palette !== undefined) {
|
|
270
|
+
try { scalars.palette = resolveFoldBarPalette(parsed.palette); }
|
|
271
|
+
catch (error: any) { return refused(`fold settings palette is invalid: ${error?.message ?? error}`); }
|
|
272
|
+
}
|
|
235
273
|
if (parsed.thresholds === undefined) {
|
|
236
274
|
if (dropped) { try { saveFoldSettingsFile(path, scalars); } catch { } }
|
|
237
275
|
return { settings: scalars, refusal: null, migrated: dropped };
|
|
@@ -271,6 +309,7 @@ export function saveFoldSettingsFile(path: string, settings: FoldSettingsFile):
|
|
|
271
309
|
if (settings.toolFoldThreshold !== undefined) clean.toolFoldThreshold = settings.toolFoldThreshold;
|
|
272
310
|
if (settings.preCommitNotice !== undefined) clean.preCommitNotice = settings.preCommitNotice;
|
|
273
311
|
if (settings.noticeLeadShare !== undefined) clean.noticeLeadShare = settings.noticeLeadShare;
|
|
312
|
+
if (settings.palette !== undefined) clean.palette = settings.palette;
|
|
274
313
|
mkdirSync(dirname(path), { recursive: true });
|
|
275
314
|
const temporary = `${path}.tmp`;
|
|
276
315
|
writeFileSync(temporary, `${JSON.stringify(clean, null, 2)}\n`);
|
|
@@ -297,6 +336,12 @@ const EDITOR_ROWS: readonly EditorRow[] = [
|
|
|
297
336
|
{ id: "toolFoldThreshold", label: "Clip old tool results", description: "The oldest share of the window shows tool results shortened, still recoverable in full. Off keeps them whole (toolFoldThreshold)" },
|
|
298
337
|
{ id: "preCommitNotice", label: "Tell the model before folding", description: "Show the model the window's status once as a fold approaches: what is staged, what is pinned, what it can change. Off is what the measured runs used (preCommitNotice)" },
|
|
299
338
|
{ id: "noticeLeadShare", label: "How early to tell it", description: "How far below the folding point that status appears. Only used when the notice is on (noticeLeadShare)" },
|
|
339
|
+
// THE BAR'S COLOURS ARE A CHOICE (Shane 2026-09-06). Three rows over one stored object:
|
|
340
|
+
// which map, and where on it the two ends of the ramp sit. The status line repaints on
|
|
341
|
+
// every step, so the bar itself is the preview.
|
|
342
|
+
{ id: "paletteMap", label: "Bar colours", description: "The colour map the status bar reads its six shades from, most compressed first and pinned last (palette.map)" },
|
|
343
|
+
{ id: "paletteStart", label: "Folded shade at", description: "Where on that map the most compressed content sits. Put it past the pinned end to run the map backwards (palette.start)" },
|
|
344
|
+
{ id: "paletteEnd", label: "Pinned shade at", description: "Where on that map pinned content sits, with raw just before it. The shades between are spaced evenly (palette.end)" },
|
|
300
345
|
];
|
|
301
346
|
|
|
302
347
|
// The cycle lattice for both shares. Shares step in cents so no float drift reaches a
|
|
@@ -326,6 +371,9 @@ const MIN_FOLD_CHOICES = [2_000, 4_000, 6_000, 8_000, 12_000, 16_000, 24_000, 32
|
|
|
326
371
|
// off: without a 0 here the only way to decline in-view clipping would be the file.
|
|
327
372
|
const TOOL_FOLD_CHOICES = [0, 0.25, 0.35, 0.50, 0.65, 0.75, 0.90];
|
|
328
373
|
|
|
374
|
+
// Positions on a colour map, the whole of it in twentieths, ends included.
|
|
375
|
+
const MAP_POSITION_CHOICES = Array.from({ length: 21 }, (_, i) => i / 20);
|
|
376
|
+
|
|
329
377
|
function shareCandidates(): number[] {
|
|
330
378
|
const { min, max, step } = SHARE_LATTICE;
|
|
331
379
|
const values: number[] = [];
|
|
@@ -337,8 +385,13 @@ function shareCandidates(): number[] {
|
|
|
337
385
|
|
|
338
386
|
// The lattice a row steps along, as numbers. Display belongs to rowDisplayValue and
|
|
339
387
|
// only to it: this list is stepped, never shown.
|
|
340
|
-
function allowedValues(id: FoldSettingId, thresholds: ActiveContextThresholds): number[] {
|
|
388
|
+
function allowedValues(id: FoldSettingId, thresholds: ActiveContextThresholds, palette: FoldBarPalette): number[] {
|
|
341
389
|
if (id === "toolFoldThreshold") return TOOL_FOLD_CHOICES;
|
|
390
|
+
// Each end of the ramp may step anywhere on the map except onto the other end: the
|
|
391
|
+
// cross-field rule is the filter, so stepping across the other end is one keypress.
|
|
392
|
+
if (id === "paletteStart") return MAP_POSITION_CHOICES.filter((v) => Math.abs(v - palette.end) > 1e-9);
|
|
393
|
+
if (id === "paletteEnd") return MAP_POSITION_CHOICES.filter((v) => Math.abs(v - palette.start) > 1e-9);
|
|
394
|
+
if (id === "paletteMap") return [];
|
|
342
395
|
// THE LEAD STEPS THE ONE SHARE LATTICE, unfiltered. It answers to nothing but its own
|
|
343
396
|
// range: a lead wider than the trigger is legal and means "speak from the first
|
|
344
397
|
// measurement", which the runtime floors rather than refuses, so there is no
|
|
@@ -359,6 +412,7 @@ function rowRawValue(settings: FoldSettingsFile, id: FoldSettingId): string {
|
|
|
359
412
|
// A SCALAR THAT IS ABSENT READS AS THE PACKAGE DEFAULT, never as zero or false. The
|
|
360
413
|
// file omits what was never set on purpose, so the screen has to supply the same
|
|
361
414
|
// value the runtime would, or the row would show a setting nobody chose.
|
|
415
|
+
if (isPaletteField(id)) return String((settings.palette ?? DEFAULT_FOLD_BAR_PALETTE)[PALETTE_FIELDS[id]]);
|
|
362
416
|
if (!isThresholdField(id)) {
|
|
363
417
|
if (id === "toolFoldThreshold") return String(settings.toolFoldThreshold ?? DEFAULT_TOOL_FOLD_THRESHOLD);
|
|
364
418
|
if (id === "noticeLeadShare") return String(settings.noticeLeadShare ?? DEFAULT_NOTICE_LEAD_SHARE);
|
|
@@ -384,6 +438,13 @@ function rowDisplayValue(settings: FoldSettingsFile, id: FoldSettingId, budgetTo
|
|
|
384
438
|
}
|
|
385
439
|
if (id === "consolidateAfter") return raw;
|
|
386
440
|
if (id === "minFoldChars") return `${Number(raw).toLocaleString("en-US")} characters`;
|
|
441
|
+
if (id === "paletteMap") return raw;
|
|
442
|
+
if (id === "paletteStart" || id === "paletteEnd") {
|
|
443
|
+
// A POSITION ON THE MAP, not a share of the window: the percentages elsewhere on this
|
|
444
|
+
// screen are occupancy, and this one is not, so it says what it is along.
|
|
445
|
+
const position = Number(raw);
|
|
446
|
+
return position === 0 ? "start of the map" : position === 1 ? "end of the map" : `${Math.round(position * 100)}% along the map`;
|
|
447
|
+
}
|
|
387
448
|
// A PERCENTAGE, like every other human surface. The screen used to read "0.80" while
|
|
388
449
|
// the status line, /fold-status and the editor header all said "80%", so the one place
|
|
389
450
|
// a person CHANGES the number spoke a different dialect from the three that report it.
|
|
@@ -539,8 +600,14 @@ export class FoldSettingsEditor extends Container {
|
|
|
539
600
|
this.applyAndSave(id, rowRawValue(this.draft, id) === "true" ? "false" : "true");
|
|
540
601
|
return;
|
|
541
602
|
}
|
|
603
|
+
// A NAMED LIST steps by index, clamped at its ends like every other row.
|
|
604
|
+
if (id === "paletteMap") {
|
|
605
|
+
const at = (COLOUR_MAP_NAMES as readonly string[]).indexOf(rowRawValue(this.draft, id)) + direction;
|
|
606
|
+
if (at >= 0 && at < COLOUR_MAP_NAMES.length) this.applyAndSave(id, COLOUR_MAP_NAMES[at]);
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
542
609
|
const thresholds = this.draft.thresholds ?? DEFAULT_THRESHOLDS;
|
|
543
|
-
const candidates = allowedValues(id, thresholds);
|
|
610
|
+
const candidates = allowedValues(id, thresholds, this.draft.palette ?? DEFAULT_FOLD_BAR_PALETTE);
|
|
544
611
|
const current = Number(rowRawValue(this.draft, id));
|
|
545
612
|
const target = direction > 0
|
|
546
613
|
? candidates.find((candidate) => candidate > current + 1e-9)
|
|
@@ -595,7 +662,7 @@ export function registerFoldSettings(
|
|
|
595
662
|
): void {
|
|
596
663
|
const settingsPath = options.settingsPath ?? DEFAULT_FOLD_SETTINGS_PATH;
|
|
597
664
|
pi.registerCommand("fold-settings", {
|
|
598
|
-
description: "Configure pi-fold: commit band, consolidation, fold size, tool-result clipping,
|
|
665
|
+
description: "Configure pi-fold: commit band, consolidation, fold size, tool-result clipping, the pre-commit notice, bar colours",
|
|
599
666
|
handler: async (_args: string, ctx: any) => {
|
|
600
667
|
if (typeof ctx.ui?.custom !== "function") {
|
|
601
668
|
throw new Error("/fold-settings needs an interactive UI; set thresholds in the settings file instead");
|