peaks-loop 4.0.8 → 4.0.9
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/CHANGELOG.md +12 -0
- package/dist/cli/commands/_register.js +2 -0
- package/dist/cli/commands/baseline-commands.d.ts +3 -0
- package/dist/cli/commands/baseline-commands.js +146 -0
- package/dist/cli/commands/statusline-commands.js +1 -1
- package/dist/services/capability-audit-service/cross-check.d.ts +8 -0
- package/dist/services/capability-audit-service/cross-check.js +11 -0
- package/dist/services/capability-audit-service/index.d.ts +3 -0
- package/dist/services/capability-audit-service/index.js +2 -0
- package/dist/services/capability-audit-service/runner.d.ts +27 -0
- package/dist/services/capability-audit-service/runner.js +40 -0
- package/dist/services/capability-audit-service/staleness.d.ts +1 -0
- package/dist/services/capability-audit-service/staleness.js +7 -0
- package/dist/services/capability-audit-service/types.d.ts +24 -0
- package/dist/services/capability-audit-service/types.js +1 -0
- package/dist/services/capability-baseline/store.d.ts +33 -0
- package/dist/services/capability-baseline/store.js +80 -0
- package/dist/services/capability-baseline/types.d.ts +45 -0
- package/dist/services/capability-baseline/types.js +5 -0
- package/dist/services/capability-baseline/validator.d.ts +19 -0
- package/dist/services/capability-baseline/validator.js +38 -0
- package/dist/services/capability-guard-runner/contracts/J01.d.ts +2 -0
- package/dist/services/capability-guard-runner/contracts/J01.js +48 -0
- package/dist/services/capability-guard-runner/contracts/J02.d.ts +2 -0
- package/dist/services/capability-guard-runner/contracts/J02.js +34 -0
- package/dist/services/capability-guard-runner/contracts/J03.d.ts +2 -0
- package/dist/services/capability-guard-runner/contracts/J03.js +24 -0
- package/dist/services/capability-guard-runner/contracts/J04.d.ts +2 -0
- package/dist/services/capability-guard-runner/contracts/J04.js +40 -0
- package/dist/services/capability-guard-runner/contracts/J05.d.ts +2 -0
- package/dist/services/capability-guard-runner/contracts/J05.js +21 -0
- package/dist/services/capability-guard-runner/contracts/J06.d.ts +2 -0
- package/dist/services/capability-guard-runner/contracts/J06.js +43 -0
- package/dist/services/capability-guard-runner/contracts/J07.d.ts +2 -0
- package/dist/services/capability-guard-runner/contracts/J07.js +53 -0
- package/dist/services/capability-guard-runner/contracts/J08.d.ts +2 -0
- package/dist/services/capability-guard-runner/contracts/J08.js +43 -0
- package/dist/services/capability-guard-runner/contracts/J09.d.ts +2 -0
- package/dist/services/capability-guard-runner/contracts/J09.js +44 -0
- package/dist/services/capability-guard-runner/contracts/J10.d.ts +2 -0
- package/dist/services/capability-guard-runner/contracts/J10.js +40 -0
- package/dist/services/capability-guard-runner/contracts/J11.d.ts +2 -0
- package/dist/services/capability-guard-runner/contracts/J11.js +39 -0
- package/dist/services/capability-guard-runner/contracts/J12.d.ts +2 -0
- package/dist/services/capability-guard-runner/contracts/J12.js +34 -0
- package/dist/services/capability-guard-runner/contracts/J13.d.ts +2 -0
- package/dist/services/capability-guard-runner/contracts/J13.js +45 -0
- package/dist/services/capability-guard-runner/contracts/J14.d.ts +2 -0
- package/dist/services/capability-guard-runner/contracts/J14.js +36 -0
- package/dist/services/capability-guard-runner/contracts/J15.d.ts +2 -0
- package/dist/services/capability-guard-runner/contracts/J15.js +40 -0
- package/dist/services/capability-guard-runner/diff.d.ts +2 -0
- package/dist/services/capability-guard-runner/diff.js +7 -0
- package/dist/services/capability-guard-runner/runner.d.ts +9 -0
- package/dist/services/capability-guard-runner/runner.js +22 -0
- package/dist/services/capability-guard-runner/types.d.ts +39 -0
- package/dist/services/capability-guard-runner/types.js +1 -0
- package/dist/services/evolution/evolution-types.d.ts +16 -16
- package/dist/services/final-review/final-review-service.d.ts +8 -0
- package/dist/services/final-review/final-review-service.js +14 -0
- package/dist/services/final-review/final-review-types.d.ts +1 -1
- package/dist/services/job/job-progress-store.d.ts +2 -2
- package/dist/services/job/job-types.d.ts +26 -26
- package/dist/services/observability/observability-service.d.ts +2 -2
- package/dist/services/sediment/json-schema.d.ts +4 -4
- package/dist/services/session/session-resume-service.d.ts +4 -0
- package/dist/services/session/session-resume-service.js +4 -0
- package/dist/services/skills/skill-statusline-renderer.d.ts +46 -56
- package/dist/services/skills/skill-statusline-renderer.js +312 -100
- package/package.json +5 -4
|
@@ -6,6 +6,10 @@
|
|
|
6
6
|
* prepend to its own prompt. The output is LLM-friendly: stable
|
|
7
7
|
* headings, monospace paths, and bullet lists — easy for the model to
|
|
8
8
|
* scan and reason about.
|
|
9
|
+
*
|
|
10
|
+
* Resume locates the deepestGate (the latest completed gate in the
|
|
11
|
+
* workflow) so the skill can pick up the next step without re-asking
|
|
12
|
+
* the user for state already captured at that gate.
|
|
9
13
|
*/
|
|
10
14
|
import { existsSync, readFileSync } from 'node:fs';
|
|
11
15
|
import { basename, sep } from 'node:path';
|
|
@@ -14,66 +14,44 @@ export type StatusLineCapability = 'ansi-unicode' | 'unicode' | 'ascii';
|
|
|
14
14
|
export interface StatusLineRenderOptions {
|
|
15
15
|
readonly capability: StatusLineCapability;
|
|
16
16
|
}
|
|
17
|
-
/**
|
|
18
|
-
* Resolve the capability tier from environment and TTY state. Pure and
|
|
19
|
-
* deterministic — no I/O beyond reading the caller-supplied `env` and
|
|
20
|
-
* `isTTY` flag. The CLI delegates to this so the read-only status line
|
|
21
|
-
* does not need to know about ANSI/NO_COLOR semantics.
|
|
22
|
-
*
|
|
23
|
-
* The optional `forced` argument is a TEST SEAM (consumed by direct unit
|
|
24
|
-
* tests, not by the CLI). It lets tests pin a tier without constructing a
|
|
25
|
-
* TTY/NO_COLOR fixture. The CLI does NOT expose any flag that maps to
|
|
26
|
-
* `forced` — the env-driven path is the single first-version runtime
|
|
27
|
-
* source of truth.
|
|
28
|
-
*
|
|
29
|
-
* Adapter-internal env override (NOT a user-facing CLI flag):
|
|
30
|
-
* `PEAKS_STATUSLINE_ASCII` — when set to `1` / `true` / `yes`, the
|
|
31
|
-
* renderer drops to the ASCII palette (no Unicode-extra glyphs, no ANSI).
|
|
32
|
-
* This is an adapter-internal mechanism for the trust boundary described
|
|
33
|
-
* by the two-forms-only / human-NL-choice-only tenets: the user never
|
|
34
|
-
* types a CLI verb to flip palettes, but the adapter (and only the
|
|
35
|
-
* adapter) can set the env var on the user's behalf when its consumer
|
|
36
|
-
* (e.g. a tiny terminal without UTF-8) needs ASCII. We do NOT expose
|
|
37
|
-
* this via a CLI flag.
|
|
38
|
-
*
|
|
39
|
-
* Order of resolution (highest priority first):
|
|
40
|
-
*
|
|
41
|
-
* 1. `forced` argument (test seam only)
|
|
42
|
-
* 2. `PEAKS_STATUSLINE_ASCII` set → `ascii` (adapter-internal override)
|
|
43
|
-
* 3. `isTTY === true` → `ansi-unicode`
|
|
44
|
-
* 4. otherwise → `unicode` (still ANSI-colored; only `ascii` is color-free)
|
|
45
|
-
*
|
|
46
|
-
* Why this ordering: the IDE statusline invokes the renderer through a
|
|
47
|
-
* hook that may not always be detected as a TTY. The `unicode` tier
|
|
48
|
-
* now emits the cyan brand accent + semantic colors so the line reads
|
|
49
|
-
* as a branded product surface whether or not the consumer is a
|
|
50
|
-
* terminal. `NO_COLOR` is honored as an ambient convention by the
|
|
51
|
-
* IDE adapter (which can set `PEAKS_STATUSLINE_ASCII=1` to opt out
|
|
52
|
-
* fully), so a separate tier for NO_COLOR is no longer needed.
|
|
53
|
-
*
|
|
54
|
-
* `ascii` is the strict no-color, no-Unicode-extra tier; it is the
|
|
55
|
-
* single safe option for plain log / file consumers.
|
|
56
|
-
*
|
|
57
|
-
* Why `PEAKS_STATUSLINE_ASCII` outranks `NO_COLOR`:
|
|
58
|
-
* The two env vars are NOT redundant. `NO_COLOR` (https://no-color.org)
|
|
59
|
-
* is the cross-industry signal that NO escape sequences should be emitted
|
|
60
|
-
* — its only effect is "ANSI off". The Unicode-extra glyphs (●, █, ░, etc.)
|
|
61
|
-
* are NOT ANSI escape sequences; they are UTF-8 characters and `NO_COLOR`
|
|
62
|
-
* does not address them. `PEAKS_STATUSLINE_ASCII` is the adapter-internal
|
|
63
|
-
* "signal source is a tiny terminal without UTF-8" override and drops the
|
|
64
|
-
* glyphs to ASCII shape as well. Both are correct signals about
|
|
65
|
-
* different concerns; the ASCII override wins because it is strictly
|
|
66
|
-
* narrower (a tiny terminal needs both no-ANSI AND no-Unicode-extra).
|
|
67
|
-
*
|
|
68
|
-
* The deliberate ordering keeps the rendered text ANSI-free when the consumer
|
|
69
|
-
* is a logger, a file, or any non-interactive sink, and only enables ANSI
|
|
70
|
-
* under an explicit supported condition (TTY + no env veto).
|
|
71
|
-
*/
|
|
72
17
|
export declare function resolveStatusLineCapability(input: {
|
|
73
18
|
readonly env: NodeJS.ProcessEnv;
|
|
74
19
|
readonly isTTY: boolean;
|
|
75
20
|
readonly forced?: StatusLineCapability;
|
|
76
21
|
}): StatusLineCapability;
|
|
22
|
+
export declare function isNoColorEnv(env: NodeJS.ProcessEnv): boolean;
|
|
23
|
+
/**
|
|
24
|
+
* Visible-character width of an ANSI-bearing string. Skips every
|
|
25
|
+
* `\x1b[...m` escape so the count reflects what the terminal paints,
|
|
26
|
+
* not the byte length on the wire.
|
|
27
|
+
*/
|
|
28
|
+
export declare function visibleCharWidth(s: string): number;
|
|
29
|
+
interface AnsiToken {
|
|
30
|
+
readonly kind: 'text' | 'esc';
|
|
31
|
+
readonly value: string;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Split an ANSI string into a flat token stream. Escape sequences are
|
|
35
|
+
* grouped as `\x1b[...m` (single token, kind='esc'); everything else
|
|
36
|
+
* is grouped as a single 'text' run. Used by {@link applyMarquee} to
|
|
37
|
+
* locate visible-cell ranges inside an SGR-bearing string.
|
|
38
|
+
*/
|
|
39
|
+
export declare function tokenizeAnsi(s: string): readonly AnsiToken[];
|
|
40
|
+
/**
|
|
41
|
+
* Apply the marquee highlight band to an ANSI-bearing string. Pure.
|
|
42
|
+
* - `bandStart` / `bandEnd` are visible-cell indices (0-based).
|
|
43
|
+
* - Cells in [bandStart, bandEnd] receive a `\x1b[1;38;2;224;224;224m`
|
|
44
|
+
* prefix; cells after the band receive the closing `\x1b[0m` reset
|
|
45
|
+
* so the rest of the line keeps its original SGR.
|
|
46
|
+
* - Empty strings and zero-width bands return the input unchanged.
|
|
47
|
+
*/
|
|
48
|
+
export declare function applyMarqueeHighlight(s: string, bandStart: number, bandEnd: number): string;
|
|
49
|
+
/**
|
|
50
|
+
* Compute the marquee band for a string at time `nowMs` and re-emit
|
|
51
|
+
* the string with the band applied. ASCII tier is a pass-through (no
|
|
52
|
+
* SGR to inject) — the scanner has no visible effect on plain text.
|
|
53
|
+
*/
|
|
54
|
+
export declare function applyMarquee(s: string, nowMs: number, capability: StatusLineCapability): string;
|
|
77
55
|
/**
|
|
78
56
|
* Render the status line. Pure — no I/O, no side effects. The output is
|
|
79
57
|
* a single short line suitable for the bottom-of-terminal status bar.
|
|
@@ -85,5 +63,17 @@ export declare function resolveStatusLineCapability(input: {
|
|
|
85
63
|
* Compact-state precedence: when `model.compact.kind !== 'none'` the
|
|
86
64
|
* compact bar replaces the active/idle/stale content. The C1 baseline
|
|
87
65
|
* line is preserved when the compact state is `none`.
|
|
66
|
+
*
|
|
67
|
+
* The full line is wrapped by {@link applyMarquee} so the brand-purple
|
|
68
|
+
* text + non-focal bar cells share a single colour surface while the
|
|
69
|
+
* scanner band paints its `#E0E0E0` highlight over a moving slice.
|
|
70
|
+
*
|
|
71
|
+
* Idle suppression: the marquee is OFF when `model.state === 'idle'`
|
|
72
|
+
* (and no compact state is active). Idle keeps the slow-blink `○`
|
|
73
|
+
* as the only motion — the user is asking whether the harness is
|
|
74
|
+
* running a skill; the scan band would compete for attention and
|
|
75
|
+
* obscure that question. Active / stale / invalid-presence / compact
|
|
76
|
+
* states all keep the marquee.
|
|
88
77
|
*/
|
|
89
|
-
export declare function renderStatusLine(model: StatusLineModel, options?: StatusLineRenderOptions): string;
|
|
78
|
+
export declare function renderStatusLine(model: StatusLineModel, options?: StatusLineRenderOptions, env?: NodeJS.ProcessEnv): string;
|
|
79
|
+
export {};
|
|
@@ -28,12 +28,39 @@ const BRAND_RGB = '38;2;90;101;216';
|
|
|
28
28
|
const BRAND_SGR_OPEN = `\x1b[1;${BRAND_RGB}m`;
|
|
29
29
|
const BRAND_SGR_FULL_OPEN = `\x1b[${BRAND_RGB}m`;
|
|
30
30
|
const BRAND_SGR_CLOSE = '\x1b[0m';
|
|
31
|
+
/**
|
|
32
|
+
* Highlight color used by the marquee scan band. `#E0E0E0` bright-grey
|
|
33
|
+
* + bold = "bleached" foreground that reads as a scanner sweeping
|
|
34
|
+
* over the colored text. Kept distinct from brand white (`#FFFFFF`)
|
|
35
|
+
* because pure white on `#5A65D8` brand purple reads as a strobe;
|
|
36
|
+
* the off-white tint gives the same "scanner" affordance without the
|
|
37
|
+
* flicker.
|
|
38
|
+
*/
|
|
39
|
+
const HIGHLIGHT_RGB = '38;2;224;224;224';
|
|
40
|
+
const HIGHLIGHT_SGR_OPEN = `\x1b[1;${HIGHLIGHT_RGB}m`;
|
|
31
41
|
function accent(text) {
|
|
32
42
|
return `${BRAND_SGR_OPEN}${text}${BRAND_SGR_CLOSE}`;
|
|
33
43
|
}
|
|
34
44
|
function accentGlyph(glyph) {
|
|
35
45
|
return `${BRAND_SGR_OPEN}${glyph}${BRAND_SGR_CLOSE}`;
|
|
36
46
|
}
|
|
47
|
+
// (kept for the brandText/active-dot helpers that inline the same SGR; the
|
|
48
|
+
// buildPalette factory owns per-token SGR injection now and does not call
|
|
49
|
+
// through these wrappers.)
|
|
50
|
+
/**
|
|
51
|
+
* Render the supplied text in the brand purple, honouring the
|
|
52
|
+
* `noColor` flag. Returns plain text when NO_COLOR is set or the
|
|
53
|
+
* tier is ASCII. Used by the active / stale / compact renders for
|
|
54
|
+
* tokens that the {@link buildPalette} cannot pre-stamp (skill
|
|
55
|
+
* name, mode, age, attention labels).
|
|
56
|
+
*/
|
|
57
|
+
function brandRun(text, noColor, capability) {
|
|
58
|
+
if (text.length === 0)
|
|
59
|
+
return text;
|
|
60
|
+
if (noColor || capability === 'ascii')
|
|
61
|
+
return text;
|
|
62
|
+
return `${BRAND_SGR_OPEN}${text}${BRAND_SGR_CLOSE}`;
|
|
63
|
+
}
|
|
37
64
|
/**
|
|
38
65
|
* Slow-blink variant of the accent glyph. Used for the idle indicator
|
|
39
66
|
* so the user perceives a pulse on the statusline. The ANSI slow-blink
|
|
@@ -44,68 +71,70 @@ function accentGlyph(glyph) {
|
|
|
44
71
|
function blinkingAccentGlyph(glyph) {
|
|
45
72
|
return `\x1b[5;1;${BRAND_RGB}m${glyph}\x1b[0m`;
|
|
46
73
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
74
|
+
/**
|
|
75
|
+
* Build the per-capability palette. `noColor=true` strips every brand
|
|
76
|
+
* ANSI SGR (warning / failed remain because they are SEMANTIC alarms,
|
|
77
|
+
* not brand colour). The unicode glyphs are kept as-is — NO_COLOR
|
|
78
|
+
* (https://no-color.org) addresses ANSI sequences, not UTF-8 chars.
|
|
79
|
+
*/
|
|
80
|
+
function buildPalette(capability, noColor) {
|
|
81
|
+
// Brand SGR helpers, noColor-aware.
|
|
82
|
+
const brand = (text) => noColor || capability === 'ascii' ? text : `${BRAND_SGR_OPEN}${text}${BRAND_SGR_CLOSE}`;
|
|
83
|
+
const brandGlyph = (glyph) => noColor || capability === 'ascii' ? glyph : `${BRAND_SGR_OPEN}${glyph}${BRAND_SGR_CLOSE}`;
|
|
84
|
+
const dimBrand = (text) => {
|
|
85
|
+
if (noColor || capability === 'ascii')
|
|
86
|
+
return text;
|
|
87
|
+
return `\x1b[2;${BRAND_RGB}m${text}\x1b[0m`;
|
|
88
|
+
};
|
|
89
|
+
const blinkBrand = (glyph) => {
|
|
90
|
+
if (noColor || capability === 'ascii')
|
|
91
|
+
return glyph;
|
|
92
|
+
return `\x1b[5;1;${BRAND_RGB}m${glyph}\x1b[0m`;
|
|
93
|
+
};
|
|
94
|
+
// Warning + failed stay semantic — they are alarms, not brand colour.
|
|
95
|
+
// noColor still suppresses them because a tiny log consumer that
|
|
96
|
+
// sets NO_COLOR expects raw text.
|
|
97
|
+
const warning = noColor || capability === 'ascii' ? '!' : '\x1b[33m!\x1b[0m';
|
|
98
|
+
const failed = noColor || capability === 'ascii' ? 'x' : '\x1b[31m✕\x1b[0m';
|
|
99
|
+
if (capability === 'ascii') {
|
|
100
|
+
return {
|
|
101
|
+
active: '*',
|
|
102
|
+
idle: 'o',
|
|
103
|
+
warning,
|
|
104
|
+
inlineSeparator: ' . ',
|
|
105
|
+
trailSeparator: ' -> ',
|
|
106
|
+
idleLabel: 'empty',
|
|
107
|
+
invalidMessage: 'presence unreadable',
|
|
108
|
+
compact: {
|
|
109
|
+
queued: '[', preparing: '+', compacting: '+', verifying: '+',
|
|
110
|
+
completed: '*', failed,
|
|
111
|
+
},
|
|
112
|
+
barFilled: '#',
|
|
113
|
+
barEmpty: '-',
|
|
114
|
+
ratioArrow: '->',
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
active: brandGlyph('●'),
|
|
119
|
+
idle: blinkBrand('○'),
|
|
120
|
+
warning,
|
|
121
|
+
inlineSeparator: brand(' · '),
|
|
122
|
+
trailSeparator: brand(' → '),
|
|
123
|
+
idleLabel: brand('empty'),
|
|
124
|
+
invalidMessage: brand('presence unreadable'),
|
|
96
125
|
compact: {
|
|
97
|
-
queued: '
|
|
98
|
-
preparing: '
|
|
99
|
-
compacting: '
|
|
100
|
-
verifying: '
|
|
101
|
-
completed: '
|
|
102
|
-
failed
|
|
126
|
+
queued: brandGlyph('◐'),
|
|
127
|
+
preparing: brandGlyph('◑'),
|
|
128
|
+
compacting: brandGlyph('◒'),
|
|
129
|
+
verifying: brandGlyph('◓'),
|
|
130
|
+
completed: brandGlyph('✓'),
|
|
131
|
+
failed,
|
|
103
132
|
},
|
|
104
|
-
barFilled: '
|
|
105
|
-
barEmpty: '
|
|
106
|
-
ratioArrow: '
|
|
107
|
-
}
|
|
108
|
-
}
|
|
133
|
+
barFilled: brand('█'),
|
|
134
|
+
barEmpty: dimBrand('░'),
|
|
135
|
+
ratioArrow: dimBrand('→'),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
109
138
|
const BREATHING_GLYPHS_UNICODE = ['●', '◐', '◑', '◒', '◓'];
|
|
110
139
|
const BREATHING_GLYPHS_ASCII = ['*', 'o', '+', '~', '|'];
|
|
111
140
|
const BREATHING_PERIOD_MS = 2_400;
|
|
@@ -133,23 +162,23 @@ function pickBreathingGlyph(capability, nowMs) {
|
|
|
133
162
|
const index = Math.floor((nowMs % BREATHING_PERIOD_MS) / (BREATHING_PERIOD_MS / set.length)) % set.length;
|
|
134
163
|
return set[index];
|
|
135
164
|
}
|
|
136
|
-
function renderActiveDot(capability, nowMs) {
|
|
165
|
+
function renderActiveDot(capability, nowMs, noColor) {
|
|
137
166
|
// Brief: the active dot carries the project accent (`#5A65D8` bold)
|
|
138
167
|
// in both colored tiers. The breathing glyph is wrapped in a fresh
|
|
139
168
|
// SGR on every render so the IDE sees a single accent per refresh.
|
|
169
|
+
// NO_COLOR strips the SGR but keeps the glyph (still rotates).
|
|
140
170
|
const glyph = pickBreathingGlyph(capability, nowMs);
|
|
141
|
-
if (capability === 'ascii')
|
|
171
|
+
if (noColor || capability === 'ascii')
|
|
142
172
|
return glyph;
|
|
143
|
-
return
|
|
173
|
+
return `${BRAND_SGR_OPEN}${glyph}${BRAND_SGR_CLOSE}`;
|
|
144
174
|
}
|
|
145
|
-
function brandText(capability) {
|
|
175
|
+
function brandText(capability, noColor) {
|
|
146
176
|
// Brief: brand carries the project accent (`#5A65D8` bold) in both
|
|
147
|
-
// colored tiers. The `ascii` tier
|
|
148
|
-
//
|
|
149
|
-
if (
|
|
150
|
-
return
|
|
151
|
-
}
|
|
152
|
-
return BRAND;
|
|
177
|
+
// colored tiers. The `ascii` tier and NO_COLOR stay plain text so
|
|
178
|
+
// log / file consumers never see escape codes.
|
|
179
|
+
if (noColor || capability === 'ascii')
|
|
180
|
+
return BRAND;
|
|
181
|
+
return `${BRAND_SGR_OPEN}${BRAND}${BRAND_SGR_CLOSE}`;
|
|
153
182
|
}
|
|
154
183
|
/**
|
|
155
184
|
* Default capability when none is supplied. Per the brief: "if backward
|
|
@@ -159,8 +188,8 @@ function brandText(capability) {
|
|
|
159
188
|
* default is the unicode palette (no escape codes).
|
|
160
189
|
*/
|
|
161
190
|
const DEFAULT_CAPABILITY = 'unicode';
|
|
162
|
-
function paletteFor(capability) {
|
|
163
|
-
return
|
|
191
|
+
function paletteFor(capability, noColor) {
|
|
192
|
+
return buildPalette(capability ?? DEFAULT_CAPABILITY, noColor);
|
|
164
193
|
}
|
|
165
194
|
function formatAge(ageMs) {
|
|
166
195
|
if (ageMs === null)
|
|
@@ -182,35 +211,35 @@ function rootLabel(projectRoot) {
|
|
|
182
211
|
* is obvious at the call site and so each state has a single
|
|
183
212
|
* responsibility.
|
|
184
213
|
*/
|
|
185
|
-
function renderActive(presence, palette, nowMs, capability) {
|
|
214
|
+
function renderActive(presence, palette, nowMs, capability, noColor) {
|
|
186
215
|
if (!presence) {
|
|
187
216
|
return `${palette.idle} ${palette.idleLabel}`;
|
|
188
217
|
}
|
|
189
218
|
const attentionLabel = isAttentionGate(presence.gate);
|
|
190
219
|
if (attentionLabel !== null) {
|
|
191
220
|
// Attention gate — surface the warning glyph + human-readable label.
|
|
192
|
-
return `${palette.warning} ${presence.skill}${palette.inlineSeparator}${attentionLabel}`;
|
|
221
|
+
return `${palette.warning} ${brandRun(presence.skill, noColor, capability)}${palette.inlineSeparator}${brandRun(attentionLabel, noColor, capability)}`;
|
|
193
222
|
}
|
|
194
223
|
const skill = presence.skill;
|
|
195
|
-
const dot = renderActiveDot(capability, nowMs);
|
|
224
|
+
const dot = renderActiveDot(capability, nowMs, noColor);
|
|
196
225
|
const beeParent = BEE_TO_PARENT[skill];
|
|
197
226
|
const modeToken = typeof presence.mode === 'string' && presence.mode.length > 0
|
|
198
|
-
? ` [${presence.mode}]
|
|
227
|
+
? brandRun(` [${presence.mode}]`, noColor, capability)
|
|
199
228
|
: '';
|
|
200
229
|
// Bee-tier skills surface both layers: the active bee name plus
|
|
201
230
|
// the full parent orchestrator name. Mode is shown for every
|
|
202
231
|
// active skill (not just peaks-code) so the line carries the same
|
|
203
232
|
// mode taxonomy for any bee in flight.
|
|
204
233
|
if (beeParent !== undefined) {
|
|
205
|
-
return `${dot} ${skill}
|
|
234
|
+
return `${dot} ${brandRun(skill, noColor, capability)} ${brandRun(`↑${beeParent}`, noColor, capability)}${modeToken}`;
|
|
206
235
|
}
|
|
207
|
-
return `${dot} ${skill}${modeToken}`;
|
|
236
|
+
return `${dot} ${brandRun(skill, noColor, capability)}${modeToken}`;
|
|
208
237
|
}
|
|
209
|
-
function renderStale(presence, ageMs, palette) {
|
|
238
|
+
function renderStale(presence, ageMs, palette, capability, noColor) {
|
|
210
239
|
const skill = presence?.skill ?? 'unknown';
|
|
211
240
|
const age = formatAge(ageMs);
|
|
212
|
-
const ageSuffix = age ? `${palette.inlineSeparator}${age}` : '';
|
|
213
|
-
return `${palette.warning} ${skill}${ageSuffix}`;
|
|
241
|
+
const ageSuffix = age ? `${palette.inlineSeparator}${brandRun(age, noColor, capability)}` : '';
|
|
242
|
+
return `${palette.warning} ${brandRun(skill, noColor, capability)}${ageSuffix}`;
|
|
214
243
|
}
|
|
215
244
|
function renderInvalid(palette) {
|
|
216
245
|
return `${palette.warning} ${palette.invalidMessage}`;
|
|
@@ -339,6 +368,18 @@ function renderCompact(state, palette) {
|
|
|
339
368
|
* is a logger, a file, or any non-interactive sink, and only enables ANSI
|
|
340
369
|
* under an explicit supported condition (TTY + no env veto).
|
|
341
370
|
*/
|
|
371
|
+
/**
|
|
372
|
+
* NO_COLOR (https://no-color.org) is the cross-industry signal that NO
|
|
373
|
+
* ANSI escape sequences should be emitted. The unicode/ansi-unicode
|
|
374
|
+
* tiers honour it by stripping every brand SGR while keeping the UTF-8
|
|
375
|
+
* glyphs (●, █, ░, ◐, …) — NO_COLOR addresses ANSI specifically.
|
|
376
|
+
* PEAKS_STATUSLINE_ASCII is the stricter override that also drops the
|
|
377
|
+
* unicode glyphs; both can coexist and the ASCII override wins.
|
|
378
|
+
*/
|
|
379
|
+
function isNoColor(env) {
|
|
380
|
+
const v = env['NO_COLOR'];
|
|
381
|
+
return typeof v === 'string' && v.length > 0 && v !== '0' && v.toLowerCase() !== 'false';
|
|
382
|
+
}
|
|
342
383
|
export function resolveStatusLineCapability(input) {
|
|
343
384
|
if (input.forced !== undefined) {
|
|
344
385
|
return input.forced;
|
|
@@ -350,12 +391,158 @@ export function resolveStatusLineCapability(input) {
|
|
|
350
391
|
if (typeof asciiFlag === 'string' && (asciiFlag === '1' || asciiFlag === 'true' || asciiFlag === 'yes')) {
|
|
351
392
|
return 'ascii';
|
|
352
393
|
}
|
|
353
|
-
// NO_COLOR is
|
|
354
|
-
//
|
|
355
|
-
//
|
|
356
|
-
// whether or not the consumer is a TTY.
|
|
394
|
+
// NO_COLOR is honoured at render time (see {@link renderStatusLine});
|
|
395
|
+
// capability stays unicode so the UTF-8 glyphs survive — only the
|
|
396
|
+
// brand SGR is suppressed.
|
|
357
397
|
return input.isTTY ? 'ansi-unicode' : 'unicode';
|
|
358
398
|
}
|
|
399
|
+
export function isNoColorEnv(env) {
|
|
400
|
+
return isNoColor(env);
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Marquee scan band — a single-pass light band that sweeps left ↔ right
|
|
404
|
+
* across the entire status line on a 2 s round trip. The band's
|
|
405
|
+
* foreground color is `#E0E0E0` (off-white) with `1;` (bold) — see
|
|
406
|
+
* {@link HIGHLIGHT_SGR_OPEN}. Cells OUTSIDE the band keep their
|
|
407
|
+
* original SGR (brand purple or semantic warning/failed); only cells
|
|
408
|
+
* INSIDE the band are temporarily re-painted to the highlight color.
|
|
409
|
+
*
|
|
410
|
+
* Why it does NOT use SGR 7 (reverse video):
|
|
411
|
+
* - Reverse video inverts BOTH background and foreground, which on a
|
|
412
|
+
* `#5A65D8` background swaps to a white background — visually that
|
|
413
|
+
* reads as a solid white block instead of a scan band.
|
|
414
|
+
* - The off-white foreground + bold keeps the original background
|
|
415
|
+
* visible behind each glyph, so the band reads as "lit text" not
|
|
416
|
+
* as a solid stripe — closer to the CSDN-style scan-band reference
|
|
417
|
+
* image.
|
|
418
|
+
*
|
|
419
|
+
* Phase formula:
|
|
420
|
+
* phase ∈ [0, 1) over MARQUEE_PERIOD_MS
|
|
421
|
+
* sweep = phase < 0.5 ? phase * 2 : (1 - phase) * 2 ← triangle wave 0→1→0
|
|
422
|
+
* center = sweep * (width - 1)
|
|
423
|
+
* band covers visible-cell range [center - half, center + half]
|
|
424
|
+
*
|
|
425
|
+
* When the band would land outside the visible range (center ≤ half
|
|
426
|
+
* OR center ≥ width-1-half) it is clipped — the band never appears
|
|
427
|
+
* off-line. At `nowMs = 0` the band sits at the LEFT edge covering
|
|
428
|
+
* cells [0, bandWidth); this is a deliberate deterministic anchor
|
|
429
|
+
* for tests that pin `withPinnedClock(0, ...)`.
|
|
430
|
+
*
|
|
431
|
+
* ASCII tier: skipped — there are no SGR codes to inject.
|
|
432
|
+
*/
|
|
433
|
+
const MARQUEE_PERIOD_MS = 2_000;
|
|
434
|
+
const MARQUEE_BAND_WIDTH = 5;
|
|
435
|
+
/**
|
|
436
|
+
* Visible-character width of an ANSI-bearing string. Skips every
|
|
437
|
+
* `\x1b[...m` escape so the count reflects what the terminal paints,
|
|
438
|
+
* not the byte length on the wire.
|
|
439
|
+
*/
|
|
440
|
+
export function visibleCharWidth(s) {
|
|
441
|
+
let w = 0;
|
|
442
|
+
for (let i = 0; i < s.length; i++) {
|
|
443
|
+
if (s[i] === '\x1b' && s[i + 1] === '[') {
|
|
444
|
+
const m = s.indexOf('m', i + 2);
|
|
445
|
+
if (m !== -1) {
|
|
446
|
+
i = m;
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
w++;
|
|
451
|
+
}
|
|
452
|
+
return w;
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* Split an ANSI string into a flat token stream. Escape sequences are
|
|
456
|
+
* grouped as `\x1b[...m` (single token, kind='esc'); everything else
|
|
457
|
+
* is grouped as a single 'text' run. Used by {@link applyMarquee} to
|
|
458
|
+
* locate visible-cell ranges inside an SGR-bearing string.
|
|
459
|
+
*/
|
|
460
|
+
export function tokenizeAnsi(s) {
|
|
461
|
+
const out = [];
|
|
462
|
+
let i = 0;
|
|
463
|
+
let buf = '';
|
|
464
|
+
const flush = () => {
|
|
465
|
+
if (buf.length > 0) {
|
|
466
|
+
out.push({ kind: 'text', value: buf });
|
|
467
|
+
buf = '';
|
|
468
|
+
}
|
|
469
|
+
};
|
|
470
|
+
while (i < s.length) {
|
|
471
|
+
if (s[i] === '\x1b' && s[i + 1] === '[') {
|
|
472
|
+
const m = s.indexOf('m', i + 2);
|
|
473
|
+
if (m !== -1) {
|
|
474
|
+
flush();
|
|
475
|
+
out.push({ kind: 'esc', value: s.slice(i, m + 1) });
|
|
476
|
+
i = m + 1;
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
buf += s[i];
|
|
481
|
+
i++;
|
|
482
|
+
}
|
|
483
|
+
flush();
|
|
484
|
+
return out;
|
|
485
|
+
}
|
|
486
|
+
/**
|
|
487
|
+
* Apply the marquee highlight band to an ANSI-bearing string. Pure.
|
|
488
|
+
* - `bandStart` / `bandEnd` are visible-cell indices (0-based).
|
|
489
|
+
* - Cells in [bandStart, bandEnd] receive a `\x1b[1;38;2;224;224;224m`
|
|
490
|
+
* prefix; cells after the band receive the closing `\x1b[0m` reset
|
|
491
|
+
* so the rest of the line keeps its original SGR.
|
|
492
|
+
* - Empty strings and zero-width bands return the input unchanged.
|
|
493
|
+
*/
|
|
494
|
+
export function applyMarqueeHighlight(s, bandStart, bandEnd) {
|
|
495
|
+
if (s.length === 0)
|
|
496
|
+
return s;
|
|
497
|
+
if (bandEnd < 0 || bandStart > bandEnd)
|
|
498
|
+
return s;
|
|
499
|
+
const tokens = tokenizeAnsi(s);
|
|
500
|
+
const parts = [];
|
|
501
|
+
let visibleIdx = 0;
|
|
502
|
+
let inside = false;
|
|
503
|
+
for (const tok of tokens) {
|
|
504
|
+
if (tok.kind === 'esc') {
|
|
505
|
+
parts.push(tok.value);
|
|
506
|
+
continue;
|
|
507
|
+
}
|
|
508
|
+
for (let k = 0; k < tok.value.length; k++) {
|
|
509
|
+
const cellPos = visibleIdx + k;
|
|
510
|
+
const inBand = cellPos >= bandStart && cellPos <= bandEnd;
|
|
511
|
+
if (inBand && !inside) {
|
|
512
|
+
parts.push(HIGHLIGHT_SGR_OPEN);
|
|
513
|
+
inside = true;
|
|
514
|
+
}
|
|
515
|
+
else if (!inBand && inside) {
|
|
516
|
+
parts.push('\x1b[0m');
|
|
517
|
+
inside = false;
|
|
518
|
+
}
|
|
519
|
+
parts.push(tok.value[k]);
|
|
520
|
+
}
|
|
521
|
+
visibleIdx += tok.value.length;
|
|
522
|
+
}
|
|
523
|
+
if (inside)
|
|
524
|
+
parts.push('\x1b[0m');
|
|
525
|
+
return parts.join('');
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* Compute the marquee band for a string at time `nowMs` and re-emit
|
|
529
|
+
* the string with the band applied. ASCII tier is a pass-through (no
|
|
530
|
+
* SGR to inject) — the scanner has no visible effect on plain text.
|
|
531
|
+
*/
|
|
532
|
+
export function applyMarquee(s, nowMs, capability) {
|
|
533
|
+
if (capability === 'ascii')
|
|
534
|
+
return s;
|
|
535
|
+
const width = visibleCharWidth(s);
|
|
536
|
+
if (width === 0)
|
|
537
|
+
return s;
|
|
538
|
+
const phase = (nowMs % MARQUEE_PERIOD_MS) / MARQUEE_PERIOD_MS;
|
|
539
|
+
const sweep = phase < 0.5 ? phase * 2 : (1 - phase) * 2;
|
|
540
|
+
const center = Math.round(sweep * (width - 1));
|
|
541
|
+
const halfBand = Math.floor(MARQUEE_BAND_WIDTH / 2);
|
|
542
|
+
const bandStart = Math.max(0, center - halfBand);
|
|
543
|
+
const bandEnd = Math.min(width - 1, center + halfBand);
|
|
544
|
+
return applyMarqueeHighlight(s, bandStart, bandEnd);
|
|
545
|
+
}
|
|
359
546
|
/**
|
|
360
547
|
* Render the status line. Pure — no I/O, no side effects. The output is
|
|
361
548
|
* a single short line suitable for the bottom-of-terminal status bar.
|
|
@@ -367,34 +554,59 @@ export function resolveStatusLineCapability(input) {
|
|
|
367
554
|
* Compact-state precedence: when `model.compact.kind !== 'none'` the
|
|
368
555
|
* compact bar replaces the active/idle/stale content. The C1 baseline
|
|
369
556
|
* line is preserved when the compact state is `none`.
|
|
557
|
+
*
|
|
558
|
+
* The full line is wrapped by {@link applyMarquee} so the brand-purple
|
|
559
|
+
* text + non-focal bar cells share a single colour surface while the
|
|
560
|
+
* scanner band paints its `#E0E0E0` highlight over a moving slice.
|
|
561
|
+
*
|
|
562
|
+
* Idle suppression: the marquee is OFF when `model.state === 'idle'`
|
|
563
|
+
* (and no compact state is active). Idle keeps the slow-blink `○`
|
|
564
|
+
* as the only motion — the user is asking whether the harness is
|
|
565
|
+
* running a skill; the scan band would compete for attention and
|
|
566
|
+
* obscure that question. Active / stale / invalid-presence / compact
|
|
567
|
+
* states all keep the marquee.
|
|
370
568
|
*/
|
|
371
|
-
export function renderStatusLine(model, options) {
|
|
569
|
+
export function renderStatusLine(model, options, env) {
|
|
372
570
|
const capability = options?.capability ?? DEFAULT_CAPABILITY;
|
|
373
|
-
const
|
|
571
|
+
const noColor = env !== undefined ? isNoColor(env) : false;
|
|
572
|
+
const palette = paletteFor(capability, noColor);
|
|
374
573
|
const root = rootLabel(model.projectRoot);
|
|
375
574
|
const rootSuffix = root ? `${palette.trailSeparator}${root}` : '';
|
|
376
|
-
const brand = brandText(capability);
|
|
575
|
+
const brand = brandText(capability, noColor);
|
|
377
576
|
// Breathing pulse: key off a single wall-clock read at the start of
|
|
378
577
|
// render so the output is deterministic for a given invocation. The
|
|
379
578
|
// glyph remains a single cell so total visible width is constant.
|
|
380
579
|
const nowMs = Date.now();
|
|
381
580
|
const compactSegment = renderCompact(model.compact, palette);
|
|
382
|
-
|
|
581
|
+
let line;
|
|
582
|
+
const hasCompact = compactSegment.length > 0;
|
|
583
|
+
if (hasCompact) {
|
|
383
584
|
// Compact state replaces the active / stale / idle skill content.
|
|
384
585
|
// `invalid-presence` still surfaces its own diagnostic when compact
|
|
385
586
|
// is also `invalid` (the compact diagnostic wins, since it's the
|
|
386
587
|
// more recent failure mode).
|
|
387
|
-
|
|
588
|
+
line = `${brand} ${compactSegment}${rootSuffix}`;
|
|
388
589
|
}
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
590
|
+
else {
|
|
591
|
+
switch (model.state) {
|
|
592
|
+
case 'active':
|
|
593
|
+
line = `${brand} ${renderActive(model.presence, palette, nowMs, capability, noColor)}${rootSuffix}`;
|
|
594
|
+
break;
|
|
595
|
+
case 'stale':
|
|
596
|
+
line = `${brand} ${renderStale(model.presence, model.ageMs, palette, capability, noColor)}${rootSuffix}`;
|
|
597
|
+
break;
|
|
598
|
+
case 'invalid-presence':
|
|
599
|
+
line = `${brand} ${renderInvalid(palette)}${rootSuffix}`;
|
|
600
|
+
break;
|
|
601
|
+
case 'idle':
|
|
602
|
+
default:
|
|
603
|
+
line = `${brand} ${renderIdle(palette)}${rootSuffix}`;
|
|
604
|
+
break;
|
|
605
|
+
}
|
|
399
606
|
}
|
|
607
|
+
// Marquee is OFF for idle (and only idle). Compact states always
|
|
608
|
+
// carry the band because the compact bar IS the headline. NO_COLOR
|
|
609
|
+
// skips the band entirely — there's no SGR to inject.
|
|
610
|
+
const shouldMarquee = !noColor && (hasCompact || model.state !== 'idle');
|
|
611
|
+
return shouldMarquee ? applyMarquee(line, nowMs, capability) : line;
|
|
400
612
|
}
|