@raidiant/notifai 3.0.1 → 5.0.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/CHANGELOG.md +73 -0
- package/README.md +12 -4
- package/dist/commands.d.ts +27 -10
- package/dist/commands.js +429 -152
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.js +20 -11
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +3 -1
- package/dist/config.js +12 -1
- package/dist/config.js.map +1 -1
- package/dist/file-lock.d.ts +6 -1
- package/dist/file-lock.js +55 -9
- package/dist/file-lock.js.map +1 -1
- package/dist/harnesses.d.ts +1 -1
- package/dist/harnesses.js.map +1 -1
- package/dist/hooks.d.ts +84 -12
- package/dist/hooks.js +433 -125
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +91 -14
- package/dist/install-hooks.js +226 -34
- package/dist/install-hooks.js.map +1 -1
- package/dist/interactive.d.ts +1 -1
- package/dist/interactive.js +1 -1
- package/dist/invocation-context.d.ts +42 -0
- package/dist/invocation-context.js +130 -0
- package/dist/invocation-context.js.map +1 -0
- package/dist/logging.d.ts +8 -0
- package/dist/logging.js +8 -0
- package/dist/logging.js.map +1 -1
- package/dist/main.js +84 -49
- package/dist/main.js.map +1 -1
- package/dist/opencode-plugin.d.ts +8 -0
- package/dist/opencode-plugin.js +24 -162
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/send.d.ts +38 -7
- package/dist/send.js +97 -41
- package/dist/send.js.map +1 -1
- package/dist/ui/help.d.ts +0 -1
- package/dist/ui/help.js +6 -3
- package/dist/ui/help.js.map +1 -1
- package/dist/ui/theme.d.ts +0 -15
- package/dist/ui/theme.js +2 -18
- package/dist/ui/theme.js.map +1 -1
- package/package.json +2 -2
- package/src/commands.test.ts +501 -63
- package/src/commands.ts +547 -187
- package/src/config-schema.test.ts +1 -1
- package/src/config-schema.ts +22 -11
- package/src/config.test.ts +178 -18
- package/src/config.ts +14 -1
- package/src/file-lock.test.ts +41 -0
- package/src/file-lock.ts +59 -9
- package/src/harnesses.test.ts +37 -29
- package/src/harnesses.ts +1 -4
- package/src/hooks.test.ts +500 -58
- package/src/hooks.ts +523 -169
- package/src/install-hooks.test.ts +232 -12
- package/src/install-hooks.ts +241 -37
- package/src/interactive.ts +1 -1
- package/src/invocation-context.test.ts +253 -0
- package/src/invocation-context.ts +176 -0
- package/src/logging-process-worker.test.ts +8 -2
- package/src/logging.test.ts +33 -18
- package/src/logging.ts +11 -0
- package/src/main.ts +119 -54
- package/src/opencode-plugin.ts +24 -164
- package/src/question-caps.test.ts +49 -0
- package/src/send.ts +144 -50
- package/src/skill.test.ts +197 -120
- package/src/ui/help.ts +8 -3
- package/src/ui/theme.ts +2 -16
package/src/skill.test.ts
CHANGED
|
@@ -1,151 +1,228 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs'
|
|
2
2
|
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { NOTIFICATION_KINDS } from '@raidiant/notifai-protocol'
|
|
4
|
+
import { GATE_REASONS } from './hooks.js'
|
|
3
5
|
|
|
4
|
-
|
|
6
|
+
/**
|
|
7
|
+
* These tests assert what the skill has to teach, not the sentences it teaches
|
|
8
|
+
* it in — as far as that is achievable. Pinning wording makes every
|
|
9
|
+
* improvement to the guidance look like a regression, which is how guidance
|
|
10
|
+
* goes stale: the cheapest way to keep a suite green becomes leaving the prose
|
|
11
|
+
* alone.
|
|
12
|
+
*
|
|
13
|
+
* What is genuinely structural: section order, the vocabularies the CLI can
|
|
14
|
+
* actually emit (`NOTIFICATION_KINDS`, `GATE_REASONS`), the token budget, that
|
|
15
|
+
* every example would pass the CLI's own validation, and that every command an
|
|
16
|
+
* agent needs is reachable.
|
|
17
|
+
*
|
|
18
|
+
* Some assertions do match on phrases, and honestly so: a rule like "never say
|
|
19
|
+
* where the answer must arrive" has no structural signature, and the cost of
|
|
20
|
+
* it silently disappearing is higher than the cost of rewording a regex. Those
|
|
21
|
+
* are matched case-insensitively and loosely on purpose.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const skillPath = new URL('../../../skills/notifai/SKILL.md', import.meta.url)
|
|
25
|
+
const skill = readFileSync(skillPath, 'utf8')
|
|
5
26
|
const harnessReference = readFileSync(
|
|
6
27
|
new URL('../../../skills/notifai/references/harness-setup.md', import.meta.url),
|
|
7
28
|
'utf8',
|
|
8
29
|
)
|
|
9
30
|
|
|
31
|
+
/** Fenced blocks, so examples can be checked as commands rather than as text. */
|
|
32
|
+
const examples = [...skill.matchAll(/```(?:bash|json)?\n([\s\S]*?)```/g)].map((match) => match[1]!)
|
|
33
|
+
const shellExamples = examples.flatMap((block) =>
|
|
34
|
+
block
|
|
35
|
+
.replace(/\\\n\s*/g, ' ')
|
|
36
|
+
.split('\n')
|
|
37
|
+
.map((line) => line.trim())
|
|
38
|
+
.filter((line) => line.startsWith('notifai ')),
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
const section = (heading: string): string => {
|
|
42
|
+
const start = skill.indexOf(heading)
|
|
43
|
+
if (start < 0) return ''
|
|
44
|
+
const next = skill.indexOf('\n## ', start + heading.length)
|
|
45
|
+
return skill.slice(start, next < 0 ? undefined : next)
|
|
46
|
+
}
|
|
47
|
+
|
|
10
48
|
describe('Notifai agent skill', () => {
|
|
11
|
-
it('
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
it('teaches kind profiles and a correctly classified completion', () => {
|
|
17
|
-
expect(skill).toContain('--kind done')
|
|
18
|
-
expect(skill).toContain('| `update` (default) | `none` | `passive` |')
|
|
19
|
-
expect(skill).toContain('| `done` | `done` | `passive` |')
|
|
20
|
-
expect(skill).toContain('| question (`--reply`) | `attention` | `active` |')
|
|
21
|
-
})
|
|
22
|
-
|
|
23
|
-
it('makes the agent the setup wizard', () => {
|
|
24
|
-
expect(skill.indexOf('## Set Notifai up')).toBeGreaterThan(skill.indexOf('## When to notify'))
|
|
25
|
-
expect(skill.indexOf('## Set Notifai up')).toBeLessThan(skill.indexOf('## Compose and send'))
|
|
26
|
-
expect(skill).toContain('Never tell the user to run')
|
|
27
|
-
expect(skill).toContain('notifai init')
|
|
28
|
-
expect(skill).toContain('notifai hooks install')
|
|
29
|
-
expect(skill).toContain('notifai doctor')
|
|
30
|
-
expect(skill).toContain('notifai login')
|
|
31
|
-
expect(skill).toContain('Ask the user only for decisions a process cannot make')
|
|
32
|
-
expect(skill).not.toContain('relay the exact action')
|
|
33
|
-
expect(harnessReference).toContain('install hooks yourself')
|
|
34
|
-
expect(harnessReference).toContain('run `notifai login` yourself')
|
|
35
|
-
expect(harnessReference).not.toContain('the user must run `notifai login`')
|
|
36
|
-
expect(harnessReference).not.toContain('do not install them without being asked')
|
|
37
|
-
})
|
|
38
|
-
|
|
39
|
-
it('treats --sound and --level as overrides, not completeness', () => {
|
|
40
|
-
expect(skill).toMatch(/Never pass `--sound` or `--level` unless/)
|
|
41
|
-
const examples = [...skill.matchAll(/```(?:bash)?\n([\s\S]*?)```/g)].map((match) => match[1])
|
|
42
|
-
const sendExamples = examples.filter((example) => example.includes('notifai send'))
|
|
43
|
-
expect(sendExamples.length).toBeGreaterThan(0)
|
|
44
|
-
for (const example of sendExamples) {
|
|
45
|
-
expect(example).not.toMatch(/(^|\s)--sound\b/)
|
|
46
|
-
expect(example).not.toMatch(/(^|\s)--level\b/)
|
|
47
|
-
}
|
|
49
|
+
it('fits inside the budget a skill is actually read within', () => {
|
|
50
|
+
// Long skills are truncated in exactly the long sessions that need them.
|
|
51
|
+
// The old skill sat at ~4,800 tokens; this ceiling keeps a working margin.
|
|
52
|
+
const approximateTokens = skill.length / 4
|
|
53
|
+
expect(approximateTokens).toBeLessThan(4_400)
|
|
48
54
|
})
|
|
49
55
|
|
|
50
|
-
it('
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
expect(skill).toContain(required)
|
|
56
|
+
it('decides whether to notify before it composes anything', () => {
|
|
57
|
+
const decide = skill.search(/^## .*notify/im)
|
|
58
|
+
const compose = skill.search(/^## Send/im)
|
|
59
|
+
expect(decide).toBeGreaterThan(0)
|
|
60
|
+
expect(decide).toBeLessThan(compose)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('names every notification kind the CLI accepts', () => {
|
|
64
|
+
for (const kind of NOTIFICATION_KINDS) {
|
|
65
|
+
expect(skill).toContain(`\`${kind}\``)
|
|
61
66
|
}
|
|
62
67
|
})
|
|
63
68
|
|
|
64
|
-
it('
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
expect(askGuidance).toContain(required)
|
|
69
|
+
it('teaches kind as required, truthful, and attention-bearing', () => {
|
|
70
|
+
const send = section('## Send')
|
|
71
|
+
expect(send).toMatch(/`--kind` is required/i)
|
|
72
|
+
// The rule that replaced "kind never chooses attention": since kind now
|
|
73
|
+
// selects the sound, the only defensible instruction is to declare the
|
|
74
|
+
// true one.
|
|
75
|
+
expect(send).toMatch(/declare the kind\s+that is true/i)
|
|
76
|
+
expect(skill).not.toMatch(/kind never chooses/i)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('keeps every send example valid against the CLI it documents', () => {
|
|
80
|
+
const sends = shellExamples.filter((line) => line.startsWith('notifai send'))
|
|
81
|
+
expect(sends.length).toBeGreaterThan(0)
|
|
82
|
+
for (const example of sends) {
|
|
83
|
+
// Exactly the boundary rule `sendCommand` enforces.
|
|
84
|
+
expect(example, example).toMatch(/--kind |--reply\b/)
|
|
85
|
+
// Attention overrides belong to the user, so no example models one.
|
|
86
|
+
expect(example, example).not.toMatch(/(^|\s)--(sound|level)\b/)
|
|
83
87
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
)
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
expect(
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('teaches the summary line, the one canonical body, and ordered evidence', () => {
|
|
91
|
+
const send = section('## Send')
|
|
92
|
+
expect(send).toContain('--subtitle')
|
|
93
|
+
expect(send).toContain('--body-file')
|
|
94
|
+
expect(send).toMatch(/one canonical Markdown body/i)
|
|
95
|
+
expect(send).toContain('media:1')
|
|
96
|
+
// Type and project are structured fields, never title text.
|
|
97
|
+
expect(send).toMatch(/never\s+put the kind or the project in it/i)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('separates how long the command waits from how long an answer is accepted', () => {
|
|
101
|
+
const ask = section('## Ask a question')
|
|
102
|
+
expect(ask).toContain('--reply-timeout')
|
|
103
|
+
expect(ask).toContain('--reply-window')
|
|
104
|
+
expect(ask).toMatch(/exit code 3/i)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('makes registration the middle of the asking turn, not the end', () => {
|
|
108
|
+
const ask = section('## Ask a question')
|
|
109
|
+
expect(ask).toMatch(/registering is not the end of the turn/i)
|
|
110
|
+
// The pre-commitment, and the route-neutrality that makes it survive.
|
|
111
|
+
expect(ask).toMatch(/what each answer will make you do/i)
|
|
112
|
+
expect(ask).toMatch(/never say where the answer must arrive/i)
|
|
113
|
+
expect(ask).toMatch(/tell me here/i)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('requires an acknowledgement before any resumed work', () => {
|
|
117
|
+
const answers = section('## When the answer arrives')
|
|
118
|
+
const acknowledge = answers.indexOf('notifai acknowledge')
|
|
119
|
+
const resume = answers.search(/then resume/i)
|
|
120
|
+
expect(acknowledge).toBeGreaterThanOrEqual(0)
|
|
121
|
+
expect(resume).toBeGreaterThan(acknowledge)
|
|
122
|
+
expect(answers).toMatch(/acknowledge before you resume/i)
|
|
123
|
+
// Unconditional: no "if required" escape hatch survives the decision that
|
|
124
|
+
// the user always learns their reply was read.
|
|
125
|
+
expect(answers).not.toMatch(/if .{0,24}acknowledgement is required/i)
|
|
126
|
+
expect(answers).toMatch(/concrete/i)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('keeps the relay honest about transport, authorship, and its limits', () => {
|
|
130
|
+
const answers = section('## When the answer arrives')
|
|
131
|
+
expect(answers).toMatch(/latest reply/i)
|
|
132
|
+
expect(answers).toMatch(/another session/i)
|
|
133
|
+
expect(answers).toMatch(/permission prompt/i)
|
|
134
|
+
expect(answers).toMatch(/without asking them to confirm again/i)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('recovers an answer it was never handed', () => {
|
|
138
|
+
expect(skill).toContain('notifai replies --pending')
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('makes the agent the operator and the human only the human', () => {
|
|
142
|
+
const setup = section('## Set Notifai up')
|
|
143
|
+
expect(setup).toMatch(/never tell the\s+user to run a command you could have run/i)
|
|
144
|
+
expect(setup).toMatch(/browser/i)
|
|
145
|
+
expect(setup).toMatch(/companion/i)
|
|
146
|
+
expect(setup).toContain('notifai doctor --json')
|
|
147
|
+
expect(setup).toContain('notifai init')
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('offers npx only as a pinned fallback behind a real install', () => {
|
|
151
|
+
const global = skill.indexOf('npm install -g @raidiant/notifai')
|
|
152
|
+
const npx = skill.indexOf('npx --yes @raidiant/notifai@')
|
|
153
|
+
expect(global).toBeGreaterThan(0)
|
|
154
|
+
expect(global).toBeLessThan(npx)
|
|
155
|
+
expect(skill).toMatch(/never as the first suggestion/i)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('gives the agent an exit-status branch for every documented outcome', () => {
|
|
159
|
+
for (const code of ['| 0 |', '| 1 |', '| 2 |', '| 3 |', '| 4 |', '| 5 |']) {
|
|
160
|
+
expect(skill).toContain(code)
|
|
115
161
|
}
|
|
162
|
+
expect(skill).toMatch(/`notifai <command> --help` is the authoritative list/i)
|
|
116
163
|
})
|
|
117
164
|
|
|
118
|
-
it('
|
|
119
|
-
|
|
120
|
-
expect(
|
|
121
|
-
expect(
|
|
122
|
-
expect(
|
|
123
|
-
expect(skill).toContain('outside the repository')
|
|
165
|
+
it('points at the record when something did not happen, and calls it private', () => {
|
|
166
|
+
const check = section('## Check what happened')
|
|
167
|
+
expect(check).toContain('notifai logs')
|
|
168
|
+
expect(check).toContain('hook.gate')
|
|
169
|
+
expect(check).toMatch(/never leaves the machine/i)
|
|
124
170
|
})
|
|
125
171
|
|
|
126
|
-
it('
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
172
|
+
it('advertises exactly the gate reasons the code can emit', () => {
|
|
173
|
+
// The drift this replaces: the skill advertised `already-asked` and
|
|
174
|
+
// `no-devices` for months, neither of which was ever emitted, while
|
|
175
|
+
// `answered`, `acknowledgement-required` and `proceeding` went undocumented.
|
|
176
|
+
// Reading the vocabulary from the code makes that impossible to repeat.
|
|
177
|
+
const check = section('## Check what happened')
|
|
178
|
+
const documented = [...check.matchAll(/`([a-z]+(?:-[a-z]+)*)`/g)].map((m) => m[1]!)
|
|
179
|
+
const advertised = new Set(documented.filter((word) => GATE_REASONS.includes(word as never)))
|
|
180
|
+
for (const reason of GATE_REASONS) {
|
|
181
|
+
// `elapsed` is debug-only bookkeeping, not a routing verdict an agent acts on.
|
|
182
|
+
if (reason === 'elapsed') continue
|
|
183
|
+
expect(advertised, `gate reason ${reason} is not documented`).toContain(reason)
|
|
184
|
+
}
|
|
185
|
+
for (const word of documented) {
|
|
186
|
+
if (!word.includes('-')) continue
|
|
187
|
+
const looksLikeAReason = /^(no|already|continuation|delivery|claimed|notifications|acknowledgement)-/.test(word)
|
|
188
|
+
if (!looksLikeAReason) continue
|
|
189
|
+
expect(GATE_REASONS, `the skill advertises "${word}", which nothing emits`).toContain(word)
|
|
190
|
+
}
|
|
132
191
|
})
|
|
133
192
|
|
|
134
|
-
it('
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
expect(
|
|
138
|
-
expect(
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
193
|
+
it('distinguishes acceptance, receipt, and unknown when reporting delivery', () => {
|
|
194
|
+
const check = section('## Check what happened')
|
|
195
|
+
expect(check).toMatch(/provider acceptance/i)
|
|
196
|
+
expect(check).toMatch(/companion receipt/i)
|
|
197
|
+
expect(check).toMatch(/unknown/i)
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('covers every command an agent needs to reach the user and recover', () => {
|
|
201
|
+
for (const command of [
|
|
202
|
+
'notifai send',
|
|
203
|
+
'notifai ask',
|
|
204
|
+
'notifai replies',
|
|
205
|
+
'notifai acknowledge',
|
|
206
|
+
'notifai close',
|
|
207
|
+
'notifai doctor',
|
|
208
|
+
'notifai init',
|
|
209
|
+
'notifai config show',
|
|
210
|
+
'notifai logs',
|
|
211
|
+
'notifai status',
|
|
212
|
+
]) {
|
|
213
|
+
expect(skill, `${command} is unreachable from the skill`).toContain(command)
|
|
214
|
+
}
|
|
143
215
|
})
|
|
144
216
|
|
|
145
217
|
it('moves per-harness mechanics into progressive disclosure', () => {
|
|
146
218
|
expect(skill).toContain('[Harness setup and recovery](references/harness-setup.md)')
|
|
219
|
+
expect(skill).toMatch(/read it when you are\s+installing hooks or diagnosing routing/i)
|
|
147
220
|
for (const harness of ['Claude Code', 'Codex', 'Cursor', 'OpenCode']) {
|
|
148
221
|
expect(harnessReference).toContain(`**${harness}:**`)
|
|
149
222
|
}
|
|
223
|
+
// The agent runs what a process can run; the human only does the human part.
|
|
224
|
+
expect(harnessReference).toContain('install hooks yourself')
|
|
225
|
+
expect(harnessReference).toMatch(/run `notifai login` yourself/i)
|
|
226
|
+
expect(harnessReference).toMatch(/only the user can approve/i)
|
|
150
227
|
})
|
|
151
228
|
})
|
package/src/ui/help.ts
CHANGED
|
@@ -27,7 +27,6 @@ export const GROUP = {
|
|
|
27
27
|
daily: 'Everyday:',
|
|
28
28
|
agent: 'For agents and scripts:',
|
|
29
29
|
advanced: 'Account and wiring:',
|
|
30
|
-
help: 'Help:',
|
|
31
30
|
} as const
|
|
32
31
|
|
|
33
32
|
/** Option groups for `send`, which carries thirty flags. */
|
|
@@ -45,7 +44,6 @@ const GROUP_ORDER: readonly string[] = [
|
|
|
45
44
|
GROUP.daily,
|
|
46
45
|
GROUP.agent,
|
|
47
46
|
GROUP.advanced,
|
|
48
|
-
GROUP.help,
|
|
49
47
|
]
|
|
50
48
|
|
|
51
49
|
function groupRank(heading: string): number {
|
|
@@ -117,7 +115,14 @@ export function rootHelpFooter(): string {
|
|
|
117
115
|
)}`,
|
|
118
116
|
` ${style.code('notifai init')} ${style.dim(`${glyph.arrow} set this project up, step by step`)}`,
|
|
119
117
|
'',
|
|
120
|
-
style.
|
|
118
|
+
style.heading('First call, for an agent'),
|
|
119
|
+
` ${style.code('notifai send --kind done --title "All 42 tests passed" --body "Finished in 3m 10s. Next: review the release candidate."')}`,
|
|
120
|
+
` ${style.code('notifai ask "Deploy migration 0007 to production?" --choice Deploy --choice Hold')}`,
|
|
121
|
+
` ${style.dim(`${glyph.arrow} ${style.code('ask')} needs question routing installed; ${style.code('notifai doctor --json')} says whether it is.`)}`,
|
|
122
|
+
'',
|
|
123
|
+
style.dim(
|
|
124
|
+
`Every command takes ${style.code('--help')}. Where a command has machine-readable output it takes ${style.code('--json')}.`,
|
|
125
|
+
),
|
|
121
126
|
style.dim(`Settings are explained by ${style.code('notifai config show')} and ${style.code('notifai config explain <key>')}.`),
|
|
122
127
|
]
|
|
123
128
|
return lines.join('\n')
|
package/src/ui/theme.ts
CHANGED
|
@@ -25,7 +25,7 @@ export const unicode: boolean =
|
|
|
25
25
|
process.env['ConEmuTask'] === '{cmd::Cmder}'
|
|
26
26
|
|
|
27
27
|
/** True when 24-bit colour is available, which the banner gradient needs. */
|
|
28
|
-
|
|
28
|
+
function supportsTrueColor(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
29
29
|
if (!pc.isColorSupported) return false
|
|
30
30
|
const colorterm = env['COLORTERM'] ?? ''
|
|
31
31
|
if (/^(truecolor|24bit)$/i.test(colorterm)) return true
|
|
@@ -63,7 +63,7 @@ function mix(a: Rgb, b: Rgb, t: number): Rgb {
|
|
|
63
63
|
* Sample the brand ramp at `t` in [0, 1]. Multi-stop so the gradient keeps the
|
|
64
64
|
* icon's coral midtones instead of washing straight from pink to magenta.
|
|
65
65
|
*/
|
|
66
|
-
|
|
66
|
+
function rampAt(t: number, ramp: readonly string[] = BRAND_RAMP): string {
|
|
67
67
|
const clamped = Math.min(1, Math.max(0, t))
|
|
68
68
|
const span = ramp.length - 1
|
|
69
69
|
const scaled = clamped * span
|
|
@@ -83,20 +83,6 @@ const ANSI_GLOBAL = /\[[0-9;]*m/g
|
|
|
83
83
|
const ANSI_SPLIT = /(\[[0-9;]*m)/
|
|
84
84
|
const ANSI_ONLY = /^\[[0-9;]*m$/
|
|
85
85
|
|
|
86
|
-
/**
|
|
87
|
-
* Paint `text` across the brand ramp, one escape per character.
|
|
88
|
-
*
|
|
89
|
-
* Falls back to a single flat brand colour without truecolor, and to the bare
|
|
90
|
-
* string with colour off — a gradient is the most decorative thing this CLI
|
|
91
|
-
* does, so it must also be the first thing to degrade quietly.
|
|
92
|
-
*/
|
|
93
|
-
export function gradient(text: string, ramp: readonly string[] = BRAND_RAMP): string {
|
|
94
|
-
if (!pc.isColorSupported) return text
|
|
95
|
-
if (!supportsTrueColor()) return pc.magenta(text)
|
|
96
|
-
const chars = [...text]
|
|
97
|
-
const last = Math.max(1, chars.length - 1)
|
|
98
|
-
return chars.map((char, i) => (char === ' ' ? char : `${rampAt(i / last, ramp)}${char}`)).join('') + RESET
|
|
99
|
-
}
|
|
100
86
|
|
|
101
87
|
/**
|
|
102
88
|
* Paint each line of a block so the gradient runs left-to-right across the
|