dsh-tui-theme 0.6.1 → 0.7.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.
@@ -0,0 +1,39 @@
1
+ /**
2
+ * One-shot guided cleanup for shadowing legacy theme files (tuiDialogs).
3
+ *
4
+ * On a runtime-themes host, byte-identical leftovers in ~/.dsh-tui/themes/
5
+ * (written by pre-0.10 plugin versions or a manual install) permanently
6
+ * shadow the runtime registry, so palette updates never reach the user. The
7
+ * existing toast points them out once; this module additionally offers a
8
+ * host-managed confirm dialog to delete the shadowing copies right away.
9
+ *
10
+ * Safety posture, unchanged from the toast-only era:
11
+ * - only files byte-identical to the bundled copy are ever touched — the
12
+ * deletion goes through removeBundledThemes(), which re-checks every byte
13
+ * at deletion time, so a file edited between detection and confirmation
14
+ * survives;
15
+ * - declining (Esc, the cancel label, the host's 30s auto-cancel when no UI
16
+ * is attached, or the seam never arriving on older hosts) keeps the exact
17
+ * previous behavior: the toast stands, nothing is deleted;
18
+ * - the offer happens at most once per activation, and the dialog request is
19
+ * owned by the activation's effect ledger, so a disposed activation cannot
20
+ * leave an orphaned panel behind.
21
+ *
22
+ * tuiDialogs is the host's own managed dialog chrome (the pi
23
+ * ctx.ui.confirm seam): the TUI owns the keyboard and renders the panel next
24
+ * to its approval UI — the plugin never touches input itself, which keeps
25
+ * the "no input interception" boundary intact.
26
+ * @module dsh-tui-theme/shadowCleanup
27
+ */
28
+ import type { Context } from '@deepseek-ai/cordis';
29
+ import type { ToastSend } from './toast.js';
30
+ /**
31
+ * Start the shadow-cleanup offer. Returns the offer function to call when
32
+ * shadowed files are detected (empty list = no-op). The tuiDialogs service
33
+ * is consumed through ctx.inject and may arrive before or after the offer —
34
+ * both orders converge on one dialog.
35
+ * @param ctx - The plugin's own activation context.
36
+ * @param sendToast - The toast relay sender for the cleanup result.
37
+ */
38
+ export declare function startShadowCleanup(ctx: Context, sendToast: ToastSend): (files: readonly string[]) => void;
39
+ //# sourceMappingURL=shadowCleanup.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"shadowCleanup.d.ts","sourceRoot":"","sources":["../../src/shadowCleanup.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAGH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AAElD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAA;AAQ3C;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,OAAO,EAAE,SAAS,EAAE,SAAS,GAAG,CAAC,KAAK,EAAE,SAAS,MAAM,EAAE,KAAK,IAAI,CAiFzG"}
@@ -0,0 +1,122 @@
1
+ /**
2
+ * One-shot guided cleanup for shadowing legacy theme files (tuiDialogs).
3
+ *
4
+ * On a runtime-themes host, byte-identical leftovers in ~/.dsh-tui/themes/
5
+ * (written by pre-0.10 plugin versions or a manual install) permanently
6
+ * shadow the runtime registry, so palette updates never reach the user. The
7
+ * existing toast points them out once; this module additionally offers a
8
+ * host-managed confirm dialog to delete the shadowing copies right away.
9
+ *
10
+ * Safety posture, unchanged from the toast-only era:
11
+ * - only files byte-identical to the bundled copy are ever touched — the
12
+ * deletion goes through removeBundledThemes(), which re-checks every byte
13
+ * at deletion time, so a file edited between detection and confirmation
14
+ * survives;
15
+ * - declining (Esc, the cancel label, the host's 30s auto-cancel when no UI
16
+ * is attached, or the seam never arriving on older hosts) keeps the exact
17
+ * previous behavior: the toast stands, nothing is deleted;
18
+ * - the offer happens at most once per activation, and the dialog request is
19
+ * owned by the activation's effect ledger, so a disposed activation cannot
20
+ * leave an orphaned panel behind.
21
+ *
22
+ * tuiDialogs is the host's own managed dialog chrome (the pi
23
+ * ctx.ui.confirm seam): the TUI owns the keyboard and renders the panel next
24
+ * to its approval UI — the plugin never touches input itself, which keeps
25
+ * the "no input interception" boundary intact.
26
+ * @module dsh-tui-theme/shadowCleanup
27
+ */
28
+ import { AsyncResource } from 'node:async_hooks';
29
+ import { removeBundledThemes } from './themeAssets.js';
30
+ import { PLUGIN_ID } from './pluginId.js';
31
+ /**
32
+ * Start the shadow-cleanup offer. Returns the offer function to call when
33
+ * shadowed files are detected (empty list = no-op). The tuiDialogs service
34
+ * is consumed through ctx.inject and may arrive before or after the offer —
35
+ * both orders converge on one dialog.
36
+ * @param ctx - The plugin's own activation context.
37
+ * @param sendToast - The toast relay sender for the cleanup result.
38
+ */
39
+ export function startShadowCleanup(ctx, sendToast) {
40
+ let dialogs;
41
+ let owner;
42
+ // The traceable service resolves its caller through the ambient Cordis
43
+ // activation (the same rule the toast relay works around): capture the
44
+ // inject's async scope and re-enter it for every call, including offers
45
+ // that arrive from another inject of this activation.
46
+ let scope;
47
+ let pending;
48
+ let offered = false;
49
+ let disposed = false;
50
+ const ask = (service, ownerCtx, files) => {
51
+ const names = files.join('、');
52
+ const request = {
53
+ title: '清理旧主题文件',
54
+ message: `检测到 ${files.length} 个与插件内置完全相同的旧主题文件(${names}),它们会遮蔽运行时主题,使配色无法随插件更新。要删除它们吗?`,
55
+ confirmLabel: '删除',
56
+ cancelLabel: '保留',
57
+ };
58
+ const call = () => {
59
+ if (scope === undefined)
60
+ return service.confirm(ownerCtx, request);
61
+ return scope.runInAsyncScope(() => service.confirm(ownerCtx, request));
62
+ };
63
+ // The real host resolves malformed requests with false rather than
64
+ // rejecting or throwing; a synchronous throw still means a hostile
65
+ // service, and a garnish must never propagate that upward.
66
+ try {
67
+ void call()
68
+ .then(confirmed => {
69
+ // A stale activation must not act on a late answer.
70
+ if (disposed || confirmed !== true)
71
+ return;
72
+ const removed = removeBundledThemes([...files]);
73
+ if (removed.length === 0) {
74
+ // Every candidate was edited (or vanished) between detection and
75
+ // confirmation — the byte check protected them, and the earlier
76
+ // toast already describes the situation.
77
+ ctx.logger.info(`${PLUGIN_ID}: shadow cleanup confirmed, but no file was still byte-identical; nothing removed`);
78
+ return;
79
+ }
80
+ ctx.logger.info(`${PLUGIN_ID}: removed ${removed.length} shadowing legacy file(s) after user confirmation`);
81
+ sendToast(`✿ 已清理 ${removed.length} 个旧主题文件,配色将随插件自动更新`, 'success');
82
+ })
83
+ .catch(error => {
84
+ ctx.logger.warn(`${PLUGIN_ID}: shadow cleanup dialog failed: ${String(error)}`);
85
+ });
86
+ }
87
+ catch (error) {
88
+ ctx.logger.warn(`${PLUGIN_ID}: shadow cleanup dialog failed: ${String(error)}`);
89
+ }
90
+ };
91
+ ctx.inject(['tuiDialogs'], dialogsCtx => {
92
+ if (disposed)
93
+ return;
94
+ dialogs = dialogsCtx.tuiDialogs;
95
+ owner = dialogsCtx;
96
+ scope = new AsyncResource('dsh-tui-theme-shadow-dialog');
97
+ dialogsCtx.effect(() => () => {
98
+ dialogs = undefined;
99
+ owner = undefined;
100
+ scope = undefined;
101
+ });
102
+ const files = pending;
103
+ if (files !== undefined) {
104
+ pending = undefined;
105
+ ask(dialogs, dialogsCtx, files);
106
+ }
107
+ });
108
+ ctx.effect(() => () => {
109
+ disposed = true;
110
+ });
111
+ return files => {
112
+ if (offered || disposed || files.length === 0)
113
+ return;
114
+ offered = true;
115
+ if (dialogs !== undefined && owner !== undefined) {
116
+ ask(dialogs, owner, files);
117
+ }
118
+ else {
119
+ pending = files;
120
+ }
121
+ };
122
+ }
@@ -6,18 +6,27 @@
6
6
  * ever appended to the session log). The host owns rendering and
7
7
  * sanitization; text is scalars only.
8
8
  *
9
+ * Two render paths, chosen once per activation (no hot switching):
10
+ * - dsh-TUI >= 0.10.1 (`registerView` present): a themed one-row rich view
11
+ * (see statusView.ts) whose colors come from the active pink palette;
12
+ * - older hosts: the historical scalar `set()` line, which the host renders
13
+ * uncolored + terminal dim.
14
+ *
9
15
  * The line belongs to the pink palettes: by default it only renders while a
10
16
  * pink theme is active (checked per render with the host's own theme
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.
17
+ * precedence, so a mid-session /theme switch takes effect at the very next
18
+ * render — the pref file's mtime is re-checked per render; the TTL below is
19
+ * only the fallback for filesystems where mtime granularity can hide a
20
+ * rewrite); `statusScope: 'all-themes'` opts it into every other theme too
21
+ * (uncolored there — non-pink palettes are not readable from a plugin).
14
22
  *
15
23
  * 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.
24
+ * et al.), but the rendered content only changes at turn boundaries and on
25
+ * the clock, so pushes/renders run on turn/start, turn/end, session/disposed,
26
+ * and the 15s tick — never per streamed chunk. The persisted-pref read behind
27
+ * the theme check and the palette read behind the colors are each cached for
28
+ * the same tick length so a render is pure string building plus one cheap
29
+ * stat of the pref file.
21
30
  * @module dsh-tui-theme/statusLine
22
31
  */
23
32
  import type { Context } from '@deepseek-ai/cordis';
@@ -26,7 +35,11 @@ export type StatusScope = 'pink-only' | 'all-themes';
26
35
  export interface StatusOptions {
27
36
  /** Master switch (cordis-config layer only; not surfaced in /settings). */
28
37
  statusEnabled?: boolean;
29
- /** Lead the line with the ✿ glyph. */
38
+ /** The character leading the line (default ✿; 1–2 display cells). */
39
+ statusGlyph?: string;
40
+ /** The character between the cells (default ·; 1–2 display cells). */
41
+ statusSeparator?: string;
42
+ /** Lead the line with the blossom glyph. */
30
43
  showGlyph?: boolean;
31
44
  /** Include the HH:MM clock. */
32
45
  showClock?: boolean;
@@ -38,8 +51,11 @@ export interface StatusOptions {
38
51
  /** Fully-resolved status knobs. */
39
52
  export type EffectiveStatus = Required<StatusOptions>;
40
53
  /**
41
- * @internal Drop the persisted-pref cache (verify.mjs only; not part of the
42
- * plugin's behavioral contract). Production invalidation is the TTL.
54
+ * @internal Drop the persisted-pref and palette caches (verify.mjs only; not
55
+ * part of the plugin's behavioral contract). Production invalidation is the
56
+ * mtime gate, with the TTL as the fallback. Both caches share the reset so
57
+ * test scenarios cannot couple through the 15s palette TTL the way they
58
+ * could through the pref one.
43
59
  */
44
60
  export declare function invalidateThemePrefCacheForTests(): void;
45
61
  /**
@@ -1 +1 @@
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"}
1
+ {"version":3,"file":"statusLine.d.ts","sourceRoot":"","sources":["../../src/statusLine.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAIH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AAkBlD,mDAAmD;AACnD,MAAM,MAAM,WAAW,GAAG,WAAW,GAAG,YAAY,CAAA;AAEpD,MAAM,WAAW,aAAa;IAC5B,2EAA2E;IAC3E,aAAa,CAAC,EAAE,OAAO,CAAA;IACvB,qEAAqE;IACrE,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,sEAAsE;IACtE,eAAe,CAAC,EAAE,MAAM,CAAA;IACxB,4CAA4C;IAC5C,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;AAsCrD;;;;;;GAMG;AACH,wBAAgB,gCAAgC,IAAI,IAAI,CAGvD;AAuGD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,OAAO,EAAE,YAAY,EAAE,MAAM,eAAe,GAAG,IAAI,CAyIvF"}
@@ -6,25 +6,38 @@
6
6
  * ever appended to the session log). The host owns rendering and
7
7
  * sanitization; text is scalars only.
8
8
  *
9
+ * Two render paths, chosen once per activation (no hot switching):
10
+ * - dsh-TUI >= 0.10.1 (`registerView` present): a themed one-row rich view
11
+ * (see statusView.ts) whose colors come from the active pink palette;
12
+ * - older hosts: the historical scalar `set()` line, which the host renders
13
+ * uncolored + terminal dim.
14
+ *
9
15
  * The line belongs to the pink palettes: by default it only renders while a
10
16
  * pink theme is active (checked per render with the host's own theme
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.
17
+ * precedence, so a mid-session /theme switch takes effect at the very next
18
+ * render — the pref file's mtime is re-checked per render; the TTL below is
19
+ * only the fallback for filesystems where mtime granularity can hide a
20
+ * rewrite); `statusScope: 'all-themes'` opts it into every other theme too
21
+ * (uncolored there — non-pink palettes are not readable from a plugin).
14
22
  *
15
23
  * 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.
24
+ * et al.), but the rendered content only changes at turn boundaries and on
25
+ * the clock, so pushes/renders run on turn/start, turn/end, session/disposed,
26
+ * and the 15s tick — never per streamed chunk. The persisted-pref read behind
27
+ * the theme check and the palette read behind the colors are each cached for
28
+ * the same tick length so a render is pure string building plus one cheap
29
+ * stat of the pref file.
21
30
  * @module dsh-tui-theme/statusLine
22
31
  */
32
+ import { readFileSync, statSync } from 'node:fs';
23
33
  import { join } from 'node:path';
24
- import { homeDir } from './themeAssets.js';
34
+ import { bundledThemesDir, homeDir, themesTargetDir } from './themeAssets.js';
25
35
  import { readThemePref } from './autoTheme.js';
36
+ import { sanitizeOrnament } from './ornament.js';
37
+ import { createStatusStore, createStatusViewComponent, statusViewDescriptor, NO_COLORS, } from './statusView.js';
26
38
  import { PLUGIN_ID } from './pluginId.js';
27
39
  const GLYPH = '✿';
40
+ const SEPARATOR = '·';
28
41
  // The tuiStatus contribution key (same value as the settings namespace and
29
42
  // the cordis plugin name — one literal would be three drift risks).
30
43
  const STATUS_KEY = PLUGIN_ID;
@@ -39,20 +52,41 @@ const THEME_PREF_TTL_MS = CLOCK_TICK_MS;
39
52
  const PINK_THEMES = new Set(['pink-night', 'pink-day', 'pink-ansi']);
40
53
  // `value: undefined` is a cached "no pref file / unparsable" answer, so the
41
54
  // common non-pink host (no theme.json at all) is one memory read per TTL
42
- // window instead of a failed syscall per render.
55
+ // window instead of a failed syscall per render. `mtimeMs` is the change
56
+ // signal the other way: the host rewrites theme.json alongside its
57
+ // in-memory /theme switch, and the next render stats the file, so the
58
+ // switch lands at that render instead of waiting out the TTL — the stale
59
+ // window was invisible on the scalar path but shows as wrong colors on the
60
+ // rich one. (mtime granularity on exotic filesystems can hide a rewrite;
61
+ // the TTL bounds that miss.)
43
62
  let prefCache;
63
+ /** -1 when the pref does not exist (a stable answer for the cache key). */
64
+ function prefMtimeMs(dataDir) {
65
+ try {
66
+ return statSync(join(dataDir, 'theme.json')).mtimeMs;
67
+ }
68
+ catch {
69
+ return -1;
70
+ }
71
+ }
44
72
  /**
45
- * @internal Drop the persisted-pref cache (verify.mjs only; not part of the
46
- * plugin's behavioral contract). Production invalidation is the TTL.
73
+ * @internal Drop the persisted-pref and palette caches (verify.mjs only; not
74
+ * part of the plugin's behavioral contract). Production invalidation is the
75
+ * mtime gate, with the TTL as the fallback. Both caches share the reset so
76
+ * test scenarios cannot couple through the 15s palette TTL the way they
77
+ * could through the pref one.
47
78
  */
48
79
  export function invalidateThemePrefCacheForTests() {
49
80
  prefCache = undefined;
81
+ colorsCache = undefined;
50
82
  }
51
83
  /**
52
84
  * The active theme name by the host's own precedence: DSH_TUI_THEME first,
53
85
  * 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.
86
+ * cache whose freshness is re-checked per render via the file's mtime —
87
+ * one stat, no read unless it moved). The unforced path (OSC 11
88
+ * auto-detection) only ever resolves to a builtin palette, never a pink
89
+ * one, so "no pref" means non-pink.
56
90
  *
57
91
  * This deliberately mirrors the host's ThemeProvider resolution chain
58
92
  * (`components/design-system/ThemeProvider.tsx`, baseline dsh-TUI 0.9.3);
@@ -64,15 +98,67 @@ function activeThemeName(dataDir) {
64
98
  if (env !== undefined && env !== '')
65
99
  return env;
66
100
  const now = Date.now();
67
- if (prefCache === undefined || now - prefCache.at >= THEME_PREF_TTL_MS) {
68
- prefCache = { at: now, value: readThemePref(dataDir) };
101
+ const mtimeMs = prefMtimeMs(dataDir);
102
+ if (prefCache === undefined ||
103
+ mtimeMs !== prefCache.mtimeMs ||
104
+ now - prefCache.at >= THEME_PREF_TTL_MS) {
105
+ prefCache = { at: now, mtimeMs, value: readThemePref(dataDir) };
69
106
  }
70
107
  return prefCache.value;
71
108
  }
72
- function isPinkThemeActive(dataDir) {
73
- const name = activeThemeName(dataDir);
109
+ function isPinkTheme(name) {
74
110
  return name !== undefined && PINK_THEMES.has(name);
75
111
  }
112
+ // ── palette colors ──────────────────────────────────────────────────────────
113
+ // Read from the effective pink palette with the same one-tick cache as the
114
+ // pref: a legacy ~/.dsh-tui/themes/<name>.json first (it shadows the runtime
115
+ // registry on old hosts), then this package's bundled copy. Non-pink themes
116
+ // render uncolored.
117
+ let colorsCache;
118
+ function paletteCell(colors, keys) {
119
+ for (const key of keys) {
120
+ const value = colors[key];
121
+ if (typeof value === 'string' && value !== '')
122
+ return value;
123
+ }
124
+ return undefined;
125
+ }
126
+ function readPalette(name) {
127
+ // `accent` is the 0.10.1 canonical brand key; `claude` is the 0.9.x name
128
+ // the bundled JSONs still carry (the host aliases it at admission, but this
129
+ // read bypasses the host entirely).
130
+ for (const dir of [themesTargetDir(), bundledThemesDir()]) {
131
+ try {
132
+ const parsed = JSON.parse(readFileSync(join(dir, `${name}.json`), 'utf8'));
133
+ if (parsed.colors === null || typeof parsed.colors !== 'object')
134
+ continue;
135
+ return {
136
+ glyph: paletteCell(parsed.colors, ['accent', 'claude']),
137
+ // The clock/turns share the host's bottom-bar tier: the model name and
138
+ // its neighbours render with `inactiveShimmer` (host StatusLine.tsx),
139
+ // and the garnish reads consistent when it matches (user-tested — the
140
+ // body-text key was too heavy, the inactive tier still felt off).
141
+ // inactive then body text are the fallbacks for palettes that lack
142
+ // the shimmer key.
143
+ text: paletteCell(parsed.colors, ['inactiveShimmer', 'inactive', 'text']),
144
+ separator: paletteCell(parsed.colors, ['subtle', 'inactive']),
145
+ };
146
+ }
147
+ catch {
148
+ // Missing or unreadable source: try the next one.
149
+ }
150
+ }
151
+ return NO_COLORS;
152
+ }
153
+ function paletteFor(themeName) {
154
+ if (!isPinkTheme(themeName))
155
+ return NO_COLORS;
156
+ const now = Date.now();
157
+ if (colorsCache === undefined || colorsCache.name !== themeName || now - colorsCache.at >= THEME_PREF_TTL_MS) {
158
+ colorsCache = { at: now, name: themeName, colors: readPalette(themeName) };
159
+ }
160
+ return colorsCache.colors;
161
+ }
76
162
  function clockText() {
77
163
  // "HH:MM" from toTimeString()'s "HH:MM:SS GMT…" prefix — locale independent.
78
164
  return new Date().toTimeString().slice(0, 5);
@@ -97,39 +183,84 @@ export function startStatusLine(ctx, getEffective) {
97
183
  const status = statusCtx.tuiStatus;
98
184
  const turns = new Map();
99
185
  let current;
100
- let dispose;
186
+ let legacyDispose;
187
+ let viewDispose;
188
+ let store;
189
+ // The scalar path's render. Both paths compute the same cells; they only
190
+ // differ in how the result reaches the host.
191
+ const renderScalar = () => {
192
+ const eff = getEffective();
193
+ const themeName = activeThemeName(dataDir);
194
+ const cells = statusCells(eff, themeName);
195
+ const separator = sanitizeOrnament(eff.statusSeparator, SEPARATOR);
196
+ const parts = [cells.glyph, cells.clock, cells.turns].filter((value) => value !== undefined);
197
+ const text = parts.join(` ${separator} `);
198
+ if (text === lastText)
199
+ return;
200
+ lastText = text;
201
+ // The trailing identity must be the inject-scoped context (the same
202
+ // activation the traceable binds as caller) — the plugin's outer ctx
203
+ // is a different activation view and would be silently rejected.
204
+ legacyDispose = status.set(STATUS_KEY, text === '' ? undefined : text, statusCtx) ?? legacyDispose;
205
+ };
101
206
  let lastText;
102
207
  const render = () => {
103
208
  try {
104
- const eff = getEffective();
105
- const parts = [];
106
- // The line is pink garnish: off on other themes unless opted in.
107
- // The theme check runs here (not once at startup) so a mid-session
108
- // /theme switch lands within one pref-cache TTL — the next tick or
109
- // turn boundary.
110
- const themeAllows = eff.statusScope === 'all-themes' || isPinkThemeActive(dataDir);
111
- if (eff.statusEnabled && themeAllows) {
112
- if (eff.showGlyph)
113
- parts.push(GLYPH);
114
- if (eff.showClock)
115
- parts.push(clockText());
116
- if (eff.showTurns && current !== undefined) {
117
- parts.push(`${turns.get(current) ?? 0}✦`);
118
- }
119
- }
120
- const text = parts.join(' · ');
121
- if (text === lastText)
209
+ if (store !== undefined) {
210
+ const eff = getEffective();
211
+ // One pref read per render, shared by the visibility check and the
212
+ // palette: the module header's "one cheap stat" invariant holds on
213
+ // both paths (statusCells and paletteFor each used to re-resolve).
214
+ const themeName = activeThemeName(dataDir);
215
+ const cells = statusCells(eff, themeName);
216
+ store.push({
217
+ visible: cells.glyph !== undefined || cells.clock !== undefined || cells.turns !== undefined,
218
+ glyph: cells.glyph,
219
+ clock: cells.clock,
220
+ turns: cells.turns,
221
+ separator: sanitizeOrnament(eff.statusSeparator, SEPARATOR),
222
+ colors: paletteFor(themeName),
223
+ });
122
224
  return;
123
- lastText = text;
124
- // The trailing identity must be the inject-scoped context (the same
125
- // activation the traceable binds as caller) — the plugin's outer ctx
126
- // is a different activation view and would be silently rejected.
127
- dispose = status.set(STATUS_KEY, text === '' ? undefined : text, statusCtx) ?? dispose;
225
+ }
226
+ renderScalar();
128
227
  }
129
228
  catch {
130
229
  // Display garnish only: a rendering hiccup must never travel upward.
131
230
  }
132
231
  };
232
+ /** The three optional cells (all undefined = the line is off: master
233
+ * switch, theme scope, and the toggles fold into this one shape).
234
+ * Takes the theme name resolved once by the caller per render. */
235
+ function statusCells(eff, themeName) {
236
+ const enabled = eff.statusEnabled && (eff.statusScope === 'all-themes' || isPinkTheme(themeName));
237
+ if (!enabled)
238
+ return { glyph: undefined, clock: undefined, turns: undefined };
239
+ return {
240
+ glyph: eff.showGlyph ? sanitizeOrnament(eff.statusGlyph, GLYPH) : undefined,
241
+ clock: eff.showClock ? clockText() : undefined,
242
+ turns: eff.showTurns && current !== undefined ? `${turns.get(current) ?? 0}✦` : undefined,
243
+ };
244
+ }
245
+ // Rich path probe: a soft capability check, exactly like every other
246
+ // seam. registerView is fixed for the host's lifetime, so the choice is
247
+ // made once per activation; a refused registration (returned undefined)
248
+ // or a hostile one that throws falls back to set(). The throw defense is
249
+ // load-bearing: without it the error escapes the inject callback and
250
+ // takes the session/event wiring and the first render down with it.
251
+ if (typeof status.registerView === 'function') {
252
+ const viewStore = createStatusStore();
253
+ try {
254
+ const dispose = status.registerView(statusViewDescriptor(STATUS_KEY, createStatusViewComponent(viewStore)), statusCtx);
255
+ if (dispose !== undefined) {
256
+ store = viewStore;
257
+ viewDispose = dispose;
258
+ }
259
+ }
260
+ catch (error) {
261
+ statusCtx.logger.warn(`dsh-tui-theme: rich status view registration failed, staying on the scalar line: ${String(error)}`);
262
+ }
263
+ }
133
264
  statusCtx.on('session/event', (session, event) => {
134
265
  current = session;
135
266
  const type = event?.type;
@@ -155,11 +286,18 @@ export function startStatusLine(ctx, getEffective) {
155
286
  statusCtx.effect(() => () => {
156
287
  clearInterval(timer);
157
288
  try {
158
- dispose?.();
289
+ legacyDispose?.();
159
290
  }
160
291
  catch {
161
292
  // The host store is already gone on teardown — nothing to clear.
162
293
  }
294
+ try {
295
+ viewDispose?.();
296
+ }
297
+ catch {
298
+ // Same teardown race as above; the rich view is best-effort too.
299
+ }
300
+ store?.clear();
163
301
  });
164
302
  render();
165
303
  });
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Rich status view (dsh-TUI >= 0.10.1 seam: `tuiStatus.registerView`).
3
+ *
4
+ * A compact one-row host React component that renders the blossom line with
5
+ * colors taken from the active pink palette — the one thing the scalar
6
+ * `tuiStatus.set` path can never do (the host renders set() text uncolored +
7
+ * terminal dim).
8
+ *
9
+ * Data channel: the component subscribes to a tiny plugin-side external
10
+ * store via the host React's useSyncExternalStore, exactly the "owns its
11
+ * live data via an external store" pattern the host seam documents. The
12
+ * plugin pushes scalar-only snapshots (turn boundaries, the clock tick,
13
+ * settings edits); the component maps one snapshot to themed Text cells and
14
+ * never touches the filesystem, timers, or events itself — it runs on the
15
+ * host's render thread and must stay a pure function of its snapshot.
16
+ *
17
+ * Host kit rules honored here: the component is created with the host React
18
+ * instance handed in via props (single-React rule — the plugin never imports
19
+ * react), and `Box`/`Text` receive only layout/color props (no focus,
20
+ * keyboard, wheel, or ref props; no pointer handlers either — the line is
21
+ * pure display).
22
+ * @module dsh-tui-theme/statusView
23
+ */
24
+ import type { TuiStatusViewDescriptor } from '@deepseek-harness-tui/dsh-tui/extensions';
25
+ /** Raw color values for one render, straight from the active palette (or
26
+ * all undefined under non-pink themes — the terminal default then applies). */
27
+ export interface StatusColors {
28
+ readonly glyph: string | undefined;
29
+ readonly text: string | undefined;
30
+ readonly separator: string | undefined;
31
+ }
32
+ export declare const NO_COLORS: StatusColors;
33
+ /** Everything one render needs, as scalars only. Frozen by the store. */
34
+ export interface StatusSnapshot {
35
+ readonly visible: boolean;
36
+ readonly glyph: string | undefined;
37
+ readonly clock: string | undefined;
38
+ /** The turn-count cell, e.g. "3✦" (undefined while no session is live). */
39
+ readonly turns: string | undefined;
40
+ readonly separator: string;
41
+ readonly colors: StatusColors;
42
+ }
43
+ /**
44
+ * The plugin-side external store the rich view subscribes to. Pushes are
45
+ * deduplicated: a snapshot equal to the current one notifies nobody, so a
46
+ * clock tick that changed nothing costs no React render.
47
+ */
48
+ export interface StatusStore {
49
+ getSnapshot(): StatusSnapshot;
50
+ subscribe(listener: () => void): () => void;
51
+ push(next: StatusSnapshot): void;
52
+ /** Drop all listeners (activation teardown). */
53
+ clear(): void;
54
+ }
55
+ export declare function createStatusStore(): StatusStore;
56
+ /** Structural views of the host kit; the real types live in the host. */
57
+ interface StatusViewReact {
58
+ createElement(type: unknown, props: Record<string, unknown> | null, ...children: unknown[]): unknown;
59
+ useSyncExternalStore(subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => StatusSnapshot): StatusSnapshot;
60
+ }
61
+ interface StatusViewUi {
62
+ readonly Box: unknown;
63
+ readonly Text: unknown;
64
+ }
65
+ export interface StatusViewProps {
66
+ readonly React: StatusViewReact;
67
+ readonly ui: StatusViewUi;
68
+ }
69
+ /**
70
+ * Build the rich view component bound to one store. The returned function is
71
+ * a host React component: it receives `{ React, ui }` from the host on every
72
+ * render and maps the current snapshot to a one-row themed Box.
73
+ */
74
+ export declare function createStatusViewComponent(store: StatusStore): (props: StatusViewProps) => unknown;
75
+ /**
76
+ * The registration descriptor for the rich view. The caller passes the same
77
+ * contribution key the scalar path uses, so the effect-ledger resource id
78
+ * and the headless order-test pin remain stable across both paths.
79
+ */
80
+ export declare function statusViewDescriptor(key: string, component: TuiStatusViewDescriptor['component']): TuiStatusViewDescriptor;
81
+ export {};
82
+ //# sourceMappingURL=statusView.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"statusView.d.ts","sourceRoot":"","sources":["../../src/statusView.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,0CAA0C,CAAA;AAEvF;gFACgF;AAChF,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,CAAA;IAClC,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,CAAA;IACjC,QAAQ,CAAC,SAAS,EAAE,MAAM,GAAG,SAAS,CAAA;CACvC;AAED,eAAO,MAAM,SAAS,EAAE,YAItB,CAAA;AAEF,yEAAyE;AACzE,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAA;IACzB,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,CAAA;IAClC,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,CAAA;IAClC,2EAA2E;IAC3E,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,CAAA;IAClC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B,QAAQ,CAAC,MAAM,EAAE,YAAY,CAAA;CAC9B;AAED;;;;GAIG;AACH,MAAM,WAAW,WAAW;IAC1B,WAAW,IAAI,cAAc,CAAA;IAC7B,SAAS,CAAC,QAAQ,EAAE,MAAM,IAAI,GAAG,MAAM,IAAI,CAAA;IAC3C,IAAI,CAAC,IAAI,EAAE,cAAc,GAAG,IAAI,CAAA;IAChC,gDAAgD;IAChD,KAAK,IAAI,IAAI,CAAA;CACd;AAeD,wBAAgB,iBAAiB,IAAI,WAAW,CA4C/C;AAED,yEAAyE;AACzE,UAAU,eAAe;IACvB,aAAa,CACX,IAAI,EAAE,OAAO,EACb,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,EACrC,GAAG,QAAQ,EAAE,OAAO,EAAE,GACrB,OAAO,CAAA;IACV,oBAAoB,CAClB,SAAS,EAAE,CAAC,aAAa,EAAE,MAAM,IAAI,KAAK,MAAM,IAAI,EACpD,WAAW,EAAE,MAAM,cAAc,GAChC,cAAc,CAAA;CAClB;AACD,UAAU,YAAY;IACpB,QAAQ,CAAC,GAAG,EAAE,OAAO,CAAA;IACrB,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAA;CACvB;AACD,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,KAAK,EAAE,eAAe,CAAA;IAC/B,QAAQ,CAAC,EAAE,EAAE,YAAY,CAAA;CAC1B;AAOD;;;;GAIG;AACH,wBAAgB,yBAAyB,CACvC,KAAK,EAAE,WAAW,GACjB,CAAC,KAAK,EAAE,eAAe,KAAK,OAAO,CAqCrC;AAED;;;;GAIG;AACH,wBAAgB,oBAAoB,CAClC,GAAG,EAAE,MAAM,EACX,SAAS,EAAE,uBAAuB,CAAC,WAAW,CAAC,GAC9C,uBAAuB,CAEzB"}