@kispace-io/extension-howto-system 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "@kispace-io/extension-howto-system",
3
+ "version": "0.8.0",
4
+ "type": "module",
5
+ "main": "./src/index.ts",
6
+ "exports": {
7
+ ".": {
8
+ "import": "./src/index.ts",
9
+ "types": "./src/index.ts"
10
+ }
11
+ },
12
+ "dependencies": {
13
+ "@kispace-io/core": "*",
14
+ "@kispace-io/extension-ai-system": "*"
15
+ },
16
+ "devDependencies": {
17
+ "typescript": "^5.9.3"
18
+ }
19
+ }
package/src/README.md ADDED
@@ -0,0 +1,249 @@
1
+ # HowTo System Extension
2
+
3
+ The HowTo System provides a framework for creating step-by-step workflows that guide users through specific processes. It features a floating, draggable UI panel that displays workflows with pre and post condition checks.
4
+
5
+ ## Features
6
+
7
+ - **Register HowToContributions**: Extensions can register workflows with multiple steps
8
+ - **Floating UI Panel**: Always-on-top, draggable panel that hosts the workflow steps
9
+ - **Sequential Step Execution**: Steps are executed one at a time in order
10
+ - **Pre and Post Conditions**: Each step can have conditions that check requirements and outcomes
11
+ - **Step Status Tracking**: Visual indicators show step status (pending, active, completed, failed, skipped)
12
+
13
+ ## Usage
14
+
15
+ ### Registering a HowTo Contribution
16
+
17
+ From any extension, you can register a HowTo contribution via the contribution registry:
18
+
19
+ ```typescript
20
+ import { contributionRegistry } from '../../core/contributionregistry';
21
+ import { HOWTO_CONTRIBUTION_TARGET } from '../../extensions/howto-system/howto-extension';
22
+ import type { HowToContribution } from '../../extensions/howto-system/howto-extension';
23
+
24
+ export default function myExtension({ contributionRegistry }: any) {
25
+ // Register a HowTo contribution
26
+ contributionRegistry.registerContribution<HowToContribution>(HOWTO_CONTRIBUTION_TARGET, {
27
+ id: 'my-extension.setup-workspace',
28
+ title: 'Setup Workspace',
29
+ description: 'Guide to set up your workspace for the first time',
30
+ icon: 'folder-open',
31
+ label: 'Setup Workspace', // Required by Contribution interface
32
+ steps: [
33
+ {
34
+ id: 'step-1',
35
+ title: 'Create Project Folder',
36
+ description: 'Create a new folder for your project',
37
+ preCondition: async () => {
38
+ // Check if workspace is selected
39
+ const workspace = await workspaceService.getWorkspace();
40
+ return workspace !== undefined;
41
+ },
42
+ postCondition: async () => {
43
+ // Check if project folder exists
44
+ const workspace = await workspaceService.getWorkspace();
45
+ if (!workspace) return false;
46
+ const projectFolder = await workspace.getChild('my-project');
47
+ return projectFolder !== undefined;
48
+ },
49
+ command: 'workspace.create-folder',
50
+ commandParams: { name: 'my-project' }
51
+ },
52
+ {
53
+ id: 'step-2',
54
+ title: 'Initialize Configuration',
55
+ description: 'Create a configuration file',
56
+ preCondition: async () => {
57
+ // Previous step must be completed
58
+ return true; // This will be checked automatically
59
+ },
60
+ postCondition: async () => {
61
+ const workspace = await workspaceService.getWorkspace();
62
+ if (!workspace) return false;
63
+ const configFile = await workspace.getChild('config.json');
64
+ return configFile !== undefined;
65
+ },
66
+ command: 'workspace.create-file',
67
+ commandParams: { name: 'config.json', content: '{}' }
68
+ }
69
+ ]
70
+ });
71
+ }
72
+ ```
73
+
74
+ ### Using Conditions
75
+
76
+ Conditions are functions that return a boolean or Promise<boolean>:
77
+
78
+ ```typescript
79
+ // Simple synchronous condition
80
+ preCondition: () => {
81
+ return someVariable === true;
82
+ }
83
+
84
+ // Async condition
85
+ preCondition: async () => {
86
+ const result = await someAsyncCheck();
87
+ return result.isValid;
88
+ }
89
+
90
+ // Condition with error handling
91
+ postCondition: async () => {
92
+ try {
93
+ const file = await workspaceService.getFile('path/to/file');
94
+ return file !== undefined;
95
+ } catch {
96
+ return false;
97
+ }
98
+ }
99
+ ```
100
+
101
+ ### Step Commands
102
+
103
+ Steps can optionally execute commands when activated:
104
+
105
+ ```typescript
106
+ {
107
+ id: 'step-1',
108
+ title: 'Open Editor',
109
+ description: 'Open the code editor',
110
+ command: 'editor.open',
111
+ commandParams: {
112
+ file: 'src/main.ts'
113
+ }
114
+ }
115
+ ```
116
+
117
+ ### Optional Steps
118
+
119
+ Steps can be marked as optional, allowing users to skip them:
120
+
121
+ ```typescript
122
+ {
123
+ id: 'step-3',
124
+ title: 'Install Dependencies (Optional)',
125
+ description: 'Install optional dependencies',
126
+ optional: true,
127
+ // ...
128
+ }
129
+ ```
130
+
131
+ ## HowTo Panel UI
132
+
133
+ The floating panel provides:
134
+
135
+ - **Workflow List**: Shows all registered workflows
136
+ - **Step List**: Displays all steps in the active workflow
137
+ - **Status Indicators**: Visual feedback for each step's status
138
+ - **Condition Checks**: Shows whether pre/post conditions are met
139
+ - **Action Buttons**: Execute or skip steps
140
+ - **Drag to Move**: Click and drag the header to reposition
141
+
142
+ ### Showing the Panel
143
+
144
+ The panel is hidden by default. You can show it using:
145
+
146
+ 1. **Toolbar Button**: Click the "HowTo" button in the bottom right toolbar
147
+ 2. **Keyboard Shortcut**: Press `Ctrl+Shift+H` to toggle the panel
148
+ 3. **Command**: Execute the `howto.show-panel` or `howto.toggle-panel` command
149
+ 4. **Command Palette**: Search for "Show HowTo Panel" or "Toggle HowTo Panel"
150
+
151
+ The panel visibility state is persisted in localStorage, so it will remember whether it was visible when you reload the page.
152
+
153
+ ## API Reference
154
+
155
+ ### HowToService
156
+
157
+ The HowToService manages HowTo contributions by reading from the contribution registry:
158
+
159
+ ```typescript
160
+ class HowToService {
161
+ // Get a contribution by ID
162
+ getContribution(contributionId: string): HowToContribution | undefined;
163
+
164
+ // Get all contributions
165
+ getAllContributions(): HowToContribution[];
166
+
167
+ // Get contributions by category
168
+ getContributionsByCategory(category: string): HowToContribution[];
169
+
170
+ // Check if a contribution exists
171
+ hasContribution(contributionId: string): boolean;
172
+ }
173
+ ```
174
+
175
+ The service is registered in the dependency injection context and can be accessed via:
176
+
177
+ ```typescript
178
+ export default function myExtension({ howToService }: any) {
179
+ const contributions = howToService.getAllContributions();
180
+ }
181
+ ```
182
+
183
+ Or imported directly:
184
+
185
+ ```typescript
186
+ import { howToService } from '../../extensions/howto-system/howto-extension';
187
+ ```
188
+
189
+ ### HowToContribution
190
+
191
+ ```typescript
192
+ interface HowToContribution {
193
+ id: string;
194
+ title: string;
195
+ description?: string;
196
+ icon?: string;
197
+ category?: string;
198
+ steps: HowToStep[];
199
+ }
200
+ ```
201
+
202
+ ### HowToStep
203
+
204
+ ```typescript
205
+ interface HowToStep {
206
+ id: string;
207
+ title: string;
208
+ description: string;
209
+ preCondition?: ConditionFunction;
210
+ postCondition?: ConditionFunction;
211
+ command?: string;
212
+ commandParams?: Record<string, any>;
213
+ optional?: boolean;
214
+ }
215
+ ```
216
+
217
+ ### ConditionFunction
218
+
219
+ ```typescript
220
+ type ConditionFunction = () => Promise<boolean> | boolean;
221
+ ```
222
+
223
+ ## Events
224
+
225
+ The system uses the contribution registry's events. Subscribe to contribution changes:
226
+
227
+ ```typescript
228
+ import { TOPIC_CONTRIBUTEIONS_CHANGED } from '../../core/contributionregistry';
229
+ import { HOWTO_CONTRIBUTION_TARGET } from '../../extensions/howto-system/howto-extension';
230
+ import { subscribe } from '../../core/events';
231
+
232
+ subscribe(TOPIC_CONTRIBUTEIONS_CHANGED, (event) => {
233
+ if (event.target === HOWTO_CONTRIBUTION_TARGET) {
234
+ console.log('HowTo contributions changed:', event.contributions);
235
+ }
236
+ });
237
+ ```
238
+
239
+ ## Contribution Target
240
+
241
+ All HowTo contributions must be registered to the target:
242
+
243
+ ```typescript
244
+ import { HOWTO_CONTRIBUTION_TARGET } from '../../extensions/howto-system/howto-extension';
245
+
246
+ // Use this constant when registering contributions
247
+ contributionRegistry.registerContribution(HOWTO_CONTRIBUTION_TARGET, contribution);
248
+ ```
249
+
@@ -0,0 +1,205 @@
1
+ import { contributionRegistry, activeEditorSignal, partDirtySignal, subscribe, appLoaderService, appSettings } from '@kispace-io/core';
2
+ import type { EditorContentProvider } from '@kispace-io/core';
3
+ import { watchSignal } from '@kispace-io/core';
4
+ import { HOWTO_CONTRIBUTION_TARGET } from '../howto-service';
5
+ import type { HowToContribution, HowToContext } from '../howto-contribution';
6
+ import { KEY_AI_CONFIG, TOPIC_AICONFIG_CHANGED } from '@kispace-io/extension-ai-system';
7
+ import type { AIConfig } from '@kispace-io/extension-ai-system';
8
+
9
+ const AI_CONFIG_EDITOR_KEY = '.system.ai-config';
10
+
11
+ /**
12
+ * Type guard to check if an editor implements EditorContentProvider
13
+ */
14
+ function isEditorContentProvider(editor: any): editor is EditorContentProvider {
15
+ return editor &&
16
+ typeof editor.getFilePath === 'function';
17
+ }
18
+
19
+ /**
20
+ * Checks if the AI config editor is open
21
+ */
22
+ function isAIConfigEditorOpen(): boolean {
23
+ const activeEditor = activeEditorSignal.get();
24
+ if (!activeEditor || !isEditorContentProvider(activeEditor)) {
25
+ return false;
26
+ }
27
+
28
+ const filePath = activeEditor.getFilePath();
29
+ return filePath === AI_CONFIG_EDITOR_KEY;
30
+ }
31
+
32
+ /**
33
+ * Checks if an LLM provider is configured (has default provider with API key)
34
+ */
35
+ async function isLLMProviderConfigured(): Promise<boolean> {
36
+ try {
37
+ const config = await appSettings.get(KEY_AI_CONFIG) as AIConfig | undefined;
38
+ if (!config || !config.defaultProvider) {
39
+ return false;
40
+ }
41
+
42
+ const defaultProvider = config.providers?.find(p => p.name === config.defaultProvider);
43
+ if (!defaultProvider) {
44
+ return false;
45
+ }
46
+
47
+ // Check if API key is set (not empty)
48
+ return !!defaultProvider.apiKey && defaultProvider.apiKey.trim() !== '';
49
+ } catch {
50
+ return false;
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Checks if the AI config editor has unsaved changes
56
+ */
57
+ function isAIConfigEditorDirty(): boolean {
58
+ const activeEditor = activeEditorSignal.get();
59
+ if (!activeEditor || !isEditorContentProvider(activeEditor)) {
60
+ return false;
61
+ }
62
+
63
+ const filePath = activeEditor.getFilePath();
64
+ if (filePath !== AI_CONFIG_EDITOR_KEY) {
65
+ return false;
66
+ }
67
+
68
+ return activeEditor.isDirty() === true;
69
+ }
70
+
71
+ /**
72
+ * Checks if the AI config editor is saved (not dirty)
73
+ */
74
+ function isAIConfigEditorSaved(): boolean {
75
+ if (!isAIConfigEditorOpen()) {
76
+ return false;
77
+ }
78
+
79
+ return !isAIConfigEditorDirty();
80
+ }
81
+
82
+ /**
83
+ * Checks if the AI config editor is closed
84
+ */
85
+ function isAIConfigEditorClosed(): boolean {
86
+ return !isAIConfigEditorOpen();
87
+ }
88
+
89
+ /**
90
+ * Checks if user has typed something in the AI chat
91
+ * This checks if there are any chat sessions with messages
92
+ */
93
+ async function hasTypedInChat(): Promise<boolean> {
94
+ try {
95
+ const sessions = await appSettings.get('aiChatSessions') as any;
96
+ if (!sessions || typeof sessions !== 'object') {
97
+ return false;
98
+ }
99
+
100
+ // Check if any session has messages
101
+ for (const sessionId in sessions) {
102
+ const session = sessions[sessionId];
103
+ if (session?.history && Array.isArray(session.history)) {
104
+ // Check if there's at least one user message
105
+ const hasUserMessage = session.history.some((msg: any) =>
106
+ msg.role === 'user' && msg.content && msg.content.trim() !== ''
107
+ );
108
+ if (hasUserMessage) {
109
+ return true;
110
+ }
111
+ }
112
+ }
113
+
114
+ return false;
115
+ } catch {
116
+ return false;
117
+ }
118
+ }
119
+
120
+ // Get current app name for the title
121
+ function getAppName(): string {
122
+ const currentApp = appLoaderService.getCurrentApp();
123
+ return currentApp?.name || 'AppSpace';
124
+ }
125
+
126
+ // Create the AI setup HowTo contribution
127
+ const aiSetupContribution: HowToContribution = {
128
+ id: 'appspace.ai-setup',
129
+ title: () => `Set up AI in ${getAppName()}`,
130
+ description: () => `Configure an LLM provider to enable AI chat features in ${getAppName()}`,
131
+ icon: 'robot',
132
+ label: '',
133
+ category: 'Getting Started',
134
+ initialize: (context: HowToContext) => {
135
+ // Set up subscriptions for editor and AI config changes
136
+ const cleanups: (() => void)[] = [];
137
+
138
+ // Watch editor changes
139
+ cleanups.push(
140
+ watchSignal(activeEditorSignal, () => {
141
+ context.requestUpdate();
142
+ })
143
+ );
144
+
145
+ // Watch dirty state changes
146
+ cleanups.push(
147
+ watchSignal(partDirtySignal, () => {
148
+ context.requestUpdate();
149
+ })
150
+ );
151
+
152
+ // Subscribe to AI config changes
153
+ subscribe(TOPIC_AICONFIG_CHANGED, () => {
154
+ context.requestUpdate();
155
+ });
156
+
157
+ // Return cleanup function
158
+ return () => {
159
+ cleanups.forEach(cleanup => cleanup());
160
+ };
161
+ },
162
+ steps: [
163
+ {
164
+ id: 'open-ai-settings',
165
+ title: 'Open AI Settings',
166
+ description: 'Open the AI settings editor by clicking the robot icon in the toolbar or using the command palette.',
167
+ preCondition: () => true, // Always available
168
+ postCondition: () => isAIConfigEditorOpen(),
169
+ command: 'open_ai_config',
170
+ },
171
+ {
172
+ id: 'configure-llm-provider',
173
+ title: 'Configure LLM Provider',
174
+ description: 'Select a provider as default and enter an API key. Make sure to save your changes using Ctrl+S or the save button.',
175
+ preCondition: () => isAIConfigEditorOpen(),
176
+ postCondition: async () => {
177
+ // Check if provider is configured AND settings are saved
178
+ const configured = await isLLMProviderConfigured();
179
+ const saved = isAIConfigEditorSaved();
180
+ return configured && saved;
181
+ },
182
+ // No command - user manually configures in the editor
183
+ },
184
+ {
185
+ id: 'save-and-close',
186
+ title: 'Save and Close',
187
+ description: 'Save your changes (if not already saved) and close the AI settings editor tab.',
188
+ preCondition: () => isAIConfigEditorOpen(),
189
+ postCondition: () => isAIConfigEditorClosed(),
190
+ // No command - user manually saves and closes the tab
191
+ },
192
+ {
193
+ id: 'type-in-chat',
194
+ title: 'Type in Chat',
195
+ description: 'Open the AI chat view (if not already open) and type a message to test your AI configuration.',
196
+ preCondition: async () => await isLLMProviderConfigured(),
197
+ postCondition: async () => await hasTypedInChat(),
198
+ // No command - user manually types in chat
199
+ }
200
+ ]
201
+ };
202
+
203
+ // Register the contribution
204
+ contributionRegistry.registerContribution<HowToContribution>(HOWTO_CONTRIBUTION_TARGET, aiSetupContribution);
205
+
@@ -0,0 +1,196 @@
1
+ import { contributionRegistry, workspaceService, File, TOPIC_WORKSPACE_CHANGED, TOPIC_WORKSPACE_CONNECTED, activeEditorSignal, partDirtySignal, subscribe, appLoaderService } from '@kispace-io/core';
2
+ import type { EditorContentProvider } from '@kispace-io/core';
3
+ import { watchSignal } from '@kispace-io/core';
4
+ import { HOWTO_CONTRIBUTION_TARGET } from '../howto-service';
5
+ import type { HowToContribution, HowToContext } from '../howto-contribution';
6
+
7
+ const ONBOARDING_FILE_PATH = 'welcome.txt';
8
+
9
+ /**
10
+ * Type guard to check if an editor implements EditorContentProvider
11
+ */
12
+ function isEditorContentProvider(editor: any): editor is EditorContentProvider {
13
+ return editor &&
14
+ typeof editor.getFilePath === 'function';
15
+ }
16
+
17
+ /**
18
+ * Checks if a workspace is selected
19
+ */
20
+ async function isWorkspaceSelected(): Promise<boolean> {
21
+ return workspaceService.isConnected();
22
+ }
23
+
24
+ /**
25
+ * Checks if the onboarding file exists
26
+ */
27
+ async function onboardingFileExists(): Promise<boolean> {
28
+ const workspace = await workspaceService.getWorkspace();
29
+ if (!workspace) return false;
30
+
31
+ try {
32
+ const resource = await workspace.getResource(ONBOARDING_FILE_PATH);
33
+ return resource instanceof File;
34
+ } catch {
35
+ return false;
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Checks if the onboarding file is open in an editor
41
+ */
42
+ function isOnboardingFileOpen(): boolean {
43
+ const activeEditor = activeEditorSignal.get();
44
+ if (!activeEditor || !isEditorContentProvider(activeEditor)) {
45
+ return false;
46
+ }
47
+
48
+ const filePath = activeEditor.getFilePath();
49
+ return filePath === ONBOARDING_FILE_PATH;
50
+ }
51
+
52
+ /**
53
+ * Checks if the active editor is dirty (has unsaved changes)
54
+ * Returns true only if the onboarding file is open AND dirty
55
+ */
56
+ function isActiveEditorDirty(): boolean {
57
+ if (!isOnboardingFileOpen()) return false;
58
+
59
+ const activeEditor = activeEditorSignal.get();
60
+ if (!activeEditor) return false;
61
+
62
+ return activeEditor.isDirty() === true;
63
+ }
64
+
65
+ /**
66
+ * Checks if the active editor is clean (no unsaved changes)
67
+ * Returns true only if the onboarding file is open AND not dirty
68
+ */
69
+ function isActiveEditorClean(): boolean {
70
+ if (!isOnboardingFileOpen()) return false;
71
+
72
+ const activeEditor = activeEditorSignal.get();
73
+ if (!activeEditor) return false;
74
+
75
+ return activeEditor.isDirty() === false;
76
+ }
77
+
78
+ /**
79
+ * Checks if the onboarding file is closed (not open in any editor)
80
+ */
81
+ function isOnboardingFileClosed(): boolean {
82
+ return !isOnboardingFileOpen();
83
+ }
84
+
85
+ // Get current app name for the title
86
+ function getAppName(): string {
87
+ const currentApp = appLoaderService.getCurrentApp();
88
+ return currentApp?.name || 'AppSpace';
89
+ }
90
+
91
+ // Create the onboarding HowTo contribution
92
+ // Using callback functions so the app name is read when the HowTo is displayed
93
+ const onboardingContribution: HowToContribution = {
94
+ id: 'appspace.onboarding',
95
+ title: () => `Welcome to ${getAppName()}`,
96
+ description: () => `Get started with ${getAppName()} by learning the basics of workspace and file management`,
97
+ icon: 'graduation-cap',
98
+ // label will be set from title in howto-service.ts
99
+ label: '',
100
+ category: 'Getting Started',
101
+ initialize: (context: HowToContext) => {
102
+ // Set up subscriptions for workspace and editor changes
103
+ const cleanups: (() => void)[] = [];
104
+
105
+ // Subscribe to workspace events
106
+ subscribe(TOPIC_WORKSPACE_CHANGED, () => {
107
+ context.requestUpdate();
108
+ });
109
+
110
+ subscribe(TOPIC_WORKSPACE_CONNECTED, () => {
111
+ context.requestUpdate();
112
+ });
113
+
114
+ // Watch editor signals
115
+ cleanups.push(
116
+ watchSignal(activeEditorSignal, () => {
117
+ context.requestUpdate();
118
+ })
119
+ );
120
+
121
+ cleanups.push(
122
+ watchSignal(partDirtySignal, () => {
123
+ context.requestUpdate();
124
+ })
125
+ );
126
+
127
+ // Return cleanup function
128
+ return () => {
129
+ cleanups.forEach(cleanup => cleanup());
130
+ };
131
+ },
132
+ steps: [
133
+ {
134
+ id: 'create-text-file',
135
+ title: 'Create welcome.txt',
136
+ description: 'Create a new text file called "welcome.txt" in your workspace. If you don\'t have a workspace selected, choose one first.',
137
+ preCondition: async () => {
138
+ // Workspace must be selected
139
+ return await isWorkspaceSelected();
140
+ },
141
+ postCondition: async () => {
142
+ return await onboardingFileExists();
143
+ },
144
+ command: 'create_file',
145
+ commandParams: {
146
+ path: ONBOARDING_FILE_PATH,
147
+ contents: 'Welcome to AppSpace!\n\nThis is your first file. You can edit it and save your changes.'
148
+ }
149
+ },
150
+ {
151
+ id: 'open-text-file',
152
+ title: 'Open welcome.txt',
153
+ description: 'Open the "welcome.txt" file in the editor.',
154
+ preCondition: async () => {
155
+ return await onboardingFileExists();
156
+ },
157
+ postCondition: () => {
158
+ return isOnboardingFileOpen();
159
+ },
160
+ command: 'open_editor',
161
+ commandParams: {
162
+ path: ONBOARDING_FILE_PATH
163
+ }
164
+ },
165
+ {
166
+ id: 'edit-and-save',
167
+ title: 'Type something and save',
168
+ description: 'Type some text in the editor to modify the file, then save it using Ctrl+S or the save button.',
169
+ preCondition: () => {
170
+ return isOnboardingFileOpen();
171
+ },
172
+ postCondition: () => {
173
+ // File must be open, was dirty (edited), and is now clean (saved)
174
+ // Check that file is open and clean (saved)
175
+ return isActiveEditorClean();
176
+ },
177
+ // No command - user manually edits and saves
178
+ },
179
+ {
180
+ id: 'close-text-file',
181
+ title: 'Close the file',
182
+ description: 'Close the editor tab by clicking the X button on the tab.',
183
+ preCondition: () => {
184
+ return isOnboardingFileOpen();
185
+ },
186
+ postCondition: () => {
187
+ return isOnboardingFileClosed();
188
+ },
189
+ // No command - user manually closes the tab
190
+ }
191
+ ]
192
+ };
193
+
194
+ // Register the contribution
195
+ contributionRegistry.registerContribution<HowToContribution>(HOWTO_CONTRIBUTION_TARGET, onboardingContribution);
196
+