dsh-tui-theme 0.5.1 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Validate bundled themes against matching dsh-TUI source and runtime modules.
3
- * DSH_TUI_SOURCE_ROOT must point at a dsh-TUI source checkout.
4
- * DSH_TUI_ADAPTER_DIR must point at the matching lib/types/dsh-adapter directory.
3
+ * DSH_TUI_SOURCE_ROOT and DSH_TUI_ADAPTER_DIR may point at a matching source
4
+ * checkout and adapter directory. When omitted, the installed dsh-TUI
5
+ * devDependency is used as a zero-configuration baseline.
5
6
  * Set DSH_TUI_EXPECTED_VERSION only when an explicit release baseline needs
6
7
  * to be pinned; ordinary development verifies the supplied host as-is.
7
8
  *
@@ -9,25 +10,32 @@
9
10
  */
10
11
  import { existsSync, mkdtempSync, readFileSync, readdirSync } from 'node:fs'
11
12
  import { tmpdir } from 'node:os'
12
- import { join, resolve } from 'node:path'
13
+ import { dirname, join, resolve } from 'node:path'
13
14
  import { fileURLToPath, pathToFileURL } from 'node:url'
14
15
  import assert from 'node:assert/strict'
15
16
  import ts from 'typescript'
17
+ import { createRequire } from 'node:module'
16
18
 
17
19
  const pluginRoot = fileURLToPath(new URL('..', import.meta.url))
18
20
  const sourceRoot = process.env.DSH_TUI_SOURCE_ROOT
19
- if (sourceRoot === undefined || sourceRoot === '') {
20
- throw new Error('DSH_TUI_SOURCE_ROOT must point at a dsh-TUI source checkout for this host theme validation.')
21
- }
22
- const hostRoot = resolve(sourceRoot)
23
- const customThemePath = join(hostRoot, 'src', 'customTheme.ts')
24
- const themePath = join(hostRoot, 'src', 'theme.ts')
21
+ const hostRequire = createRequire(import.meta.url)
22
+ const devPackageJson = hostRequire.resolve('@deepseek-harness-tui/dsh-tui/package.json')
23
+ const hostRoot = sourceRoot === undefined || sourceRoot === ''
24
+ ? dirname(devPackageJson)
25
+ : resolve(sourceRoot)
26
+ const sourceMode = sourceRoot !== undefined && sourceRoot !== ''
27
+ const customThemePath = sourceMode
28
+ ? join(hostRoot, 'src', 'customTheme.ts')
29
+ : join(hostRoot, 'lib', 'types', 'customTheme.js')
30
+ const themePath = sourceMode
31
+ ? join(hostRoot, 'src', 'theme.ts')
32
+ : join(hostRoot, 'lib', 'types', 'theme.d.ts')
25
33
 
26
34
  if (!existsSync(customThemePath) || !existsSync(themePath)) {
27
- throw new Error(`dsh-TUI sources not found at ${hostRoot}`)
35
+ throw new Error(`dsh-TUI ${sourceMode ? 'sources' : 'devDependency build'} not found at ${hostRoot}`)
28
36
  }
29
37
 
30
- const hostPackagePath = join(hostRoot, 'package.json')
38
+ const hostPackagePath = sourceMode ? join(hostRoot, 'package.json') : devPackageJson
31
39
  if (!existsSync(hostPackagePath)) {
32
40
  throw new Error(`dsh-TUI package metadata not found at ${hostPackagePath}`)
33
41
  }
@@ -35,10 +43,10 @@ const hostPackage = JSON.parse(readFileSync(hostPackagePath, 'utf8'))
35
43
  assert.match(hostPackage.version, /^\d+\.\d+\.\d+(?:[-+].+)?$/u, 'host source must declare a version')
36
44
 
37
45
  const adapter = process.env.DSH_TUI_ADAPTER_DIR
38
- if (adapter === undefined || adapter === '') {
46
+ if (sourceMode && (adapter === undefined || adapter === '')) {
39
47
  throw new Error('DSH_TUI_ADAPTER_DIR must point at dsh-TUI lib/types/dsh-adapter for this host theme validation.')
40
48
  }
41
- const adapterRoot = resolve(adapter)
49
+ const adapterRoot = resolve(adapter || join(hostRoot, 'lib', 'types', 'dsh-adapter'))
42
50
  const runtimeThemePath = join(adapterRoot, '..', 'theme.js')
43
51
  const runtimeCustomThemePath = join(adapterRoot, '..', 'customTheme.js')
44
52
  if (!existsSync(runtimeThemePath) || !existsSync(runtimeCustomThemePath)) {
@@ -86,7 +94,7 @@ function readThemeKeysFromSource(path) {
86
94
  return keys
87
95
  }
88
96
 
89
- const allKeys = readThemeKeysFromSource(themePath)
97
+ const allKeys = sourceMode ? readThemeKeysFromSource(themePath) : Object.keys(getTheme('dark'))
90
98
  assert.deepEqual(
91
99
  [...Object.keys(getTheme('dark'))].sort(),
92
100
  [...allKeys].sort(),
@@ -40,6 +40,10 @@ for (const required of [
40
40
  'lib/types/statusLine.d.ts',
41
41
  'lib/types/themeAssets.js',
42
42
  'lib/types/themeAssets.d.ts',
43
+ 'lib/types/runtimeThemes.js',
44
+ 'lib/types/runtimeThemes.d.ts',
45
+ 'lib/types/toast.js',
46
+ 'lib/types/toast.d.ts',
43
47
  'themes/pink-night.json',
44
48
  'themes/pink-day.json',
45
49
  'themes/pink-ansi.json',
@@ -49,6 +53,7 @@ for (const required of [
49
53
  'scripts/verify.mjs',
50
54
  'scripts/verify-package.mjs',
51
55
  'scripts/headless-order-test.mjs',
56
+ 'scripts/runtime-themes-headless.mjs',
52
57
  'scripts/validate-themes-against-host.mjs',
53
58
  'scripts/expected-settings-contract.mjs',
54
59
  ]) {
@@ -68,7 +73,15 @@ for (const entry of exportedPaths) {
68
73
  for (const name of Object.keys(packageJson.peerDependencies)) {
69
74
  if (!name.startsWith('@deepseek-ai/')) continue
70
75
  assert.equal(packageJson.dependencies?.[name], undefined, `${name} must not be a runtime dependency`)
71
- assert.equal(packageJson.devDependencies?.[name], packageJson.peerDependencies[name], `${name} peer and dev ranges must match`)
76
+ const peerRange = packageJson.peerDependencies[name]
77
+ const devRange = packageJson.devDependencies?.[name]
78
+ // Development baselines may pin one concrete prerelease while the published
79
+ // peer contract remains a union of compatible release lines.
80
+ const devAccepted = peerRange.split('||').some(entry => {
81
+ const candidate = entry.trim()
82
+ return candidate === devRange || candidate.replace(/^\^/u, '') === devRange
83
+ })
84
+ assert.equal(devAccepted, true, `${name} dev pin must be accepted by its peer range`)
72
85
  }
73
86
  assert.equal(packageJson.dependencies?.['@deepseek-ai/schemastery'], undefined)
74
87
  const rootLock = lockfile.packages?.['']
@@ -44,8 +44,9 @@ const builtinFs = require('node:fs')
44
44
  const sandboxThemes = join(sandboxHome, '.dsh-tui', 'themes')
45
45
 
46
46
  const { name, apply } = await import('../lib/types/index.js')
47
- const { installBundledThemes } = await import('../lib/types/themeAssets.js')
48
- const { startStatusLine } = await import('../lib/types/statusLine.js')
47
+ const { installBundledThemes, readBundledThemes, findShadowedBundledThemes } = await import('../lib/types/themeAssets.js')
48
+ const { startStatusLine, invalidateThemePrefCacheForTests } = await import('../lib/types/statusLine.js')
49
+ const { setToastRetryDelaysForTests } = await import('../lib/types/toast.js')
49
50
  const {
50
51
  themeForBackground,
51
52
  readThemePref,
@@ -56,16 +57,27 @@ const {
56
57
  } = await import('../lib/types/autoTheme.js')
57
58
 
58
59
  assert.equal(name, 'dsh-tui-theme')
60
+ const settle = () => new Promise(resolve => setTimeout(resolve, 0))
61
+ const applyAndSettle = async (ctx, config) => {
62
+ apply(ctx, config)
63
+ await settle()
64
+ }
59
65
 
60
66
  /** A stub Cordis-like context; every seam optional and recorded. */
61
- function makeStubCtx({ status, sections, settingsService } = {}) {
62
- const record = { handlers: new Map(), disposers: [], statusCalls: [], sectionsCalls: [], registerCalls: [], watchers: [], warnings: [], infos: [] }
67
+ function makeStubCtx({ status, sections, settingsService, themes, toast, deferThemes = false, deferToast = false } = {}) {
68
+ const record = { handlers: new Map(), disposers: [], statusCalls: [], sectionsCalls: [], registerCalls: [], watchers: [], themeRegisters: [], warnings: [], infos: [] }
69
+ let availableThemes = themes
70
+ let availableToast = toast
71
+ const deferredThemeCallbacks = []
72
+ const deferredToastCallbacks = []
63
73
  const logger = { info: msg => record.infos.push(String(msg)), warn: msg => record.warnings.push(String(msg)), error: () => {} }
64
74
  const base = {
65
75
  logger,
66
76
  get(serviceName) {
67
77
  if (serviceName === 'tuiStatus') return status
68
78
  if (serviceName === 'tuiSettingsSections') return sections
79
+ if (serviceName === 'tuiThemes') return availableThemes
80
+ if (serviceName === 'tuiToast') return availableToast
69
81
  return undefined
70
82
  },
71
83
  on(event, handler) {
@@ -85,6 +97,16 @@ function makeStubCtx({ status, sections, settingsService } = {}) {
85
97
  settings: settingsService,
86
98
  tuiStatus: status,
87
99
  tuiSettingsSections: sections,
100
+ tuiThemes: availableThemes,
101
+ tuiToast: availableToast,
102
+ }
103
+ if (deferThemes && availableThemes === undefined && deps.includes('tuiThemes')) {
104
+ deferredThemeCallbacks.push(callback)
105
+ return
106
+ }
107
+ if (deferToast && availableToast === undefined && deps.includes('tuiToast')) {
108
+ deferredToastCallbacks.push(callback)
109
+ return
88
110
  }
89
111
  if (deps.every(dep => services[dep] !== undefined)) {
90
112
  const props = Object.fromEntries(deps.map(dep => [dep, services[dep]]))
@@ -92,11 +114,26 @@ function makeStubCtx({ status, sections, settingsService } = {}) {
92
114
  }
93
115
  },
94
116
  }
117
+ record.activateThemes = service => {
118
+ availableThemes = service
119
+ for (const callback of deferredThemeCallbacks.splice(0)) callback({ ...base, tuiThemes: service })
120
+ }
121
+ record.activateToast = service => {
122
+ availableToast = service
123
+ for (const callback of deferredToastCallbacks.splice(0)) callback({ ...base, tuiToast: service })
124
+ }
95
125
  return { ctx: base, record }
96
126
  }
97
127
 
98
128
  const fakeStatus = calls => ({ set(key, text) { calls.push([key, text]); return () => {} } })
99
129
  const fakeSections = calls => ({ register(section) { calls.push(section); return () => {} } })
130
+ const fakeThemes = (record, { throws = false } = {}) => ({
131
+ register(descriptor, identity) {
132
+ record.themeRegisters.push([descriptor, identity])
133
+ if (throws) throw new Error('runtime registry unavailable')
134
+ return () => {}
135
+ },
136
+ })
100
137
  const fakeSettingsService = (record, doc) => ({
101
138
  register(namespace, schema) {
102
139
  record.registerCalls.push([namespace, schema])
@@ -106,6 +143,18 @@ const fakeSettingsService = (record, doc) => ({
106
143
  }
107
144
  },
108
145
  })
146
+ /** Records delivered toasts; the first `dropFirst` shows are dropped (no sink yet). */
147
+ const fakeToast = (deliveries, { dropFirst = 0 } = {}) => {
148
+ let shown = 0
149
+ return {
150
+ show(text, options) {
151
+ shown += 1
152
+ if (shown <= dropFirst) return false
153
+ deliveries.push([String(text), options?.color])
154
+ return true
155
+ },
156
+ }
157
+ }
109
158
 
110
159
  const emit = (record, event, ...args) => {
111
160
  for (const handler of record.handlers.get(event) ?? []) handler(...args)
@@ -138,7 +187,7 @@ const emit = (record, event, ...args) => {
138
187
  sections: fakeSections(sectionsCalls),
139
188
  settingsService: fakeSettingsService(settingsRecord, {}),
140
189
  })
141
- apply(ctx)
190
+ await applyAndSettle(ctx)
142
191
 
143
192
  // Themes installed into the SANDBOX, never the real home.
144
193
  for (const theme of ['pink-night', 'pink-day', 'pink-ansi']) {
@@ -176,8 +225,62 @@ const emit = (record, event, ...args) => {
176
225
  console.log('✓ full host: themes installed, status line live, settings wired')
177
226
  }
178
227
 
228
+ // ── 2a. runtime themes: service owns palettes and static files stay absent ───
229
+ {
230
+ rmSync(sandboxThemes, { recursive: true, force: true })
231
+ const registrations = []
232
+ const themes = { register(descriptor, identity) { registrations.push([descriptor, identity]); return () => {} } }
233
+ const { ctx } = makeStubCtx({ themes })
234
+ await applyAndSettle(ctx)
235
+ assert.equal(existsSync(sandboxThemes), false, 'runtime registration must not create static files')
236
+ const expected = readBundledThemes().map(({ file, ...theme }) => theme)
237
+ assert.deepEqual(registrations.map(([descriptor]) => descriptor), expected)
238
+ assert.equal(
239
+ registrations.every(([, identity]) => identity?.tuiThemes !== undefined),
240
+ true,
241
+ 'runtime registrations use the inject-scoped identity',
242
+ )
243
+ console.log('✓ runtime themes: three descriptors registered without static files')
244
+ }
245
+
246
+ // ── 2b. late runtime service: confirmation removes this activation's files ──
247
+ {
248
+ rmSync(sandboxThemes, { recursive: true, force: true })
249
+ const { ctx, record } = makeStubCtx({ deferThemes: true })
250
+ apply(ctx)
251
+ assert.equal(existsSync(sandboxThemes), true, 'legacy fallback must install synchronously before service arrival')
252
+ record.activateThemes(fakeThemes(record))
253
+ await settle()
254
+ assert.equal(existsSync(sandboxThemes), false, 'late runtime service must remove all fallback files and the empty directory')
255
+
256
+ rmSync(sandboxThemes, { recursive: true, force: true })
257
+ const protectedContext = makeStubCtx({ deferThemes: true })
258
+ apply(protectedContext.ctx)
259
+ assert.equal(existsSync(sandboxThemes), true, 'legacy fallback must install before protecting a user edit')
260
+ const protectedTheme = join(sandboxThemes, 'pink-night.json')
261
+ writeFileSync(protectedTheme, '{ "name": "pink-night", "colors": { "text": "#123456" } }')
262
+ protectedContext.record.activateThemes(fakeThemes(protectedContext.record))
263
+ await settle()
264
+ assert.equal(existsSync(join(sandboxThemes, 'pink-day.json')), false, 'late runtime service must remove plugin-owned files')
265
+ assert.equal(existsSync(join(sandboxThemes, 'pink-ansi.json')), false, 'late runtime service must remove plugin-owned files')
266
+ assert.equal(readFileSync(protectedTheme, 'utf8'), '{ "name": "pink-night", "colors": { "text": "#123456" } }')
267
+ rmSync(sandboxThemes, { recursive: true, force: true })
268
+ console.log('✓ runtime race: late service wins and removes static fallback files')
269
+ }
270
+
271
+ // ── 2c. hostile runtime service: registration failures degrade to warnings ───
272
+ {
273
+ const themeRecord = { themeRegisters: [] }
274
+ const { ctx, record } = makeStubCtx({ themes: fakeThemes(themeRecord, { throws: true }) })
275
+ await applyAndSettle(ctx)
276
+ assert.equal(record.warnings.length, 3, 'each hostile registration is contained and warned')
277
+ console.log('✓ hostile runtime service: registration failures warn, never propagate')
278
+ }
279
+
179
280
  // ── 3. idempotence + user-file protection ───────────────────────────────────
180
281
  {
282
+ const seeded = installBundledThemes()
283
+ assert.equal(seeded.installed.length, 3)
181
284
  const again = installBundledThemes()
182
285
  assert.deepEqual(again.installed, [])
183
286
  assert.deepEqual(again.repaired, [])
@@ -197,7 +300,7 @@ const emit = (record, event, ...args) => {
197
300
  rmSync(sandboxHome, { recursive: true, force: true })
198
301
  mkdirSync(sandboxHome, { recursive: true })
199
302
  const { ctx } = makeStubCtx()
200
- apply(ctx, { autoInstallThemes: false })
303
+ await applyAndSettle(ctx, { autoInstallThemes: false })
201
304
  assert.equal(existsSync(join(sandboxHome, '.dsh-tui')), false, 'must not create the dir when disabled')
202
305
  console.log('✓ autoInstallThemes=false: leaves the themes dir untouched')
203
306
  }
@@ -218,15 +321,27 @@ const emit = (record, event, ...args) => {
218
321
  // Same value -> no rewrite churn (mtime-agnostic: pref already matches).
219
322
  assert.equal(applyCachedFollow(dataDir), 'pink-day')
220
323
 
221
- // The follow runner only applies cache and reports its result. It accepts no
222
- // stdin/stdout handles and cannot create a raw-mode lease or input listener.
223
- const logs = []
224
- runFollowSystem(dataDir, () => true, message => logs.push(message))
225
- assert.deepEqual(logs, ['follow: applied cached background (pink-day)'])
324
+ // The follow runner only applies cache and reports its structured outcome.
325
+ // It accepts no stdin/stdout handles and cannot create a raw-mode lease or
326
+ // input listener.
327
+ const reports = []
328
+ runFollowSystem(dataDir, () => true, outcome => reports.push(outcome))
329
+ assert.deepEqual(reports, [{ kind: 'applied', theme: 'pink-day', changed: false }])
226
330
 
227
331
  writeFileSync(join(dataDir, 'theme.json'), JSON.stringify({ theme: 'dark' }, null, 2))
228
- runFollowSystem(dataDir, () => false, message => logs.push(message))
332
+ runFollowSystem(dataDir, () => false, outcome => reports.push(outcome))
229
333
  assert.equal(readThemePref(dataDir), 'dark', 'inactive follow preserves manual choice')
334
+
335
+ runFollowSystem(dataDir, () => true, outcome => reports.push(outcome))
336
+ assert.deepEqual(
337
+ reports.at(-1),
338
+ { kind: 'applied', theme: 'pink-day', changed: true },
339
+ 'a real pref flip is reported as changed',
340
+ )
341
+
342
+ rmSync(join(dataDir, 'theme-follow.json'), { force: true })
343
+ runFollowSystem(dataDir, () => true, outcome => reports.push(outcome))
344
+ assert.deepEqual(reports.at(-1), { kind: 'unavailable' })
230
345
  console.log('✓ follow: cached background applies without terminal I/O')
231
346
  }
232
347
 
@@ -234,7 +349,7 @@ const emit = (record, event, ...args) => {
234
349
  {
235
350
  const statusCalls = []
236
351
  const { ctx } = makeStubCtx({ status: fakeStatus(statusCalls) })
237
- apply(ctx, { statusEnabled: false })
352
+ await applyAndSettle(ctx, { statusEnabled: false })
238
353
  assert.equal(statusCalls.length > 0, true, 'render still ran once')
239
354
  // Every contribution is cleared (undefined), not rendered.
240
355
  for (const [, text] of statusCalls) assert.equal(text, undefined)
@@ -252,7 +367,7 @@ const emit = (record, event, ...args) => {
252
367
  sections: fakeSections([]),
253
368
  settingsService: fakeSettingsService(settingsRecord, {}),
254
369
  })
255
- apply(ctx, { followSystem: true })
370
+ await applyAndSettle(ctx, { followSystem: true })
256
371
  // Cordis layer on, empty user layer, no cache yet -> no pref churn.
257
372
  assert.equal(existsSync(join(dataDir, 'theme.json')), false)
258
373
 
@@ -277,15 +392,17 @@ const emit = (record, event, ...args) => {
277
392
  status: fakeStatus(statusCalls),
278
393
  settingsService: fakeSettingsService(settingsRecord, {}),
279
394
  })
280
- apply(ctx)
395
+ await applyAndSettle(ctx)
281
396
  const session = { id: 'g1' }
282
397
 
283
398
  // Pink active → renders.
284
399
  emit(record, 'session/event', session, { type: 'turn/end' })
285
400
  assert.match(statusCalls.at(-1)[1], /^✿ · \d{2}:\d{2} · 1✦$/)
286
401
 
287
- // Non-pink theme active → hidden by default.
402
+ // Non-pink theme active → hidden by default. The pref cache is dropped so
403
+ // the rewrite is visible immediately (production invalidation is the TTL).
288
404
  writeFileSync(themePrefPath, JSON.stringify({ theme: 'dark' }, null, 2))
405
+ invalidateThemePrefCacheForTests()
289
406
  emit(record, 'session/event', session, { type: 'turn/end' })
290
407
  assert.equal(statusCalls.at(-1)[1], undefined)
291
408
 
@@ -318,7 +435,7 @@ const emit = (record, event, ...args) => {
318
435
  register() { throw new Error('namespace already registered (hot reload)') },
319
436
  }
320
437
  const { ctx, record } = makeStubCtx({ settingsService: throwingSettings })
321
- apply(ctx) // must not throw
438
+ await applyAndSettle(ctx) // must not throw
322
439
  assert.equal(record.warnings.length, 1, 'registration failure logs one warning')
323
440
  assert.match(record.warnings[0], /settings namespace registration failed/)
324
441
  console.log('✓ hostile settings service: registration failure warns, never propagates')
@@ -331,7 +448,7 @@ const emit = (record, event, ...args) => {
331
448
  writeFileSync(join(dataDir, 'theme.json'), JSON.stringify({ theme: 'dark' }, null, 2))
332
449
  writeFileSync(join(dataDir, 'theme-follow.json'), JSON.stringify({ light: true, at: 1 }))
333
450
  const { ctx } = makeStubCtx()
334
- apply(ctx, { followSystem: true })
451
+ await applyAndSettle(ctx, { followSystem: true })
335
452
  assert.equal(readThemePref(dataDir), 'dark', 'without settings, cached follow must not override manual choice')
336
453
  console.log('✓ missing settings: cached follow leaves the manual choice intact')
337
454
  }
@@ -431,6 +548,27 @@ const emit = (record, event, ...args) => {
431
548
  builtinFs.readFileSync = originalRead
432
549
  syncBuiltinESMExports()
433
550
  }
551
+
552
+ // A successful backup followed by a failed replacement is a failure, not a
553
+ // protected skip: the target is absent until the next boot can retry.
554
+ const failedHeal = join(targetDir, 'pink-ansi.json')
555
+ writeFileSync(failedHeal, '{ broken')
556
+ const originalWrite = builtinFs.writeFileSync
557
+ try {
558
+ builtinFs.writeFileSync = (path, data, options) => {
559
+ if (String(path) === failedHeal && options?.flag === 'wx' && !existsSync(failedHeal)) {
560
+ throw new Error('ENOSPC: replacement blocked')
561
+ }
562
+ return originalWrite(path, data, options)
563
+ }
564
+ syncBuiltinESMExports()
565
+ const failed = installBundledThemes(targetDir, sourceDir)
566
+ assert.equal(failed.failed.includes('pink-ansi.json'), true)
567
+ assert.equal(failed.skipped.includes('pink-ansi.json'), false)
568
+ } finally {
569
+ builtinFs.writeFileSync = originalWrite
570
+ syncBuiltinESMExports()
571
+ }
434
572
  console.log('✓ torn installation: corrupt target backed up and reinstalled; user files and unreadable targets untouched')
435
573
  }
436
574
 
@@ -439,7 +577,7 @@ const emit = (record, event, ...args) => {
439
577
  // Default user layer (empty doc): no spurious "follow: disabled" line.
440
578
  const settingsRecord = { registerCalls: [], watchers: [] }
441
579
  const { ctx, record } = makeStubCtx({ settingsService: fakeSettingsService(settingsRecord, {}) })
442
- apply(ctx)
580
+ await applyAndSettle(ctx)
443
581
  assert.equal(
444
582
  record.infos.some(message => message.includes('follow: disabled')),
445
583
  false,
@@ -455,7 +593,7 @@ const emit = (record, event, ...args) => {
455
593
  sections: fakeSections([]),
456
594
  settingsService: fakeSettingsService(enabledRecord, { followSystem: true }),
457
595
  })
458
- apply(enabledCtx)
596
+ await applyAndSettle(enabledCtx)
459
597
  assert.equal(readThemePref(dataDir), 'pink-day', 'an enabled baseline applies the cached background')
460
598
  assert.equal(
461
599
  enabledInfo.infos.some(message => message.includes('follow: disabled')),
@@ -541,5 +679,243 @@ const emit = (record, event, ...args) => {
541
679
  console.log('✓ status lifecycle: session handlers follow tuiStatus activation')
542
680
  }
543
681
 
682
+ // ── 13. toast feedback: the 0.10 seam surfaces what the logger cannot ───────
683
+ {
684
+ setToastRetryDelaysForTests([5, 5])
685
+ const dataDir = join(sandboxHome, '.dsh-tui')
686
+ mkdirSync(dataDir, { recursive: true })
687
+
688
+ // 13a. Startup baseline with a disagreeing cache: one success toast that
689
+ // points at /reload (the live TUI still shows the previous palette). The
690
+ // tuiToast seam trails apply on a real host (extensions row), so the send
691
+ // must survive on the bounded retry until the seam shows up.
692
+ writeFileSync(join(dataDir, 'theme.json'), JSON.stringify({ theme: 'pink-night' }, null, 2))
693
+ writeFileSync(join(dataDir, 'theme-follow.json'), JSON.stringify({ light: true, at: 1 }))
694
+ const baselineDeliveries = []
695
+ const baselineRecord = { registerCalls: [], watchers: [] }
696
+ const baselineContext = makeStubCtx({
697
+ status: fakeStatus([]),
698
+ sections: fakeSections([]),
699
+ settingsService: fakeSettingsService(baselineRecord, { followSystem: true }),
700
+ deferToast: true,
701
+ })
702
+ await applyAndSettle(baselineContext.ctx)
703
+ assert.equal(readThemePref(dataDir), 'pink-day', 'the pref write is synchronous, independent of the toast seam')
704
+ baselineContext.record.activateToast(fakeToast(baselineDeliveries))
705
+ const baselineDeadline = Date.now() + 2_000
706
+ while (baselineDeliveries.length === 0 && Date.now() < baselineDeadline) {
707
+ await new Promise(resolve => setTimeout(resolve, 5))
708
+ }
709
+ assert.equal(baselineDeliveries.length, 1, 'a real baseline pref write toasts exactly once')
710
+ assert.equal(baselineDeliveries[0][1], 'success')
711
+ assert.match(baselineDeliveries[0][0], /pink-day/)
712
+ assert.match(baselineDeliveries[0][0], /reload/)
713
+
714
+ // 13b. Stable baseline (pref already matches the cache): quiet.
715
+ const quietDeliveries = []
716
+ const quietRecord = { registerCalls: [], watchers: [] }
717
+ const { ctx: quietCtx } = makeStubCtx({
718
+ status: fakeStatus([]),
719
+ sections: fakeSections([]),
720
+ settingsService: fakeSettingsService(quietRecord, { followSystem: true }),
721
+ toast: fakeToast(quietDeliveries),
722
+ })
723
+ await applyAndSettle(quietCtx)
724
+ assert.equal(quietDeliveries.length, 0, 'a baseline that changes nothing stays silent')
725
+
726
+ // 13c. Toggle confirmations: an explicit enable always answers — with the
727
+ // matching-cache confirmation, or the honest warning when nothing applies.
728
+ const toggleDeliveries = []
729
+ const toggleRecord = { registerCalls: [], watchers: [] }
730
+ const { ctx: toggleCtx } = makeStubCtx({
731
+ status: fakeStatus([]),
732
+ sections: fakeSections([]),
733
+ settingsService: fakeSettingsService(toggleRecord, {}),
734
+ toast: fakeToast(toggleDeliveries),
735
+ })
736
+ await applyAndSettle(toggleCtx)
737
+ for (const w of toggleRecord.watchers) w({ followSystem: true })
738
+ assert.equal(toggleDeliveries.length, 1, 'toggle-on with a matching cache confirms')
739
+ assert.equal(toggleDeliveries[0][1], 'success')
740
+ assert.equal(/reload/.test(toggleDeliveries[0][0]), false, 'no reload hint when nothing changed')
741
+ for (const w of toggleRecord.watchers) w({ followSystem: false })
742
+ rmSync(join(dataDir, 'theme-follow.json'), { force: true })
743
+ for (const w of toggleRecord.watchers) w({ followSystem: true })
744
+ assert.equal(toggleDeliveries.length, 2, 'toggle-on without a cache answers too')
745
+ assert.equal(toggleDeliveries[1][1], 'warning')
746
+
747
+ // 13d. A toast dropped before the host sink exists is retried until delivered.
748
+ const retryDeliveries = []
749
+ const retryRecord = { registerCalls: [], watchers: [] }
750
+ const { ctx: retryCtx } = makeStubCtx({
751
+ status: fakeStatus([]),
752
+ sections: fakeSections([]),
753
+ settingsService: fakeSettingsService(retryRecord, {}),
754
+ toast: fakeToast(retryDeliveries, { dropFirst: 1 }),
755
+ })
756
+ await applyAndSettle(retryCtx)
757
+ writeFileSync(join(dataDir, 'theme-follow.json'), JSON.stringify({ light: false, at: 1 }))
758
+ writeFileSync(join(dataDir, 'theme.json'), JSON.stringify({ theme: 'pink-day' }, null, 2))
759
+ for (const w of retryRecord.watchers) w({ followSystem: true })
760
+ const retryDeadline = Date.now() + 2_000
761
+ while (retryDeliveries.length === 0 && Date.now() < retryDeadline) {
762
+ await new Promise(resolve => setTimeout(resolve, 5))
763
+ }
764
+ assert.equal(retryDeliveries.length, 1, 'a dropped toast is retried and delivered')
765
+ assert.match(retryDeliveries[0][0], /pink-night/)
766
+
767
+ // 13e. Toast-seam-less host (dsh-TUI < 0.10): sends are silent no-ops and
768
+ // the follow feature itself is unchanged. The retry chain stays bounded:
769
+ // once it gives up, a seam arriving later must not resurrect the abandoned
770
+ // toast.
771
+ const legacyRecord = { registerCalls: [], watchers: [] }
772
+ const legacyContext = makeStubCtx({
773
+ status: fakeStatus([]),
774
+ sections: fakeSections([]),
775
+ settingsService: fakeSettingsService(legacyRecord, {}),
776
+ deferToast: true,
777
+ })
778
+ await applyAndSettle(legacyContext.ctx)
779
+ writeFileSync(join(dataDir, 'theme-follow.json'), JSON.stringify({ light: true, at: 1 }))
780
+ writeFileSync(join(dataDir, 'theme.json'), JSON.stringify({ theme: 'dark' }, null, 2))
781
+ for (const w of legacyRecord.watchers) w({ followSystem: true })
782
+ assert.equal(readThemePref(dataDir), 'pink-day', 'follow works unchanged without the toast seam')
783
+ await new Promise(resolve => setTimeout(resolve, 50)) // the [5, 5] chain is long exhausted
784
+ const lateDeliveries = []
785
+ legacyContext.record.activateToast(fakeToast(lateDeliveries))
786
+ await settle()
787
+ assert.equal(lateDeliveries.length, 0, 'an abandoned toast is not resurrected by a late seam')
788
+
789
+ // 13f. Shadow hint: legacy same-named files that are byte-identical to the
790
+ // bundled copy are pointed out once on runtime confirmation; user edits
791
+ // stay unmentioned. Files this activation installed itself are excluded
792
+ // (they were already removed before the check).
793
+ rmSync(sandboxThemes, { recursive: true, force: true })
794
+ mkdirSync(sandboxThemes, { recursive: true })
795
+ for (const theme of ['pink-night', 'pink-day', 'pink-ansi']) {
796
+ writeFileSync(join(sandboxThemes, `${theme}.json`), readFileSync(join(pluginRoot, 'themes', `${theme}.json`), 'utf8'))
797
+ }
798
+ assert.deepEqual(findShadowedBundledThemes().sort(), ['pink-ansi.json', 'pink-day.json', 'pink-night.json'])
799
+ const shadowDeliveries = []
800
+ const shadowContext = makeStubCtx({ deferThemes: true, toast: fakeToast(shadowDeliveries) })
801
+ apply(shadowContext.ctx)
802
+ shadowContext.record.activateThemes(fakeThemes(shadowContext.record))
803
+ await settle()
804
+ assert.equal(shadowDeliveries.length, 1, 'identical legacy files are pointed out once')
805
+ assert.equal(shadowDeliveries[0][1], undefined, 'the shadow hint is neutral, not an error')
806
+ assert.match(shadowDeliveries[0][0], /pink-day\.json/)
807
+
808
+ rmSync(sandboxThemes, { recursive: true, force: true })
809
+ installBundledThemes()
810
+ writeFileSync(join(sandboxThemes, 'pink-night.json'), '{ "name": "pink-night", "colors": { "text": "#123456" } }')
811
+ const editDeliveries = []
812
+ const editContext = makeStubCtx({ deferThemes: true, toast: fakeToast(editDeliveries) })
813
+ apply(editContext.ctx)
814
+ editContext.record.activateThemes(fakeThemes(editContext.record))
815
+ await settle()
816
+ assert.equal(editDeliveries.length, 1)
817
+ assert.equal(/pink-night\.json/.test(editDeliveries[0][0]), false, 'a user-edited file is never nagged')
818
+ assert.equal(/pink-day\.json/.test(editDeliveries[0][0]), true)
819
+
820
+ // 13g. Corrupt-file self-heal is surfaced as a warning toast. The toast
821
+ // seam trails apply on a real host, so the send waits on the retry chain.
822
+ rmSync(sandboxThemes, { recursive: true, force: true })
823
+ mkdirSync(sandboxThemes, { recursive: true })
824
+ writeFileSync(join(sandboxThemes, 'pink-day.json'), '{ broken')
825
+ const healDeliveries = []
826
+ const healContext = makeStubCtx({ deferThemes: true, deferToast: true })
827
+ apply(healContext.ctx)
828
+ healContext.record.activateToast(fakeToast(healDeliveries))
829
+ const healDeadline = Date.now() + 2_000
830
+ while (healDeliveries.length === 0 && Date.now() < healDeadline) {
831
+ await new Promise(resolve => setTimeout(resolve, 5))
832
+ }
833
+ assert.equal(healDeliveries.length, 1, 'a repaired theme file is surfaced')
834
+ assert.equal(healDeliveries[0][1], 'warning')
835
+ assert.match(healDeliveries[0][0], /pink-day\.json/)
836
+
837
+ rmSync(sandboxThemes, { recursive: true, force: true })
838
+ setToastRetryDelaysForTests([2_000, 4_000])
839
+ console.log('✓ toast feedback: follow/self-heal/shadow hints delivered, dropped and seam-less sends retried, legacy hosts silent and bounded')
840
+ }
841
+
842
+ // ── 14. hot path: the token firehose must not reach render or the disk ──────
843
+ {
844
+ const themePrefPath = join(sandboxHome, '.dsh-tui', 'theme.json')
845
+ writeFileSync(themePrefPath, JSON.stringify({ theme: 'pink-night' }, null, 2))
846
+ // Earlier scenarios ran status activations against a different pref; drop
847
+ // their cached answer so this scenario starts from the fresh sandbox state.
848
+ invalidateThemePrefCacheForTests()
849
+ const statusCalls = []
850
+ const settingsRecord = { registerCalls: [], watchers: [] }
851
+ const { ctx, record } = makeStubCtx({
852
+ status: fakeStatus(statusCalls),
853
+ settingsService: fakeSettingsService(settingsRecord, {}),
854
+ })
855
+ await applyAndSettle(ctx)
856
+ const session = { id: 'h1' }
857
+ emit(record, 'session/event', session, { type: 'turn/end' })
858
+ const baselineCalls = statusCalls.length
859
+ assert.match(statusCalls.at(-1)[1], /1✦$/)
860
+
861
+ // A firehose burst (streaming chunks, tool traffic, step brackets) updates
862
+ // the tracked session but must render nothing and never touch the disk.
863
+ const originalRead = builtinFs.readFileSync
864
+ let prefReads = 0
865
+ try {
866
+ builtinFs.readFileSync = (path, ...rest) => {
867
+ if (String(path) === themePrefPath) prefReads += 1
868
+ return originalRead(path, ...rest)
869
+ }
870
+ syncBuiltinESMExports()
871
+ for (let index = 0; index < 50; index += 1) {
872
+ emit(record, 'session/event', session, {
873
+ type: 'assistant/chunk',
874
+ turn: 1,
875
+ step: 1,
876
+ chunk: { type: 'text', text: 'x' },
877
+ })
878
+ }
879
+ emit(record, 'session/event', session, {
880
+ type: 'tool/call', turn: 1, step: 1, callId: 'c1', name: 'tool', arguments: '{}',
881
+ })
882
+ emit(record, 'session/event', session, { type: 'step/end', turn: 1, step: 1 })
883
+ emit(record, 'session/event', session, { type: 'todo/write', todos: [] })
884
+ assert.equal(statusCalls.length, baselineCalls, 'firehose events render nothing')
885
+ assert.equal(prefReads, 0, 'firehose events never read the theme pref')
886
+
887
+ // A turn boundary renders — served by the warm pref cache (no disk hit
888
+ // within the TTL), so even boundary renders stay off the filesystem.
889
+ emit(record, 'session/event', session, { type: 'turn/end' })
890
+ assert.equal(statusCalls.length, baselineCalls + 1, 'a turn boundary renders')
891
+ assert.equal(prefReads, 0, 'the warm cache serves the boundary render')
892
+
893
+ // A session switch repaints at its first turn/start with a fresh count.
894
+ const nextSession = { id: 'h2' }
895
+ emit(record, 'session/event', nextSession, { type: 'turn/start' })
896
+ assert.equal(statusCalls.length, baselineCalls + 2)
897
+ assert.match(statusCalls.at(-1)[1], /0✦$/)
898
+ assert.equal(prefReads, 0)
899
+
900
+ // Inside the TTL a pref rewrite is not yet visible and costs no read…
901
+ writeFileSync(themePrefPath, JSON.stringify({ theme: 'dark' }, null, 2))
902
+ emit(record, 'session/event', nextSession, { type: 'turn/end' })
903
+ assert.equal(statusCalls.length, baselineCalls + 3)
904
+ assert.match(statusCalls.at(-1)[1], /1✦$/, 'the cached pink pref keeps the line visible')
905
+ assert.equal(prefReads, 0, 'the TTL serves the stale-but-pink answer without I/O')
906
+
907
+ // …after invalidation (TTL expiry in production) the next render re-reads.
908
+ invalidateThemePrefCacheForTests()
909
+ emit(record, 'session/event', nextSession, { type: 'turn/end' })
910
+ assert.equal(prefReads, 1, 'invalidation re-reads the pref exactly once')
911
+ assert.equal(statusCalls.at(-1)[1], undefined, 'the non-pink pref hides the line')
912
+ } finally {
913
+ builtinFs.readFileSync = originalRead
914
+ syncBuiltinESMExports()
915
+ invalidateThemePrefCacheForTests()
916
+ }
917
+ console.log('✓ hot path: firehose events render nothing and never read the pref; boundaries use the cache')
918
+ }
919
+
544
920
  console.log('\nAll plugin verifications passed.')
545
921
  console.log(`(sandbox used: ${sandboxHome} — the real home was never touched)`)