@raidiant/notifai 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/README.md +20 -4
  2. package/dist/atomic-file.d.ts +4 -0
  3. package/dist/atomic-file.js +60 -10
  4. package/dist/atomic-file.js.map +1 -1
  5. package/dist/client.d.ts +25 -2
  6. package/dist/client.js +45 -1
  7. package/dist/client.js.map +1 -1
  8. package/dist/commands.d.ts +85 -6
  9. package/dist/commands.js +1133 -322
  10. package/dist/commands.js.map +1 -1
  11. package/dist/config-schema.d.ts +62 -0
  12. package/dist/config-schema.js +312 -0
  13. package/dist/config-schema.js.map +1 -0
  14. package/dist/config.d.ts +38 -3
  15. package/dist/config.js +61 -14
  16. package/dist/config.js.map +1 -1
  17. package/dist/file-lock.d.ts +26 -0
  18. package/dist/file-lock.js +268 -0
  19. package/dist/file-lock.js.map +1 -0
  20. package/dist/harnesses.d.ts +28 -0
  21. package/dist/harnesses.js +40 -0
  22. package/dist/harnesses.js.map +1 -0
  23. package/dist/hook-adapter.d.ts +27 -0
  24. package/dist/hook-adapter.js +188 -0
  25. package/dist/hook-adapter.js.map +1 -0
  26. package/dist/hook-input.d.ts +10 -0
  27. package/dist/hook-input.js +64 -0
  28. package/dist/hook-input.js.map +1 -0
  29. package/dist/hooks.d.ts +66 -10
  30. package/dist/hooks.js +751 -276
  31. package/dist/hooks.js.map +1 -1
  32. package/dist/install-hooks.d.ts +40 -16
  33. package/dist/install-hooks.js +217 -73
  34. package/dist/install-hooks.js.map +1 -1
  35. package/dist/interactive.d.ts +4 -0
  36. package/dist/interactive.js +604 -0
  37. package/dist/interactive.js.map +1 -0
  38. package/dist/logging.d.ts +142 -0
  39. package/dist/logging.js +533 -0
  40. package/dist/logging.js.map +1 -0
  41. package/dist/main.js +201 -51
  42. package/dist/main.js.map +1 -1
  43. package/dist/opencode-plugin.d.ts +7 -7
  44. package/dist/opencode-plugin.js +12 -13
  45. package/dist/opencode-plugin.js.map +1 -1
  46. package/dist/ui/banner.d.ts +22 -0
  47. package/dist/ui/banner.js +87 -0
  48. package/dist/ui/banner.js.map +1 -0
  49. package/dist/ui/config-view.d.ts +32 -0
  50. package/dist/ui/config-view.js +108 -0
  51. package/dist/ui/config-view.js.map +1 -0
  52. package/dist/ui/help.d.ts +46 -0
  53. package/dist/ui/help.js +105 -0
  54. package/dist/ui/help.js.map +1 -0
  55. package/dist/ui/theme.d.ts +108 -0
  56. package/dist/ui/theme.js +261 -0
  57. package/dist/ui/theme.js.map +1 -0
  58. package/package.json +11 -9
  59. package/src/atomic-file.ts +86 -7
  60. package/src/client.test.ts +12 -0
  61. package/src/client.ts +72 -4
  62. package/src/commands.test.ts +971 -44
  63. package/src/commands.ts +1323 -337
  64. package/src/config-schema.test.ts +152 -0
  65. package/src/config-schema.ts +363 -0
  66. package/src/config.test.ts +6 -5
  67. package/src/config.ts +85 -16
  68. package/src/file-lock-process-worker.test.ts +64 -0
  69. package/src/file-lock.test.ts +279 -0
  70. package/src/file-lock.ts +318 -0
  71. package/src/harnesses.test.ts +48 -0
  72. package/src/harnesses.ts +59 -0
  73. package/src/hook-adapter.test.ts +187 -0
  74. package/src/hook-adapter.ts +212 -0
  75. package/src/hook-input.test.ts +39 -0
  76. package/src/hook-input.ts +72 -0
  77. package/src/hooks.test.ts +1318 -240
  78. package/src/hooks.ts +1046 -296
  79. package/src/install-hooks.test.ts +90 -41
  80. package/src/install-hooks.ts +259 -74
  81. package/src/interactive.ts +692 -0
  82. package/src/logging-process-worker.test.ts +39 -0
  83. package/src/logging.test.ts +453 -0
  84. package/src/logging.ts +639 -0
  85. package/src/main.ts +220 -48
  86. package/src/opencode-plugin.ts +15 -16
  87. package/src/session-end-log-lock-worker.test.ts +35 -0
  88. package/src/skill.test.ts +11 -0
  89. package/src/ui/banner.ts +95 -0
  90. package/src/ui/config-view.test.ts +88 -0
  91. package/src/ui/config-view.ts +138 -0
  92. package/src/ui/help.ts +124 -0
  93. package/src/ui/theme.test.ts +92 -0
  94. package/src/ui/theme.ts +279 -0
@@ -0,0 +1,279 @@
1
+ /**
2
+ * One visual language for every human-facing surface: help, `config show`,
3
+ * `doctor`, and the interactive app.
4
+ *
5
+ * Two rules hold everything here together.
6
+ *
7
+ * Colour is decoration, never information. Every state that colour marks also
8
+ * carries a glyph and a word, because the output is read through pipes, on
9
+ * monochrome terminals, by screen readers, and by people who cannot separate
10
+ * red from green. `picocolors` resolves to identity functions when stdout is
11
+ * not a TTY or `NO_COLOR` is set, so the plain path is the same code, not a
12
+ * second implementation that can drift.
13
+ *
14
+ * And an agent's bytes are not ours to restyle. Machine-readable output
15
+ * (`--json`) never routes through this module, and every non-TTY invocation
16
+ * lands on the uncoloured branch by construction.
17
+ */
18
+ import pc from 'picocolors'
19
+
20
+ /** True when the terminal can render the box-drawing and status glyphs below. */
21
+ export const unicode: boolean =
22
+ process.platform !== 'win32' ||
23
+ process.env['WT_SESSION'] !== undefined ||
24
+ process.env['TERM_PROGRAM'] === 'vscode' ||
25
+ process.env['ConEmuTask'] === '{cmd::Cmder}'
26
+
27
+ /** True when 24-bit colour is available, which the banner gradient needs. */
28
+ export function supportsTrueColor(env: NodeJS.ProcessEnv = process.env): boolean {
29
+ if (!pc.isColorSupported) return false
30
+ const colorterm = env['COLORTERM'] ?? ''
31
+ if (/^(truecolor|24bit)$/i.test(colorterm)) return true
32
+ return env['TERM_PROGRAM'] === 'iTerm.app' || env['TERM_PROGRAM'] === 'vscode'
33
+ }
34
+
35
+ export const color = pc
36
+
37
+ /**
38
+ * Brand ramp, shared with the companion app icon: pink → coral → rose →
39
+ * magenta. Used for the wordmark and for accenting the surfaces a human sees.
40
+ */
41
+ export const BRAND_RAMP = ['#FF9EC0', '#FF816B', '#EC5A73', '#B84E93'] as const
42
+
43
+ interface Rgb {
44
+ r: number
45
+ g: number
46
+ b: number
47
+ }
48
+
49
+ function hexToRgb(hex: string): Rgb {
50
+ const value = Number.parseInt(hex.slice(1), 16)
51
+ return { r: (value >> 16) & 255, g: (value >> 8) & 255, b: value & 255 }
52
+ }
53
+
54
+ function mix(a: Rgb, b: Rgb, t: number): Rgb {
55
+ return {
56
+ r: Math.round(a.r + (b.r - a.r) * t),
57
+ g: Math.round(a.g + (b.g - a.g) * t),
58
+ b: Math.round(a.b + (b.b - a.b) * t),
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Sample the brand ramp at `t` in [0, 1]. Multi-stop so the gradient keeps the
64
+ * icon's coral midtones instead of washing straight from pink to magenta.
65
+ */
66
+ export function rampAt(t: number, ramp: readonly string[] = BRAND_RAMP): string {
67
+ const clamped = Math.min(1, Math.max(0, t))
68
+ const span = ramp.length - 1
69
+ const scaled = clamped * span
70
+ const index = Math.min(span - 1, Math.floor(scaled))
71
+ const { r, g, b } = mix(hexToRgb(ramp[index]!), hexToRgb(ramp[index + 1]!), scaled - index)
72
+ return `[38;2;${r};${g};${b}m`
73
+ }
74
+
75
+ const RESET = ''
76
+
77
+ /**
78
+ * SGR escape sequences, in the three shapes the helpers below need: stripping
79
+ * them to measure, splitting around them to slice, and recognising one on its
80
+ * own. Kept together so a change to what counts as an escape lands once.
81
+ */
82
+ const ANSI_GLOBAL = /\[[0-9;]*m/g
83
+ const ANSI_SPLIT = /(\[[0-9;]*m)/
84
+ const ANSI_ONLY = /^\[[0-9;]*m$/
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
+
101
+ /**
102
+ * Paint each line of a block so the gradient runs left-to-right across the
103
+ * whole block rather than restarting per line. The wordmark is one image, and
104
+ * per-line gradients make it read as several.
105
+ */
106
+ export function gradientBlock(lines: readonly string[], ramp: readonly string[] = BRAND_RAMP): string[] {
107
+ if (!pc.isColorSupported) return [...lines]
108
+ const width = Math.max(1, ...lines.map((line) => [...line].length))
109
+ if (!supportsTrueColor()) return lines.map((line) => pc.magenta(line))
110
+ return lines.map((line) =>
111
+ [...line]
112
+ .map((char, i) => (char === ' ' ? char : `${rampAt(i / Math.max(1, width - 1), ramp)}${char}`))
113
+ .join('') + RESET,
114
+ )
115
+ }
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // Semantic styles
119
+ // ---------------------------------------------------------------------------
120
+
121
+ export const style = {
122
+ /** A heading that opens a section. */
123
+ heading: (text: string): string => pc.bold(text),
124
+ /** The brand voice — command names, the product name, the selected thing. */
125
+ accent: (text: string): string => pc.magenta(text),
126
+ /** Secondary text: hints, provenance, units, defaults. */
127
+ dim: (text: string): string => pc.dim(text),
128
+ /** Something the reader can type. */
129
+ code: (text: string): string => pc.cyan(text),
130
+ ok: (text: string): string => pc.green(text),
131
+ warn: (text: string): string => pc.yellow(text),
132
+ bad: (text: string): string => pc.red(text),
133
+ /** A value as it currently stands. */
134
+ value: (text: string): string => pc.bold(pc.white(text)),
135
+ /** A value that is not set, or is inherited rather than chosen. */
136
+ unset: (text: string): string => pc.dim(pc.italic(text)),
137
+ } as const
138
+
139
+ /**
140
+ * Status marks. The glyph and the colour say the same thing twice on purpose;
141
+ * either alone is enough to read the line.
142
+ */
143
+ export const glyph = {
144
+ ok: unicode ? '●' : '*',
145
+ warn: unicode ? '▲' : '!',
146
+ bad: unicode ? '✕' : 'x',
147
+ pending: unicode ? '○' : 'o',
148
+ bullet: unicode ? '•' : '-',
149
+ arrow: unicode ? '→' : '->',
150
+ chevron: unicode ? '›' : '>',
151
+ bar: unicode ? '│' : '|',
152
+ } as const
153
+
154
+ export type Tone = 'ok' | 'warn' | 'bad' | 'pending'
155
+
156
+ /** A coloured glyph for a tone; the caller supplies the words. */
157
+ export function mark(tone: Tone): string {
158
+ switch (tone) {
159
+ case 'ok':
160
+ return style.ok(glyph.ok)
161
+ case 'warn':
162
+ return style.warn(glyph.warn)
163
+ case 'bad':
164
+ return style.bad(glyph.bad)
165
+ case 'pending':
166
+ return style.dim(glyph.pending)
167
+ }
168
+ }
169
+
170
+ /** Visible width, ignoring ANSI escapes, for alignment maths. */
171
+ export function width(text: string): number {
172
+ return [...text.replace(ANSI_GLOBAL, '')].length
173
+ }
174
+
175
+ /** Pad to `size` using visible width, so colour never breaks a column. */
176
+ export function pad(text: string, size: number): string {
177
+ return text + ' '.repeat(Math.max(0, size - width(text)))
178
+ }
179
+
180
+ /**
181
+ * Usable terminal width: the real one, capped so paragraphs do not run to 200
182
+ * characters on a maximised window.
183
+ *
184
+ * No lower clamp. Raising a narrow terminal to a comfortable minimum sounds
185
+ * harmless and is not — every box drawn to that invented width overflows the
186
+ * real one and wraps, which looks far worse than a cramped box that fits.
187
+ */
188
+ export function terminalWidth(fallback = 80): number {
189
+ const columns = process.stdout.columns
190
+ if (typeof columns !== 'number' || columns <= 0) return fallback
191
+ return Math.min(100, columns)
192
+ }
193
+
194
+ /**
195
+ * A titled box around `rows`.
196
+ *
197
+ * Hand-drawn rather than delegated, because the obvious library call sizes
198
+ * itself from `process.stdout.columns` and divides by it — a pty that reports
199
+ * zero columns (some CI runners, some `script` invocations) makes the padding
200
+ * negative and throws, taking the whole screen down with it. A status card is
201
+ * decoration; it may degrade, but it must never be what crashes the app.
202
+ *
203
+ * Rows are measured with `width`, so colour inside them does not skew the
204
+ * border, and anything too wide for the terminal is clipped rather than
205
+ * wrapped — a status line that wraps stops being a status line.
206
+ */
207
+ export function card(title: string, rows: readonly string[], columns = terminalWidth()): string[] {
208
+ const [tl, tr, bl, br, h, v] = unicode
209
+ ? ['╭', '╮', '╰', '╯', '─', '│']
210
+ : ['+', '+', '+', '+', '-', '|']
211
+ const inner = Math.max(20, columns - 4)
212
+ const content = rows.map((row) => (width(row) > inner ? clip(row, inner) : row))
213
+ const bodyWidth = Math.max(width(title) + 4, ...content.map((row) => width(row)))
214
+ const boxWidth = Math.min(inner, bodyWidth)
215
+ const heading = `${h} ${style.heading(title)} `
216
+ const lines: string[] = []
217
+ lines.push(style.dim(tl) + heading + style.dim(h!.repeat(Math.max(0, boxWidth - width(heading) + 2))) + style.dim(tr))
218
+ for (const row of content) {
219
+ lines.push(`${style.dim(v)} ${pad(row, boxWidth)} ${style.dim(v)}`)
220
+ }
221
+ lines.push(style.dim(bl! + h!.repeat(boxWidth + 2) + br))
222
+ return lines
223
+ }
224
+
225
+ /**
226
+ * Truncate to a visible width, carrying escape sequences through untouched.
227
+ *
228
+ * Counting only printable characters is the whole point: slicing a coloured
229
+ * string by `String.length` cuts through an escape sequence and spills raw
230
+ * `[38;2` into the terminal.
231
+ */
232
+ export function clip(text: string, limit: number): string {
233
+ if (width(text) <= limit) return text
234
+ const parts = text.split(ANSI_SPLIT)
235
+ let out = ''
236
+ let visible = 0
237
+ for (const part of parts) {
238
+ if (part === undefined || part === '') continue
239
+ if (ANSI_ONLY.test(part)) {
240
+ out += part
241
+ continue
242
+ }
243
+ for (const char of part) {
244
+ if (visible >= limit - 1) return `${out}\u2026${RESET}`
245
+ out += char
246
+ visible += 1
247
+ }
248
+ }
249
+ return `${out}${RESET}`
250
+ }
251
+
252
+ /**
253
+ * Wrap `text` to `columns`, returning bare lines.
254
+ *
255
+ * Indentation is the caller's, deliberately: an earlier version indented
256
+ * continuation lines itself, callers indented every line as well, and each
257
+ * wrapped paragraph stepped further right down the page.
258
+ */
259
+ export function wrap(text: string, columns: number): string[] {
260
+ // Honour the width asked for. A floor here would silently ignore small
261
+ // values and hand back lines wider than the caller reserved room for; a
262
+ // width of zero or less is the only nonsense, and that falls back.
263
+ const limit = columns > 0 ? columns : 80
264
+ const words = text.split(/\s+/).filter(Boolean)
265
+ if (words.length === 0) return ['']
266
+ const lines: string[] = []
267
+ let line = ''
268
+ for (const word of words) {
269
+ const candidate = line === '' ? word : `${line} ${word}`
270
+ if (width(candidate) > limit && line !== '') {
271
+ lines.push(line)
272
+ line = word
273
+ } else {
274
+ line = candidate
275
+ }
276
+ }
277
+ if (line !== '') lines.push(line)
278
+ return lines
279
+ }