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.
- package/README.md +23 -13
- package/cordis.patch.yml +2 -0
- package/docs/decisions/2026-09-12-pink-day-claude-family-lightening.md +49 -0
- package/lib/types/autoTheme.d.ts +3 -0
- package/lib/types/autoTheme.d.ts.map +1 -1
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +19 -2
- package/lib/types/ornament.d.ts +38 -0
- package/lib/types/ornament.d.ts.map +1 -0
- package/lib/types/ornament.js +88 -0
- package/lib/types/settingsSection.d.ts +9 -4
- package/lib/types/settingsSection.d.ts.map +1 -1
- package/lib/types/settingsSection.js +88 -7
- package/lib/types/shadowCleanup.d.ts +39 -0
- package/lib/types/shadowCleanup.d.ts.map +1 -0
- package/lib/types/shadowCleanup.js +122 -0
- package/lib/types/statusLine.d.ts +27 -11
- package/lib/types/statusLine.d.ts.map +1 -1
- package/lib/types/statusLine.js +181 -43
- package/lib/types/statusView.d.ts +82 -0
- package/lib/types/statusView.d.ts.map +1 -0
- package/lib/types/statusView.js +126 -0
- package/lib/types/themeAssets.d.ts +4 -0
- package/lib/types/themeAssets.d.ts.map +1 -1
- package/lib/types/themeAssets.js +8 -3
- package/package.json +42 -42
- package/scripts/expected-settings-contract.mjs +21 -1
- package/scripts/headless-order-test.mjs +33 -10
- package/scripts/runtime-themes-headless.mjs +160 -23
- package/scripts/validate-themes-against-host.mjs +33 -6
- package/scripts/verify-package.mjs +16 -7
- package/scripts/verify.mjs +377 -9
- package/themes/pink-day.json +4 -4
- package/themes/pink-night.json +4 -4
|
@@ -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
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
|
17
|
-
* clock, so renders run on turn/start, turn/end, session/disposed,
|
|
18
|
-
* 15s tick — never per streamed chunk. The persisted-pref read behind
|
|
19
|
-
* theme check
|
|
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
|
-
/**
|
|
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
|
|
42
|
-
* plugin's behavioral contract). Production invalidation is the
|
|
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
|
|
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"}
|
package/lib/types/statusLine.js
CHANGED
|
@@ -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
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
|
17
|
-
* clock, so renders run on turn/start, turn/end, session/disposed,
|
|
18
|
-
* 15s tick — never per streamed chunk. The persisted-pref read behind
|
|
19
|
-
* theme check
|
|
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
|
|
46
|
-
* plugin's behavioral contract). Production invalidation is the
|
|
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
|
|
55
|
-
*
|
|
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
|
-
|
|
68
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
124
|
-
|
|
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
|
-
|
|
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"}
|