dsh-tui-theme 0.6.0 → 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 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAGH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AAMlD,OAAO,EAAwB,KAAK,eAAe,EAAE,MAAM,sBAAsB,CAAA;AAIjF,eAAO,MAAM,IAAI,kBAAY,CAAA;AAE7B,wDAAwD;AACxD,MAAM,MAAM,MAAM,GAAG,eAAe,CAAA;AAUpC,eAAO,MAAM,MAAM,EAAE,WAAW,CAAC,MAAM,CAQrC,CAAA;AAkBF;;;;GAIG;AACH,wBAAgB,KAAK,CAAC,GAAG,EAAE,OAAO,EAAE,MAAM,GAAE,MAAW,GAAG,IAAI,CA+I7D"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAGH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AAMlD,OAAO,EAAwB,KAAK,eAAe,EAAE,MAAM,sBAAsB,CAAA;AAIjF,eAAO,MAAM,IAAI,kBAAY,CAAA;AAE7B,wDAAwD;AACxD,MAAM,MAAM,MAAM,GAAG,eAAe,CAAA;AAUpC,eAAO,MAAM,MAAM,EAAE,WAAW,CAAC,MAAM,CAQrC,CAAA;AAkBF;;;;GAIG;AACH,wBAAgB,KAAK,CAAC,GAAG,EAAE,OAAO,EAAE,MAAM,GAAE,MAAW,GAAG,IAAI,CAwJ7D"}
@@ -101,8 +101,15 @@ export function apply(ctx, config = {}) {
101
101
  const firstConfirmation = !runtimeConfirmed;
102
102
  runtimeConfirmed = true;
103
103
  if (ownedStaticFiles.size > 0) {
104
- removeBundledThemes([...ownedStaticFiles]);
104
+ // The write-then-remove cycle: this boot installed static fallbacks
105
+ // because the runtime seam was absent at apply time, then the seam
106
+ // arrived. Logged so the cycle's real-world frequency (and any future
107
+ // deferred-install optimization) can be measured from plugin logs.
108
+ const removed = removeBundledThemes([...ownedStaticFiles]);
105
109
  ownedStaticFiles.clear();
110
+ if (removed.length > 0) {
111
+ ctx.logger.info(`${PLUGIN_ID}: runtime themes confirmed; removed ${removed.length} static fallback file(s) installed earlier this boot`);
112
+ }
106
113
  }
107
114
  // Same-named static files win over runtime registrations. User-edited
108
115
  // files stay unmentioned; files byte-identical to the bundled copy add
@@ -8,8 +8,16 @@
8
8
  *
9
9
  * The line belongs to the pink palettes: by default it only renders while a
10
10
  * pink theme is active (checked per render with the host's own theme
11
- * precedence, so a mid-session /theme switch takes effect on the next tick);
12
- * `statusScope: 'all-themes'` opts it into every other theme too.
11
+ * precedence, so a mid-session /theme switch takes effect within the pref
12
+ * cache TTL — at most one clock tick); `statusScope: 'all-themes'` opts it
13
+ * into every other theme too.
14
+ *
15
+ * Cost discipline: `session/event` is a token-level firehose (assistant/chunk
16
+ * et al.), but the rendered text only changes at turn boundaries and on the
17
+ * clock, so renders run on turn/start, turn/end, session/disposed, and the
18
+ * 15s tick — never per streamed chunk. The persisted-pref read behind the
19
+ * theme check is cached for the same tick length so a render is pure string
20
+ * building.
13
21
  * @module dsh-tui-theme/statusLine
14
22
  */
15
23
  import type { Context } from '@deepseek-ai/cordis';
@@ -29,6 +37,11 @@ export interface StatusOptions {
29
37
  }
30
38
  /** Fully-resolved status knobs. */
31
39
  export type EffectiveStatus = Required<StatusOptions>;
40
+ /**
41
+ * @internal Drop the persisted-pref cache (verify.mjs only; not part of the
42
+ * plugin's behavioral contract). Production invalidation is the TTL.
43
+ */
44
+ export declare function invalidateThemePrefCacheForTests(): void;
32
45
  /**
33
46
  * Start the status line inside the `tuiStatus` inject.
34
47
  *
@@ -1 +1 @@
1
- {"version":3,"file":"statusLine.d.ts","sourceRoot":"","sources":["../../src/statusLine.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAGH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AAQlD,mDAAmD;AACnD,MAAM,MAAM,WAAW,GAAG,WAAW,GAAG,YAAY,CAAA;AAEpD,MAAM,WAAW,aAAa;IAC5B,2EAA2E;IAC3E,aAAa,CAAC,EAAE,OAAO,CAAA;IACvB,sCAAsC;IACtC,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,+BAA+B;IAC/B,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,0DAA0D;IAC1D,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,iEAAiE;IACjE,WAAW,CAAC,EAAE,WAAW,CAAA;CAC1B;AAED,mCAAmC;AACnC,MAAM,MAAM,eAAe,GAAG,QAAQ,CAAC,aAAa,CAAC,CAAA;AA+CrD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,OAAO,EAAE,YAAY,EAAE,MAAM,eAAe,GAAG,IAAI,CA8DvF"}
1
+ {"version":3,"file":"statusLine.d.ts","sourceRoot":"","sources":["../../src/statusLine.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAGH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AAQlD,mDAAmD;AACnD,MAAM,MAAM,WAAW,GAAG,WAAW,GAAG,YAAY,CAAA;AAEpD,MAAM,WAAW,aAAa;IAC5B,2EAA2E;IAC3E,aAAa,CAAC,EAAE,OAAO,CAAA;IACvB,sCAAsC;IACtC,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,+BAA+B;IAC/B,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,0DAA0D;IAC1D,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,iEAAiE;IACjE,WAAW,CAAC,EAAE,WAAW,CAAA;CAC1B;AAED,mCAAmC;AACnC,MAAM,MAAM,eAAe,GAAG,QAAQ,CAAC,aAAa,CAAC,CAAA;AAsBrD;;;GAGG;AACH,wBAAgB,gCAAgC,IAAI,IAAI,CAEvD;AA0CD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,OAAO,EAAE,YAAY,EAAE,MAAM,eAAe,GAAG,IAAI,CAqEvF"}
@@ -8,8 +8,16 @@
8
8
  *
9
9
  * The line belongs to the pink palettes: by default it only renders while a
10
10
  * pink theme is active (checked per render with the host's own theme
11
- * precedence, so a mid-session /theme switch takes effect on the next tick);
12
- * `statusScope: 'all-themes'` opts it into every other theme too.
11
+ * precedence, so a mid-session /theme switch takes effect within the pref
12
+ * cache TTL — at most one clock tick); `statusScope: 'all-themes'` opts it
13
+ * into every other theme too.
14
+ *
15
+ * Cost discipline: `session/event` is a token-level firehose (assistant/chunk
16
+ * et al.), but the rendered text only changes at turn boundaries and on the
17
+ * clock, so renders run on turn/start, turn/end, session/disposed, and the
18
+ * 15s tick — never per streamed chunk. The persisted-pref read behind the
19
+ * theme check is cached for the same tick length so a render is pure string
20
+ * building.
13
21
  * @module dsh-tui-theme/statusLine
14
22
  */
15
23
  import { join } from 'node:path';
@@ -21,13 +29,30 @@ const GLYPH = '✿';
21
29
  // the cordis plugin name — one literal would be three drift risks).
22
30
  const STATUS_KEY = PLUGIN_ID;
23
31
  const CLOCK_TICK_MS = 15_000;
32
+ // The persisted-pref cache lives for one clock tick: a mid-session /theme
33
+ // switch (the host rewrites theme.json alongside its own in-memory switch)
34
+ // lands on the next turn boundary or tick, which is exactly the "next tick"
35
+ // semantic the render path always promised. Without the cache every render
36
+ // would pay a synchronous stat+read+JSON.parse on the host's UI thread.
37
+ const THEME_PREF_TTL_MS = CLOCK_TICK_MS;
24
38
  /** The bundled themes this garnish belongs to. */
25
39
  const PINK_THEMES = new Set(['pink-night', 'pink-day', 'pink-ansi']);
40
+ // `value: undefined` is a cached "no pref file / unparsable" answer, so the
41
+ // common non-pink host (no theme.json at all) is one memory read per TTL
42
+ // window instead of a failed syscall per render.
43
+ let prefCache;
44
+ /**
45
+ * @internal Drop the persisted-pref cache (verify.mjs only; not part of the
46
+ * plugin's behavioral contract). Production invalidation is the TTL.
47
+ */
48
+ export function invalidateThemePrefCacheForTests() {
49
+ prefCache = undefined;
50
+ }
26
51
  /**
27
52
  * The active theme name by the host's own precedence: DSH_TUI_THEME first,
28
- * then the persisted ~/.dsh-tui/theme.json pref. The unforced path (OSC 11
29
- * auto-detection) only ever resolves to a builtin palette, never a pink one,
30
- * so "no pref" means non-pink.
53
+ * then the persisted ~/.dsh-tui/theme.json pref (read through a one-tick
54
+ * cache). The unforced path (OSC 11 auto-detection) only ever resolves to a
55
+ * builtin palette, never a pink one, so "no pref" means non-pink.
31
56
  *
32
57
  * This deliberately mirrors the host's ThemeProvider resolution chain
33
58
  * (`components/design-system/ThemeProvider.tsx`, baseline dsh-TUI 0.9.3);
@@ -38,7 +63,11 @@ function activeThemeName(dataDir) {
38
63
  const env = process.env.DSH_TUI_THEME;
39
64
  if (env !== undefined && env !== '')
40
65
  return env;
41
- return readThemePref(dataDir);
66
+ const now = Date.now();
67
+ if (prefCache === undefined || now - prefCache.at >= THEME_PREF_TTL_MS) {
68
+ prefCache = { at: now, value: readThemePref(dataDir) };
69
+ }
70
+ return prefCache.value;
42
71
  }
43
72
  function isPinkThemeActive(dataDir) {
44
73
  const name = activeThemeName(dataDir);
@@ -76,7 +105,8 @@ export function startStatusLine(ctx, getEffective) {
76
105
  const parts = [];
77
106
  // The line is pink garnish: off on other themes unless opted in.
78
107
  // The theme check runs here (not once at startup) so a mid-session
79
- // /theme switch lands on the next tick or session event.
108
+ // /theme switch lands within one pref-cache TTL — the next tick or
109
+ // turn boundary.
80
110
  const themeAllows = eff.statusScope === 'all-themes' || isPinkThemeActive(dataDir);
81
111
  if (eff.statusEnabled && themeAllows) {
82
112
  if (eff.showGlyph)
@@ -102,10 +132,17 @@ export function startStatusLine(ctx, getEffective) {
102
132
  };
103
133
  statusCtx.on('session/event', (session, event) => {
104
134
  current = session;
105
- if (event?.type === 'turn/end') {
135
+ const type = event?.type;
136
+ if (type === 'turn/end') {
106
137
  turns.set(session, (turns.get(session) ?? 0) + 1);
107
138
  }
108
- render();
139
+ // The firehose filter: session/event carries every token-level chunk,
140
+ // tool call, and step bracket, but the rendered text only changes at
141
+ // turn boundaries (the count) or on the clock timer. turn/start is a
142
+ // session's first live event, so a session switch repaints immediately
143
+ // (the new session's count is 0 until its first turn ends).
144
+ if (type === 'turn/start' || type === 'turn/end')
145
+ render();
109
146
  });
110
147
  statusCtx.on('session/disposed', session => {
111
148
  turns.delete(session);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-tui-theme",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "Sakura-pink themes for dsh-TUI with optional cached background follow (pink-day/pink-night), a blossom status line, and a /settings section. No shortcuts, no commands.",
5
5
  "type": "module",
6
6
  "main": "lib/types/index.js",
@@ -45,7 +45,7 @@ const sandboxThemes = join(sandboxHome, '.dsh-tui', 'themes')
45
45
 
46
46
  const { name, apply } = await import('../lib/types/index.js')
47
47
  const { installBundledThemes, readBundledThemes, findShadowedBundledThemes } = await import('../lib/types/themeAssets.js')
48
- const { startStatusLine } = await import('../lib/types/statusLine.js')
48
+ const { startStatusLine, invalidateThemePrefCacheForTests } = await import('../lib/types/statusLine.js')
49
49
  const { setToastRetryDelaysForTests } = await import('../lib/types/toast.js')
50
50
  const {
51
51
  themeForBackground,
@@ -399,8 +399,10 @@ const emit = (record, event, ...args) => {
399
399
  emit(record, 'session/event', session, { type: 'turn/end' })
400
400
  assert.match(statusCalls.at(-1)[1], /^✿ · \d{2}:\d{2} · 1✦$/)
401
401
 
402
- // 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).
403
404
  writeFileSync(themePrefPath, JSON.stringify({ theme: 'dark' }, null, 2))
405
+ invalidateThemePrefCacheForTests()
404
406
  emit(record, 'session/event', session, { type: 'turn/end' })
405
407
  assert.equal(statusCalls.at(-1)[1], undefined)
406
408
 
@@ -837,5 +839,83 @@ const emit = (record, event, ...args) => {
837
839
  console.log('✓ toast feedback: follow/self-heal/shadow hints delivered, dropped and seam-less sends retried, legacy hosts silent and bounded')
838
840
  }
839
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
+
840
920
  console.log('\nAll plugin verifications passed.')
841
921
  console.log(`(sandbox used: ${sandboxHome} — the real home was never touched)`)