kanban-lite 1.0.4

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.
Files changed (99) hide show
  1. package/.editorconfig +9 -0
  2. package/.github/workflows/ci.yml +59 -0
  3. package/.github/workflows/release.yml +75 -0
  4. package/.prettierignore +6 -0
  5. package/.prettierrc.yaml +4 -0
  6. package/.vscode/extensions.json +3 -0
  7. package/.vscode/launch.json +17 -0
  8. package/.vscode/settings.json +21 -0
  9. package/.vscode/tasks.json +22 -0
  10. package/.vscodeignore +11 -0
  11. package/CHANGELOG.md +184 -0
  12. package/CLAUDE.md +58 -0
  13. package/CONTRIBUTING.md +114 -0
  14. package/LICENSE +22 -0
  15. package/README.md +482 -0
  16. package/SKILL.md +237 -0
  17. package/dist/cli.js +8716 -0
  18. package/dist/extension.js +8463 -0
  19. package/dist/mcp-server.js +1327 -0
  20. package/dist/standalone-webview/icons-Dx9MGYqN.js +180 -0
  21. package/dist/standalone-webview/icons-Dx9MGYqN.js.map +1 -0
  22. package/dist/standalone-webview/index.js +85 -0
  23. package/dist/standalone-webview/index.js.map +1 -0
  24. package/dist/standalone-webview/react-vendor-DkYdDBET.js +25 -0
  25. package/dist/standalone-webview/react-vendor-DkYdDBET.js.map +1 -0
  26. package/dist/standalone-webview/style.css +1 -0
  27. package/dist/standalone.js +7513 -0
  28. package/dist/webview/icons-Dx9MGYqN.js +180 -0
  29. package/dist/webview/icons-Dx9MGYqN.js.map +1 -0
  30. package/dist/webview/index.js +85 -0
  31. package/dist/webview/index.js.map +1 -0
  32. package/dist/webview/react-vendor-DkYdDBET.js +25 -0
  33. package/dist/webview/react-vendor-DkYdDBET.js.map +1 -0
  34. package/dist/webview/style.css +1 -0
  35. package/docs/images/board-overview.png +0 -0
  36. package/docs/images/editor-view.png +0 -0
  37. package/docs/plans/2026-02-20-kanban-json-config-design.md +74 -0
  38. package/docs/plans/2026-02-20-kanban-json-config.md +690 -0
  39. package/eslint.config.mjs +31 -0
  40. package/package.json +161 -0
  41. package/postcss.config.js +6 -0
  42. package/resources/icon-light.png +0 -0
  43. package/resources/icon-light.svg +105 -0
  44. package/resources/icon.png +0 -0
  45. package/resources/icon.svg +105 -0
  46. package/resources/kanban-dark.svg +21 -0
  47. package/resources/kanban-light.svg +21 -0
  48. package/resources/kanban.svg +21 -0
  49. package/src/cli/index.ts +846 -0
  50. package/src/extension/FeatureHeaderProvider.ts +370 -0
  51. package/src/extension/KanbanPanel.ts +973 -0
  52. package/src/extension/SidebarViewProvider.ts +507 -0
  53. package/src/extension/featureFileUtils.ts +82 -0
  54. package/src/extension/index.ts +234 -0
  55. package/src/mcp-server/index.ts +632 -0
  56. package/src/sdk/KanbanSDK.ts +349 -0
  57. package/src/sdk/__tests__/KanbanSDK.test.ts +468 -0
  58. package/src/sdk/__tests__/parser.test.ts +170 -0
  59. package/src/sdk/fileUtils.ts +76 -0
  60. package/src/sdk/index.ts +6 -0
  61. package/src/sdk/parser.ts +70 -0
  62. package/src/sdk/types.ts +15 -0
  63. package/src/shared/config.ts +113 -0
  64. package/src/shared/editorTypes.ts +14 -0
  65. package/src/shared/types.ts +120 -0
  66. package/src/standalone/__tests__/server.integration.test.ts +1916 -0
  67. package/src/standalone/__tests__/webhooks.test.ts +357 -0
  68. package/src/standalone/fileUtils.ts +70 -0
  69. package/src/standalone/index.ts +71 -0
  70. package/src/standalone/server.ts +1046 -0
  71. package/src/standalone/webhooks.ts +135 -0
  72. package/src/webview/App.tsx +469 -0
  73. package/src/webview/assets/main.css +329 -0
  74. package/src/webview/assets/standalone-theme.css +130 -0
  75. package/src/webview/components/ColumnDialog.tsx +119 -0
  76. package/src/webview/components/CreateFeatureDialog.tsx +524 -0
  77. package/src/webview/components/DatePicker.tsx +185 -0
  78. package/src/webview/components/FeatureCard.tsx +186 -0
  79. package/src/webview/components/FeatureEditor.tsx +623 -0
  80. package/src/webview/components/KanbanBoard.tsx +144 -0
  81. package/src/webview/components/KanbanColumn.tsx +159 -0
  82. package/src/webview/components/MarkdownEditor.tsx +291 -0
  83. package/src/webview/components/PrioritySelect.tsx +39 -0
  84. package/src/webview/components/QuickAddInput.tsx +72 -0
  85. package/src/webview/components/SettingsPanel.tsx +284 -0
  86. package/src/webview/components/Toolbar.tsx +175 -0
  87. package/src/webview/components/UndoToast.tsx +70 -0
  88. package/src/webview/index.html +12 -0
  89. package/src/webview/lib/utils.ts +6 -0
  90. package/src/webview/main.tsx +11 -0
  91. package/src/webview/standalone-main.tsx +13 -0
  92. package/src/webview/standalone-shim.ts +132 -0
  93. package/src/webview/standalone.html +12 -0
  94. package/src/webview/store/index.ts +241 -0
  95. package/tailwind.config.js +53 -0
  96. package/tsconfig.json +22 -0
  97. package/vite.config.ts +36 -0
  98. package/vite.standalone.config.ts +62 -0
  99. package/vitest.config.ts +15 -0
@@ -0,0 +1,973 @@
1
+ import * as vscode from 'vscode'
2
+ import * as path from 'path'
3
+ import { generateKeyBetween, generateNKeysBetween } from 'fractional-indexing'
4
+ import { getTitleFromContent, generateFeatureFilename, extractNumericId } from '../shared/types'
5
+ import type { Feature, FeatureStatus, Priority, KanbanColumn, FeatureFrontmatter, CardDisplaySettings } from '../shared/types'
6
+ import { parseFeatureFile, serializeFeature } from '../sdk/parser'
7
+ import { ensureStatusSubfolders, moveFeatureFile, renameFeatureFile, getFeatureFilePath, getStatusFromPath } from './featureFileUtils'
8
+ import { readConfig, writeConfig, configToSettings, settingsToConfig, allocateCardId, syncCardIdCounter, CONFIG_FILENAME, DEFAULT_CONFIG } from '../shared/config'
9
+
10
+
11
+ interface CreateFeatureData {
12
+ status: FeatureStatus
13
+ priority: Priority
14
+ content: string
15
+ assignee: string | null
16
+ dueDate: string | null
17
+ labels: string[]
18
+ }
19
+
20
+ export class KanbanPanel {
21
+ public static readonly viewType = 'kanban-lite.panel'
22
+ public static currentPanel: KanbanPanel | undefined
23
+
24
+ private readonly _panel: vscode.WebviewPanel
25
+ private readonly _extensionUri: vscode.Uri
26
+ private readonly _context: vscode.ExtensionContext
27
+ private _features: Feature[] = []
28
+ private _disposables: vscode.Disposable[] = []
29
+ private _fileWatcher: vscode.FileSystemWatcher | undefined
30
+ private _configWatcher: vscode.FileSystemWatcher | undefined
31
+ private _currentEditingFeatureId: string | null = null
32
+ private _lastWrittenContent: string = ''
33
+ private _migrating = false
34
+ private _onDisposeCallbacks: (() => void)[] = []
35
+
36
+ public static createOrShow(extensionUri: vscode.Uri, context: vscode.ExtensionContext) {
37
+ const column = vscode.window.activeTextEditor
38
+ ? vscode.window.activeTextEditor.viewColumn
39
+ : undefined
40
+
41
+ // If we already have a panel, show it
42
+ if (KanbanPanel.currentPanel) {
43
+ KanbanPanel.currentPanel._panel.reveal(column)
44
+ return
45
+ }
46
+
47
+ // Otherwise, create a new panel
48
+ const panel = vscode.window.createWebviewPanel(
49
+ KanbanPanel.viewType,
50
+ 'Kanban Board',
51
+ column || vscode.ViewColumn.One,
52
+ {
53
+ enableScripts: true,
54
+ retainContextWhenHidden: true,
55
+ localResourceRoots: [
56
+ vscode.Uri.joinPath(extensionUri, 'dist'),
57
+ vscode.Uri.joinPath(extensionUri, 'dist', 'webview')
58
+ ]
59
+ }
60
+ )
61
+
62
+ // Set the tab icon
63
+ panel.iconPath = {
64
+ light: vscode.Uri.joinPath(extensionUri, 'resources', 'kanban-light.svg'),
65
+ dark: vscode.Uri.joinPath(extensionUri, 'resources', 'kanban-dark.svg')
66
+ }
67
+
68
+ KanbanPanel.currentPanel = new KanbanPanel(panel, extensionUri, context)
69
+ }
70
+
71
+ public static revive(panel: vscode.WebviewPanel, extensionUri: vscode.Uri, context: vscode.ExtensionContext) {
72
+ KanbanPanel.currentPanel = new KanbanPanel(panel, extensionUri, context)
73
+ }
74
+
75
+ private constructor(panel: vscode.WebviewPanel, extensionUri: vscode.Uri, context: vscode.ExtensionContext) {
76
+ this._panel = panel
77
+ this._extensionUri = extensionUri
78
+ this._context = context
79
+
80
+ // Ensure webview options are set (critical for deserialization after reload)
81
+ this._panel.webview.options = {
82
+ enableScripts: true,
83
+ localResourceRoots: [
84
+ vscode.Uri.joinPath(extensionUri, 'dist'),
85
+ vscode.Uri.joinPath(extensionUri, 'dist', 'webview')
86
+ ]
87
+ }
88
+
89
+ // Set the webview's initial html content
90
+ this._update()
91
+
92
+ // Listen for when the panel is disposed
93
+ this._panel.onDidDispose(() => this.dispose(), null, this._disposables)
94
+
95
+ // Handle messages from the webview
96
+ this._panel.webview.onDidReceiveMessage(
97
+ async (message) => {
98
+ switch (message.type) {
99
+ case 'ready':
100
+ await this._loadFeatures()
101
+ this._sendFeaturesToWebview()
102
+ break
103
+ case 'createFeature': {
104
+ await this._createFeature(message.data)
105
+ const createRoot = this._getWorkspaceRoot()
106
+ const createCfg = createRoot ? readConfig(createRoot) : DEFAULT_CONFIG
107
+ if (createCfg.markdownEditorMode) {
108
+ // Open the newly created feature in native editor
109
+ const created = this._features[this._features.length - 1]
110
+ if (created) {
111
+ this._openFeatureInNativeEditor(created.id)
112
+ }
113
+ }
114
+ break
115
+ }
116
+ case 'moveFeature':
117
+ await this._moveFeature(message.featureId, message.newStatus, message.newOrder)
118
+ break
119
+ case 'deleteFeature':
120
+ await this._deleteFeature(message.featureId)
121
+ break
122
+ case 'updateFeature':
123
+ await this._updateFeature(message.featureId, message.updates)
124
+ break
125
+ case 'openFeature': {
126
+ const openRoot = this._getWorkspaceRoot()
127
+ const openCfg = openRoot ? readConfig(openRoot) : DEFAULT_CONFIG
128
+ if (openCfg.markdownEditorMode) {
129
+ this._openFeatureInNativeEditor(message.featureId)
130
+ } else {
131
+ await this._sendFeatureContent(message.featureId)
132
+ }
133
+ break
134
+ }
135
+ case 'saveFeatureContent':
136
+ await this._saveFeatureContent(message.featureId, message.content, message.frontmatter)
137
+ break
138
+ case 'closeFeature':
139
+ this._currentEditingFeatureId = null
140
+ break
141
+ case 'openFile': {
142
+ const feat = this._features.find(f => f.id === message.featureId)
143
+ if (feat) {
144
+ const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(feat.filePath))
145
+ await vscode.window.showTextDocument(doc, { viewColumn: vscode.ViewColumn.Beside })
146
+ }
147
+ break
148
+ }
149
+ case 'openSettings': {
150
+ const settingsRoot = this._getWorkspaceRoot()
151
+ const settingsCfg = settingsRoot ? readConfig(settingsRoot) : { ...DEFAULT_CONFIG }
152
+ const openSettings = configToSettings(settingsCfg)
153
+ this._panel.webview.postMessage({ type: 'showSettings', settings: openSettings })
154
+ break
155
+ }
156
+ case 'focusMenuBar':
157
+ // Focus must leave the webview before focusMenuBar works (VS Code limitation).
158
+ // Use Activity Bar (not Side Bar) — it's always visible and won't expand a collapsed sidebar.
159
+ await vscode.commands.executeCommand('workbench.action.focusActivityBar')
160
+ await vscode.commands.executeCommand('workbench.action.focusMenuBar')
161
+ break
162
+ case 'addAttachment':
163
+ await this._addAttachment(message.featureId)
164
+ break
165
+ case 'openAttachment':
166
+ await this._openAttachment(message.featureId, message.attachment)
167
+ break
168
+ case 'removeAttachment':
169
+ await this._removeAttachment(message.featureId, message.attachment)
170
+ break
171
+ case 'startWithAI':
172
+ await this._startWithAI(message.agent, message.permissionMode)
173
+ break
174
+ case 'saveSettings':
175
+ await this._saveSettings(message.settings)
176
+ break
177
+ case 'addColumn':
178
+ await this._addColumn(message.column)
179
+ break
180
+ case 'editColumn':
181
+ await this._editColumn(message.columnId, message.updates)
182
+ break
183
+ case 'removeColumn':
184
+ await this._removeColumn(message.columnId)
185
+ break
186
+ }
187
+ },
188
+ null,
189
+ this._disposables
190
+ )
191
+
192
+ // Set up file watcher for feature files
193
+ this._setupFileWatcher()
194
+
195
+ // Watch .kanban.json for config changes
196
+ this._setupConfigWatcher()
197
+ }
198
+
199
+ private _setupFileWatcher(): void {
200
+ // Dispose old watcher if re-setting up (e.g. featuresDirectory changed)
201
+ if (this._fileWatcher) {
202
+ this._fileWatcher.dispose()
203
+ }
204
+
205
+ const featuresDir = this._getWorkspaceFeaturesDir()
206
+ if (!featuresDir) return
207
+
208
+ // Watch for changes in the features directory (recursive for status subfolders)
209
+ const pattern = new vscode.RelativePattern(featuresDir, '**/*.md')
210
+ this._fileWatcher = vscode.workspace.createFileSystemWatcher(pattern)
211
+
212
+ // Debounce to avoid multiple rapid updates
213
+ let debounceTimer: NodeJS.Timeout | undefined
214
+
215
+ const handleFileChange = (uri?: vscode.Uri) => {
216
+ if (this._migrating) return
217
+ if (debounceTimer) clearTimeout(debounceTimer)
218
+ debounceTimer = setTimeout(async () => {
219
+ await this._loadFeatures()
220
+ this._sendFeaturesToWebview()
221
+
222
+ // If the changed file is the currently-edited feature, check for external changes
223
+ if (this._currentEditingFeatureId && uri) {
224
+ const editingFeature = this._features.find(f => f.id === this._currentEditingFeatureId)
225
+ if (editingFeature && editingFeature.filePath === uri.fsPath) {
226
+ const currentContent = this._serializeFeature(editingFeature)
227
+ if (currentContent !== this._lastWrittenContent) {
228
+ // External change detected — refresh the editor
229
+ this._sendFeatureContent(this._currentEditingFeatureId)
230
+ }
231
+ }
232
+ }
233
+ }, 100)
234
+ }
235
+
236
+ this._fileWatcher.onDidChange((uri) => handleFileChange(uri), null, this._disposables)
237
+ this._fileWatcher.onDidCreate((uri) => handleFileChange(uri), null, this._disposables)
238
+ this._fileWatcher.onDidDelete((uri) => handleFileChange(uri), null, this._disposables)
239
+
240
+ this._disposables.push(this._fileWatcher)
241
+ }
242
+
243
+ private _setupConfigWatcher(): void {
244
+ if (this._configWatcher) {
245
+ this._configWatcher.dispose()
246
+ }
247
+
248
+ const root = this._getWorkspaceRoot()
249
+ if (!root) return
250
+
251
+ const pattern = new vscode.RelativePattern(root, CONFIG_FILENAME)
252
+ this._configWatcher = vscode.workspace.createFileSystemWatcher(pattern)
253
+
254
+ let lastFeaturesDir = this._getWorkspaceFeaturesDir()
255
+
256
+ const handleConfigChange = () => {
257
+ const newFeaturesDir = this._getWorkspaceFeaturesDir()
258
+ if (lastFeaturesDir !== newFeaturesDir) {
259
+ lastFeaturesDir = newFeaturesDir
260
+ this._setupFileWatcher()
261
+ this._loadFeatures().then(() => this._sendFeaturesToWebview())
262
+ } else {
263
+ this._sendFeaturesToWebview()
264
+ }
265
+ }
266
+
267
+ this._configWatcher.onDidChange(handleConfigChange, null, this._disposables)
268
+ this._configWatcher.onDidCreate(handleConfigChange, null, this._disposables)
269
+ this._configWatcher.onDidDelete(handleConfigChange, null, this._disposables)
270
+ this._disposables.push(this._configWatcher)
271
+ }
272
+
273
+ public onDispose(callback: () => void): void {
274
+ this._onDisposeCallbacks.push(callback)
275
+ }
276
+
277
+ public dispose() {
278
+ KanbanPanel.currentPanel = undefined
279
+
280
+ for (const cb of this._onDisposeCallbacks) {
281
+ cb()
282
+ }
283
+ this._onDisposeCallbacks = []
284
+
285
+ this._panel.dispose()
286
+
287
+ while (this._disposables.length) {
288
+ const x = this._disposables.pop()
289
+ if (x) {
290
+ x.dispose()
291
+ }
292
+ }
293
+ }
294
+
295
+ private _update() {
296
+ this._panel.webview.html = this._getHtmlForWebview(this._panel.webview)
297
+ }
298
+
299
+ private _getHtmlForWebview(webview: vscode.Webview): string {
300
+ const scriptUri = webview.asWebviewUri(
301
+ vscode.Uri.joinPath(this._extensionUri, 'dist', 'webview', 'index.js')
302
+ )
303
+ const styleUri = webview.asWebviewUri(
304
+ vscode.Uri.joinPath(this._extensionUri, 'dist', 'webview', 'style.css')
305
+ )
306
+
307
+ const nonce = this._getNonce()
308
+
309
+ return `<!DOCTYPE html>
310
+ <html lang="en">
311
+ <head>
312
+ <meta charset="UTF-8">
313
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
314
+ <meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src ${webview.cspSource} 'unsafe-inline'; script-src ${webview.cspSource} 'nonce-${nonce}';">
315
+ <link href="${styleUri}" rel="stylesheet">
316
+ <title>Kanban Board</title>
317
+ </head>
318
+ <body>
319
+ <div id="root"></div>
320
+ <script type="module" nonce="${nonce}" src="${scriptUri}"></script>
321
+ </body>
322
+ </html>`
323
+ }
324
+
325
+ private _getNonce(): string {
326
+ let text = ''
327
+ const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
328
+ for (let i = 0; i < 32; i++) {
329
+ text += possible.charAt(Math.floor(Math.random() * possible.length))
330
+ }
331
+ return text
332
+ }
333
+
334
+ private _getWorkspaceRoot(): string | null {
335
+ const workspaceFolders = vscode.workspace.workspaceFolders
336
+ if (!workspaceFolders || workspaceFolders.length === 0) return null
337
+ return workspaceFolders[0].uri.fsPath
338
+ }
339
+
340
+ private _getWorkspaceFeaturesDir(): string | null {
341
+ const root = this._getWorkspaceRoot()
342
+ if (!root) return null
343
+ const config = readConfig(root)
344
+ return path.join(root, config.featuresDirectory)
345
+ }
346
+
347
+ private async _ensureFeaturesDir(): Promise<string | null> {
348
+ const featuresDir = this._getWorkspaceFeaturesDir()
349
+ if (!featuresDir) return null
350
+
351
+ try {
352
+ await vscode.workspace.fs.createDirectory(vscode.Uri.file(featuresDir))
353
+ const columnIds = this._getColumns().map(c => c.id)
354
+ await ensureStatusSubfolders(featuresDir, columnIds)
355
+ return featuresDir
356
+ } catch {
357
+ return null
358
+ }
359
+ }
360
+
361
+ private async _loadFeatures(): Promise<void> {
362
+ const featuresDir = this._getWorkspaceFeaturesDir()
363
+ if (!featuresDir) {
364
+ this._features = []
365
+ return
366
+ }
367
+
368
+ try {
369
+ await vscode.workspace.fs.createDirectory(vscode.Uri.file(featuresDir))
370
+ const columnIds = this._getColumns().map(c => c.id)
371
+ await ensureStatusSubfolders(featuresDir, columnIds)
372
+
373
+ // Phase 1: Migrate flat root .md files into their status subfolder
374
+ this._migrating = true
375
+ try {
376
+ const rootEntries = await vscode.workspace.fs.readDirectory(vscode.Uri.file(featuresDir))
377
+ for (const [name, type] of rootEntries) {
378
+ if (type !== vscode.FileType.File || !name.endsWith('.md')) continue
379
+ const filePath = path.join(featuresDir, name)
380
+ try {
381
+ const content = new TextDecoder().decode(await vscode.workspace.fs.readFile(vscode.Uri.file(filePath)))
382
+ const feature = this._parseFeatureFile(content, filePath)
383
+ if (feature) {
384
+ await moveFeatureFile(filePath, featuresDir, feature.status, feature.attachments)
385
+ }
386
+ } catch {
387
+ // Skip files that fail to migrate
388
+ }
389
+ }
390
+ } finally {
391
+ this._migrating = false
392
+ }
393
+
394
+ // Phase 2: Load .md files from ALL subdirectories
395
+ const features: Feature[] = []
396
+ const topEntries = await vscode.workspace.fs.readDirectory(vscode.Uri.file(featuresDir))
397
+ for (const [name, type] of topEntries) {
398
+ if (type !== vscode.FileType.Directory || name.startsWith('.')) continue
399
+ const subdir = path.join(featuresDir, name)
400
+ try {
401
+ const entries = await vscode.workspace.fs.readDirectory(vscode.Uri.file(subdir))
402
+ for (const [file, fileType] of entries) {
403
+ if (fileType !== vscode.FileType.File || !file.endsWith('.md')) continue
404
+ const filePath = path.join(subdir, file)
405
+ const content = new TextDecoder().decode(await vscode.workspace.fs.readFile(vscode.Uri.file(filePath)))
406
+ const feature = this._parseFeatureFile(content, filePath)
407
+ if (feature) features.push(feature)
408
+ }
409
+ } catch {
410
+ // Skip unreadable directories
411
+ }
412
+ }
413
+
414
+ // Phase 3: Reconcile status ↔ folder mismatches
415
+ this._migrating = true
416
+ try {
417
+ for (const feature of features) {
418
+ const pathStatus = getStatusFromPath(feature.filePath, featuresDir)
419
+ if (pathStatus !== null && pathStatus !== feature.status) {
420
+ try {
421
+ const newPath = await moveFeatureFile(feature.filePath, featuresDir, feature.status, feature.attachments)
422
+ feature.filePath = newPath
423
+ } catch {
424
+ // Will retry on next load
425
+ }
426
+ }
427
+ }
428
+ } finally {
429
+ this._migrating = false
430
+ }
431
+
432
+ // Migrate legacy integer order values to fractional indices
433
+ const hasLegacyOrder = features.some(f => /^\d+$/.test(f.order))
434
+ if (hasLegacyOrder) {
435
+ const byStatus = new Map<string, Feature[]>()
436
+ for (const f of features) {
437
+ const list = byStatus.get(f.status) || []
438
+ list.push(f)
439
+ byStatus.set(f.status, list)
440
+ }
441
+
442
+ const migrationWrites: Feature[] = []
443
+ for (const columnFeatures of byStatus.values()) {
444
+ columnFeatures.sort((a, b) => parseInt(a.order) - parseInt(b.order))
445
+ const keys = generateNKeysBetween(null, null, columnFeatures.length)
446
+ for (let i = 0; i < columnFeatures.length; i++) {
447
+ columnFeatures[i].order = keys[i]
448
+ migrationWrites.push(columnFeatures[i])
449
+ }
450
+ }
451
+
452
+ for (const f of migrationWrites) {
453
+ const content = this._serializeFeature(f)
454
+ await vscode.workspace.fs.writeFile(vscode.Uri.file(f.filePath), new TextEncoder().encode(content))
455
+ }
456
+ }
457
+
458
+ // Sync ID counter with existing cards
459
+ const root = this._getWorkspaceRoot()
460
+ if (root) {
461
+ const numericIds = features
462
+ .map(f => parseInt(f.id, 10))
463
+ .filter(n => !Number.isNaN(n))
464
+ if (numericIds.length > 0) {
465
+ syncCardIdCounter(root, numericIds)
466
+ }
467
+ }
468
+
469
+ this._features = features.sort((a, b) => (a.order < b.order ? -1 : a.order > b.order ? 1 : 0))
470
+ } catch {
471
+ this._features = []
472
+ }
473
+ }
474
+
475
+ private _parseFeatureFile(content: string, filePath: string): Feature | null {
476
+ return parseFeatureFile(content, filePath)
477
+ }
478
+
479
+ private _serializeFeature(feature: Feature): string {
480
+ return serializeFeature(feature)
481
+ }
482
+
483
+ public triggerCreateDialog(): void {
484
+ this._panel.webview.postMessage({ type: 'triggerCreateDialog' })
485
+ }
486
+
487
+ public openFeature(featureId: string): void {
488
+ const root = this._getWorkspaceRoot()
489
+ const cfg = root ? readConfig(root) : DEFAULT_CONFIG
490
+ if (cfg.markdownEditorMode) {
491
+ this._openFeatureInNativeEditor(featureId)
492
+ } else {
493
+ this._sendFeatureContent(featureId)
494
+ }
495
+ }
496
+
497
+ private async _createFeature(data: CreateFeatureData): Promise<void> {
498
+ const featuresDir = await this._ensureFeaturesDir()
499
+ if (!featuresDir) {
500
+ vscode.window.showErrorMessage('No workspace folder open')
501
+ return
502
+ }
503
+
504
+ const title = getTitleFromContent(data.content)
505
+ const workspaceRoot = this._getWorkspaceRoot()
506
+ if (!workspaceRoot) return
507
+ const numericId = allocateCardId(workspaceRoot)
508
+ const filename = generateFeatureFilename(numericId, title)
509
+ const now = new Date().toISOString()
510
+ const featuresInStatus = this._features
511
+ .filter(f => f.status === data.status)
512
+ .sort((a, b) => (a.order < b.order ? -1 : a.order > b.order ? 1 : 0))
513
+ const lastOrder = featuresInStatus.length > 0 ? featuresInStatus[featuresInStatus.length - 1].order : null
514
+
515
+ const feature: Feature = {
516
+ id: String(numericId),
517
+ status: data.status,
518
+ priority: data.priority,
519
+ assignee: data.assignee,
520
+ dueDate: data.dueDate,
521
+ created: now,
522
+ modified: now,
523
+ completedAt: data.status === 'done' ? now : null,
524
+ labels: data.labels,
525
+ attachments: [],
526
+ order: generateKeyBetween(lastOrder, null),
527
+ content: data.content,
528
+ filePath: getFeatureFilePath(featuresDir, data.status, filename)
529
+ }
530
+
531
+ await vscode.workspace.fs.createDirectory(vscode.Uri.file(path.dirname(feature.filePath)))
532
+ const content = this._serializeFeature(feature)
533
+ await vscode.workspace.fs.writeFile(vscode.Uri.file(feature.filePath), new TextEncoder().encode(content))
534
+
535
+ this._features.push(feature)
536
+ this._sendFeaturesToWebview()
537
+ }
538
+
539
+ private async _moveFeature(featureId: string, newStatus: string, newOrder: number): Promise<void> {
540
+ const feature = this._features.find(f => f.id === featureId)
541
+ if (!feature) return
542
+
543
+ const featuresDir = this._getWorkspaceFeaturesDir()
544
+ if (!featuresDir) return
545
+
546
+ const oldStatus = feature.status
547
+ const statusChanged = oldStatus !== newStatus
548
+
549
+ // Update feature status
550
+ feature.status = newStatus as FeatureStatus
551
+ feature.modified = new Date().toISOString()
552
+ if (statusChanged) {
553
+ feature.completedAt = newStatus === 'done' ? new Date().toISOString() : null
554
+ }
555
+
556
+ // Get sorted features in the target column (excluding the moved feature)
557
+ const targetColumnFeatures = this._features
558
+ .filter(f => f.status === newStatus && f.id !== featureId)
559
+ .sort((a, b) => (a.order < b.order ? -1 : a.order > b.order ? 1 : 0))
560
+
561
+ // Compute fractional index between neighbors at the target position
562
+ const clampedOrder = Math.max(0, Math.min(newOrder, targetColumnFeatures.length))
563
+ const before = clampedOrder > 0 ? targetColumnFeatures[clampedOrder - 1].order : null
564
+ const after = clampedOrder < targetColumnFeatures.length ? targetColumnFeatures[clampedOrder].order : null
565
+ feature.order = generateKeyBetween(before, after)
566
+
567
+ // Only the moved feature needs to be written
568
+ const content = this._serializeFeature(feature)
569
+ await vscode.workspace.fs.writeFile(vscode.Uri.file(feature.filePath), new TextEncoder().encode(content))
570
+
571
+ // Move file when status changes
572
+ if (statusChanged) {
573
+ this._migrating = true
574
+ try {
575
+ const newPath = await moveFeatureFile(feature.filePath, featuresDir, newStatus, feature.attachments)
576
+ feature.filePath = newPath
577
+ } catch {
578
+ // Move failed; file stays in old folder, will reconcile on next load
579
+ } finally {
580
+ this._migrating = false
581
+ }
582
+ }
583
+
584
+ this._sendFeaturesToWebview()
585
+ }
586
+
587
+ private async _deleteFeature(featureId: string): Promise<void> {
588
+ const feature = this._features.find(f => f.id === featureId)
589
+ if (!feature) return
590
+
591
+ try {
592
+ await vscode.workspace.fs.delete(vscode.Uri.file(feature.filePath))
593
+ this._features = this._features.filter(f => f.id !== featureId)
594
+ this._sendFeaturesToWebview()
595
+ } catch (err) {
596
+ vscode.window.showErrorMessage(`Failed to delete feature: ${err}`)
597
+ }
598
+ }
599
+
600
+ private async _updateFeature(featureId: string, updates: Partial<Feature>): Promise<void> {
601
+ const feature = this._features.find(f => f.id === featureId)
602
+ if (!feature) return
603
+
604
+ const featuresDir = this._getWorkspaceFeaturesDir()
605
+ if (!featuresDir) return
606
+
607
+ const oldStatus = feature.status
608
+ const oldTitle = getTitleFromContent(feature.content)
609
+
610
+ // Merge updates
611
+ Object.assign(feature, updates)
612
+ feature.modified = new Date().toISOString()
613
+ if (oldStatus !== feature.status) {
614
+ feature.completedAt = feature.status === 'done' ? new Date().toISOString() : null
615
+ }
616
+
617
+ // Persist to file
618
+ const content = this._serializeFeature(feature)
619
+ await vscode.workspace.fs.writeFile(vscode.Uri.file(feature.filePath), new TextEncoder().encode(content))
620
+
621
+ // Rename file if title changed (numeric-ID cards only)
622
+ const newTitle = getTitleFromContent(feature.content)
623
+ const numId = extractNumericId(feature.id)
624
+ if (numId !== null && newTitle !== oldTitle) {
625
+ const newFilename = generateFeatureFilename(numId, newTitle)
626
+ this._migrating = true
627
+ try {
628
+ feature.filePath = await renameFeatureFile(feature.filePath, newFilename)
629
+ } catch { /* retry next load */ } finally { this._migrating = false }
630
+ }
631
+
632
+ // Move file when status changes
633
+ if (oldStatus !== feature.status) {
634
+ this._migrating = true
635
+ try {
636
+ const newPath = await moveFeatureFile(feature.filePath, featuresDir, feature.status, feature.attachments)
637
+ feature.filePath = newPath
638
+ } catch {
639
+ // Move failed; file stays in old folder, will reconcile on next load
640
+ } finally {
641
+ this._migrating = false
642
+ }
643
+ }
644
+
645
+ this._sendFeaturesToWebview()
646
+ }
647
+
648
+ private async _openFeatureInNativeEditor(featureId: string): Promise<void> {
649
+ const feature = this._features.find(f => f.id === featureId)
650
+ if (!feature) return
651
+
652
+ // Use a fixed column beside the panel so repeated clicks reuse the same split
653
+ const panelColumn = this._panel.viewColumn ?? vscode.ViewColumn.One
654
+ const targetColumn = panelColumn === vscode.ViewColumn.One ? vscode.ViewColumn.Two : vscode.ViewColumn.Beside
655
+
656
+ const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(feature.filePath))
657
+ await vscode.window.showTextDocument(doc, { viewColumn: targetColumn, preview: true })
658
+ }
659
+
660
+ private async _sendFeatureContent(featureId: string): Promise<void> {
661
+ const feature = this._features.find(f => f.id === featureId)
662
+ if (!feature) return
663
+
664
+ this._currentEditingFeatureId = featureId
665
+
666
+ const frontmatter: FeatureFrontmatter = {
667
+ id: feature.id,
668
+ status: feature.status,
669
+ priority: feature.priority,
670
+ assignee: feature.assignee,
671
+ dueDate: feature.dueDate,
672
+ created: feature.created,
673
+ modified: feature.modified,
674
+ completedAt: feature.completedAt,
675
+ labels: feature.labels,
676
+ attachments: feature.attachments,
677
+ order: feature.order
678
+ }
679
+
680
+ this._panel.webview.postMessage({
681
+ type: 'featureContent',
682
+ featureId: feature.id,
683
+ content: feature.content,
684
+ frontmatter
685
+ })
686
+ }
687
+
688
+ private async _saveFeatureContent(
689
+ featureId: string,
690
+ content: string,
691
+ frontmatter: FeatureFrontmatter
692
+ ): Promise<void> {
693
+ const feature = this._features.find(f => f.id === featureId)
694
+ if (!feature) return
695
+
696
+ const featuresDir = this._getWorkspaceFeaturesDir()
697
+ if (!featuresDir) return
698
+
699
+ const oldStatus = feature.status
700
+ const oldTitle = getTitleFromContent(feature.content)
701
+
702
+ // Update feature in memory
703
+ feature.content = content
704
+ feature.status = frontmatter.status
705
+ feature.priority = frontmatter.priority
706
+ feature.assignee = frontmatter.assignee
707
+ feature.dueDate = frontmatter.dueDate
708
+ feature.labels = frontmatter.labels
709
+ feature.attachments = frontmatter.attachments || feature.attachments || []
710
+ feature.modified = new Date().toISOString()
711
+ if (oldStatus !== feature.status) {
712
+ feature.completedAt = feature.status === 'done' ? new Date().toISOString() : null
713
+ }
714
+
715
+ // Save to file
716
+ const fileContent = this._serializeFeature(feature)
717
+ this._lastWrittenContent = fileContent
718
+ await vscode.workspace.fs.writeFile(vscode.Uri.file(feature.filePath), new TextEncoder().encode(fileContent))
719
+
720
+ // Rename file if title changed (numeric-ID cards only)
721
+ const saveNewTitle = getTitleFromContent(feature.content)
722
+ const saveNumId = extractNumericId(feature.id)
723
+ if (saveNumId !== null && saveNewTitle !== oldTitle) {
724
+ const newFilename = generateFeatureFilename(saveNumId, saveNewTitle)
725
+ this._migrating = true
726
+ try {
727
+ feature.filePath = await renameFeatureFile(feature.filePath, newFilename)
728
+ } catch { /* retry next load */ } finally { this._migrating = false }
729
+ }
730
+
731
+ // Move file when status changes
732
+ if (oldStatus !== feature.status) {
733
+ this._migrating = true
734
+ try {
735
+ const newPath = await moveFeatureFile(feature.filePath, featuresDir, feature.status, feature.attachments)
736
+ feature.filePath = newPath
737
+ } catch {
738
+ // Move failed; file stays in old folder, will reconcile on next load
739
+ } finally {
740
+ this._migrating = false
741
+ }
742
+ }
743
+
744
+ // Update all features in webview
745
+ this._sendFeaturesToWebview()
746
+ }
747
+
748
+ private async _addAttachment(featureId: string): Promise<void> {
749
+ const feature = this._features.find(f => f.id === featureId)
750
+ if (!feature) return
751
+
752
+ const uris = await vscode.window.showOpenDialog({
753
+ canSelectMany: true,
754
+ openLabel: 'Attach',
755
+ title: 'Select files to attach'
756
+ })
757
+ if (!uris || uris.length === 0) return
758
+
759
+ const featureDir = path.dirname(feature.filePath)
760
+
761
+ for (const uri of uris) {
762
+ const fileName = path.basename(uri.fsPath)
763
+ const destPath = path.join(featureDir, fileName)
764
+
765
+ // If file is not already in the feature directory, copy it
766
+ if (path.dirname(uri.fsPath) !== featureDir) {
767
+ await vscode.workspace.fs.copy(uri, vscode.Uri.file(destPath), { overwrite: true })
768
+ }
769
+
770
+ // Add to attachments if not already present
771
+ if (!feature.attachments.includes(fileName)) {
772
+ feature.attachments.push(fileName)
773
+ }
774
+ }
775
+
776
+ feature.modified = new Date().toISOString()
777
+ const fileContent = this._serializeFeature(feature)
778
+ this._lastWrittenContent = fileContent
779
+ await vscode.workspace.fs.writeFile(vscode.Uri.file(feature.filePath), new TextEncoder().encode(fileContent))
780
+
781
+ this._sendFeaturesToWebview()
782
+ // Refresh the editor with updated frontmatter
783
+ if (this._currentEditingFeatureId === featureId) {
784
+ await this._sendFeatureContent(featureId)
785
+ }
786
+ }
787
+
788
+ private async _openAttachment(featureId: string, attachment: string): Promise<void> {
789
+ const feature = this._features.find(f => f.id === featureId)
790
+ if (!feature) return
791
+
792
+ const featureDir = path.dirname(feature.filePath)
793
+ const attachmentPath = path.resolve(featureDir, attachment)
794
+
795
+ try {
796
+ await vscode.workspace.fs.stat(vscode.Uri.file(attachmentPath))
797
+ const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(attachmentPath))
798
+ await vscode.window.showTextDocument(doc, { viewColumn: vscode.ViewColumn.Beside })
799
+ } catch {
800
+ // For binary files or files that can't be opened as text, reveal in OS
801
+ await vscode.env.openExternal(vscode.Uri.file(attachmentPath))
802
+ }
803
+ }
804
+
805
+ private async _removeAttachment(featureId: string, attachment: string): Promise<void> {
806
+ const feature = this._features.find(f => f.id === featureId)
807
+ if (!feature) return
808
+
809
+ feature.attachments = feature.attachments.filter(a => a !== attachment)
810
+ feature.modified = new Date().toISOString()
811
+
812
+ const fileContent = this._serializeFeature(feature)
813
+ this._lastWrittenContent = fileContent
814
+ await vscode.workspace.fs.writeFile(vscode.Uri.file(feature.filePath), new TextEncoder().encode(fileContent))
815
+
816
+ this._sendFeaturesToWebview()
817
+ if (this._currentEditingFeatureId === featureId) {
818
+ await this._sendFeatureContent(featureId)
819
+ }
820
+ }
821
+
822
+ private async _startWithAI(
823
+ agent?: 'claude' | 'codex' | 'opencode',
824
+ permissionMode?: 'default' | 'plan' | 'acceptEdits' | 'bypassPermissions'
825
+ ): Promise<void> {
826
+ // Find the currently editing feature
827
+ const feature = this._features.find(f => f.id === this._currentEditingFeatureId)
828
+ if (!feature) {
829
+ vscode.window.showErrorMessage('No feature selected')
830
+ return
831
+ }
832
+
833
+ // Parse title from the first # heading in content
834
+ const titleMatch = feature.content.match(/^#\s+(.+)$/m)
835
+ const title = titleMatch ? titleMatch[1].trim() : getTitleFromContent(feature.content)
836
+
837
+ const labels = feature.labels.length > 0 ? ` [${feature.labels.join(', ')}]` : ''
838
+ const description = feature.content.replace(/\n/g, ' ').replace(/\s+/g, ' ').trim()
839
+ const shortDesc = description.length > 200 ? description.substring(0, 200) + '...' : description
840
+
841
+ const prompt = `Implement this feature: "${title}" (${feature.priority} priority)${labels}. ${shortDesc} See full details in: ${feature.filePath}`
842
+
843
+ // Use provided agent or fall back to config
844
+ const aiRoot = this._getWorkspaceRoot()
845
+ const aiConfig = aiRoot ? readConfig(aiRoot) : DEFAULT_CONFIG
846
+ const selectedAgent = agent || aiConfig.aiAgent || 'claude'
847
+ const selectedPermissionMode = permissionMode || 'default'
848
+
849
+ let command: string
850
+ const escapedPrompt = prompt.replace(/"/g, '\\"')
851
+
852
+ switch (selectedAgent) {
853
+ case 'claude': {
854
+ const permissionFlag = selectedPermissionMode !== 'default' ? ` --permission-mode ${selectedPermissionMode}` : ''
855
+ command = `claude${permissionFlag} "${escapedPrompt}"`
856
+ break
857
+ }
858
+ case 'codex': {
859
+ const approvalMap: Record<string, string> = {
860
+ 'default': 'suggest',
861
+ 'plan': 'suggest',
862
+ 'acceptEdits': 'auto-edit',
863
+ 'bypassPermissions': 'full-auto'
864
+ }
865
+ const approvalMode = approvalMap[selectedPermissionMode] || 'suggest'
866
+ command = `codex --approval-mode ${approvalMode} "${escapedPrompt}"`
867
+ break
868
+ }
869
+ case 'opencode': {
870
+ command = `opencode "${escapedPrompt}"`
871
+ break
872
+ }
873
+ default:
874
+ command = `claude "${escapedPrompt}"`
875
+ }
876
+
877
+ const agentNames: Record<string, string> = {
878
+ 'claude': 'Claude Code',
879
+ 'codex': 'Codex',
880
+ 'opencode': 'OpenCode'
881
+ }
882
+ const terminal = vscode.window.createTerminal({
883
+ name: agentNames[selectedAgent] || 'AI Agent',
884
+ cwd: vscode.workspace.workspaceFolders?.[0]?.uri.fsPath
885
+ })
886
+ terminal.show()
887
+ terminal.sendText(command)
888
+ }
889
+
890
+ private _saveSettings(settings: CardDisplaySettings): void {
891
+ const root = this._getWorkspaceRoot()
892
+ if (!root) return
893
+ const config = readConfig(root)
894
+ const updated = settingsToConfig(config, settings)
895
+ writeConfig(root, updated)
896
+ this._sendFeaturesToWebview()
897
+ }
898
+
899
+ private _getColumns(): KanbanColumn[] {
900
+ const root = this._getWorkspaceRoot()
901
+ if (!root) return [...DEFAULT_CONFIG.columns]
902
+ const config = readConfig(root)
903
+ return config.columns.map(c => ({ ...c }))
904
+ }
905
+
906
+ private _saveColumns(columns: KanbanColumn[]): void {
907
+ const root = this._getWorkspaceRoot()
908
+ if (!root) return
909
+ const config = readConfig(root)
910
+ config.columns = columns
911
+ writeConfig(root, config)
912
+ this._sendFeaturesToWebviewWithColumns(columns)
913
+ }
914
+
915
+ private _sendFeaturesToWebviewWithColumns(columns: KanbanColumn[]): void {
916
+ const root = this._getWorkspaceRoot()
917
+ const config = root ? readConfig(root) : { ...DEFAULT_CONFIG }
918
+ const settings = configToSettings(config)
919
+
920
+ // Override showBuildWithAI based on VS Code's AI feature toggle
921
+ const aiDisabled = vscode.workspace.getConfiguration('chat').get<boolean>('disableAIFeatures', false)
922
+ if (aiDisabled) {
923
+ settings.showBuildWithAI = false
924
+ }
925
+
926
+ this._panel.webview.postMessage({
927
+ type: 'init',
928
+ features: this._features,
929
+ columns,
930
+ settings
931
+ })
932
+ }
933
+
934
+ private async _addColumn(column: { name: string; color: string }): Promise<void> {
935
+ const columns = this._getColumns()
936
+ const id = column.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
937
+ let uniqueId = id
938
+ let counter = 1
939
+ while (columns.some(c => c.id === uniqueId)) {
940
+ uniqueId = `${id}-${counter++}`
941
+ }
942
+ columns.push({ id: uniqueId, name: column.name, color: column.color })
943
+ await this._saveColumns(columns)
944
+ }
945
+
946
+ private async _editColumn(columnId: string, updates: { name: string; color: string }): Promise<void> {
947
+ const columns = this._getColumns()
948
+ if (!columns.some(c => c.id === columnId)) return
949
+ const updatedColumns = columns.map(c =>
950
+ c.id === columnId ? { ...c, name: updates.name, color: updates.color } : c
951
+ )
952
+ await this._saveColumns(updatedColumns)
953
+ }
954
+
955
+ private async _removeColumn(columnId: string): Promise<void> {
956
+ const columns = this._getColumns()
957
+ const hasFeatures = this._features.some(f => f.status === columnId)
958
+ if (hasFeatures) {
959
+ vscode.window.showWarningMessage(`Cannot remove list "${columnId}" because it still contains features. Move or delete them first.`)
960
+ return
961
+ }
962
+ const updated = columns.filter(c => c.id !== columnId)
963
+ if (updated.length === 0) {
964
+ vscode.window.showWarningMessage('Cannot remove the last list.')
965
+ return
966
+ }
967
+ await this._saveColumns(updated)
968
+ }
969
+
970
+ private _sendFeaturesToWebview(): void {
971
+ this._sendFeaturesToWebviewWithColumns(this._getColumns())
972
+ }
973
+ }