diff --git a/.ai-team/agents/rusty/history.md b/.ai-team/agents/rusty/history.md index 0c2cbca..140dee8 100644 --- a/.ai-team/agents/rusty/history.md +++ b/.ai-team/agents/rusty/history.md @@ -48,3 +48,11 @@ The SquadUI extension emerged from initial scaffolding through a rapid sequence ### Team Update: 2026-02-23 - Standup Report Enhancements & Fork-Aware Issues **Team update (2026-02-23):** Two decisions merged: (1) Standup report issue linkification (#N in AI summaries become clickable GitHub links) with escape-then-linkify pipeline to prevent injection; (2) Velocity chart legend repositioning below canvas for better viewport + accessibility. Fork-aware issue fetching auto-detects upstream repos via GitHub API, with manual override via team.md. decided by @copilot + Rusty +### Charter Editor Command (2026-02-23) +- **Issue:** #72 — Markdown Charter Editor +- **What:** Added `squadui.editCharter` command that opens agent charters in VS Code's text editor with markdown preview side-by-side, enabling in-place editing. +- **Changes:** `package.json` (command + context menu + palette), `src/extension.ts` (command handler), new test file `editCharterCommand.test.ts` (3 tests). +- **Pattern:** Command accepts both string member name and tree item object (for context menu). Uses same slug generation as `viewCharter`. Opens with `preview: false` so edits persist, then fires `markdown.showPreviewToSide` for live preview. +- **Key difference from viewCharter:** `viewCharter` opens read-only markdown preview; `editCharter` opens the text editor (editable) + preview side-by-side. +- **File watcher coverage:** Existing `.ai-team/` file watchers auto-refresh the tree view when charter files are saved — no additional watcher needed. + diff --git a/package.json b/package.json index f8a0c1d..0bb5389 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,12 @@ "category": "Squad", "icon": "$(open-preview)" }, + { + "command": "squadui.editCharter", + "title": "Edit Charter", + "category": "Squad", + "icon": "$(edit)" + }, { "command": "squadui.removeMember", "title": "Remove Team Member", @@ -172,6 +178,11 @@ } ], "view/item/context": [ + { + "command": "squadui.editCharter", + "when": "view == squadTeam && viewItem == member", + "group": "inline" + }, { "command": "squadui.removeMember", "when": "view == squadTeam && viewItem == member", @@ -194,6 +205,10 @@ } ], "commandPalette": [ + { + "command": "squadui.editCharter", + "when": "false" + }, { "command": "squadui.showWorkDetails", "when": "false" diff --git a/src/extension.ts b/src/extension.ts index d21467f..4875f10 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -221,6 +221,32 @@ export function activate(context: vscode.ExtensionContext): void { }) ); + // Register edit charter command — opens charter in text editor + markdown preview side-by-side + context.subscriptions.push( + vscode.commands.registerCommand('squadui.editCharter', async (rawName?: unknown) => { + let memberName: string = ''; + if (typeof rawName === 'string') { + memberName = rawName; + } else if (typeof rawName === 'object' && rawName !== null) { + memberName = String((rawName as any).label || (rawName as any).memberId || (rawName as any).name || ''); + } + if (!memberName) { + vscode.window.showWarningMessage('No member selected'); + return; + } + const slug = memberName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, ''); + const charterPath = path.join(currentRoot, squadFolderName, 'agents', slug, 'charter.md'); + if (!fs.existsSync(charterPath)) { + vscode.window.showWarningMessage(`Charter not found for ${memberName}`); + return; + } + const uri = vscode.Uri.file(charterPath); + const doc = await vscode.workspace.openTextDocument(uri); + await vscode.window.showTextDocument(doc, { preview: false }); + await vscode.commands.executeCommand('markdown.showPreviewToSide', uri); + }) + ); + // Register squad init command let allocationPollInterval: ReturnType | undefined; let initInProgress = false; diff --git a/src/test/suite/editCharterCommand.test.ts b/src/test/suite/editCharterCommand.test.ts new file mode 100644 index 0000000..9c90342 --- /dev/null +++ b/src/test/suite/editCharterCommand.test.ts @@ -0,0 +1,220 @@ +/** + * Tests for the editCharter command (squadui.editCharter). + * + * The command opens a charter file in VS Code's text editor with + * markdown preview side-by-side for in-place editing. + */ + +import * as assert from 'assert'; +import * as path from 'path'; +import * as fs from 'fs'; +import * as vscode from 'vscode'; + +suite('EditCharterCommand', () => { + // ─── Command Registration ────────────────────────────────────────────── + + suite('Command Registration', () => { + test('editCharter command is registered', async function () { + const extension = vscode.extensions.getExtension('csharpfritz.squadui'); + if (!extension || !extension.isActive || !vscode.workspace.workspaceFolders?.length) { + this.skip(); + return; + } + + const commands = await vscode.commands.getCommands(true); + assert.ok( + commands.includes('squadui.editCharter'), + 'squadui.editCharter command should be registered' + ); + }); + + test('editCharter command is declared in package.json', async () => { + const extension = vscode.extensions.getExtension('csharpfritz.squadui'); + if (extension) { + const packageJson = extension.packageJSON; + const cmds = packageJson?.contributes?.commands || []; + const hasCmd = cmds.some( + (c: { command: string }) => c.command === 'squadui.editCharter' + ); + assert.ok(hasCmd, 'editCharter should be declared in package.json commands'); + } + }); + + test('editCharter has context menu entry for member items', async () => { + const extension = vscode.extensions.getExtension('csharpfritz.squadui'); + if (extension) { + const packageJson = extension.packageJSON; + const menus = packageJson?.contributes?.menus?.['view/item/context'] || []; + const hasMenu = menus.some( + (m: { command: string; when: string }) => + m.command === 'squadui.editCharter' && + m.when.includes('viewItem == member') + ); + assert.ok(hasMenu, 'editCharter should have a context menu entry for member items'); + } + }); + }); + + // ─── Opening Charter for Editing ─────────────────────────────────────── + + suite('Opening Charter for Editing', () => { + test('opens charter.md in text editor for a valid member', async function () { + const commands = await vscode.commands.getCommands(true); + if (!commands.includes('squadui.editCharter')) { + this.skip(); + return; + } + + const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + if (!workspaceRoot) { + this.skip(); + return; + } + + const agentDir = path.join(workspaceRoot, '.ai-team', 'agents', 'edittest'); + const charterPath = path.join(agentDir, 'charter.md'); + const charterExistedBefore = fs.existsSync(charterPath); + + try { + if (!charterExistedBefore) { + fs.mkdirSync(agentDir, { recursive: true }); + fs.writeFileSync(charterPath, '# EditTest — Tester\n\nTest charter content.\n', 'utf-8'); + } + + await vscode.commands.executeCommand('squadui.editCharter', 'EditTest'); + await new Promise(resolve => setTimeout(resolve, 500)); + + const activeEditor = vscode.window.activeTextEditor; + if (activeEditor) { + const activeFilePath = activeEditor.document.uri.fsPath; + assert.ok( + activeFilePath.endsWith(path.join('agents', 'edittest', 'charter.md')), + `Active editor should show charter.md, got: ${activeFilePath}` + ); + } + } finally { + if (!charterExistedBefore) { + try { + fs.rmSync(agentDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + } + } + }); + + test('accepts tree item object with label property', async function () { + const commands = await vscode.commands.getCommands(true); + if (!commands.includes('squadui.editCharter')) { + this.skip(); + return; + } + + const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + if (!workspaceRoot) { + this.skip(); + return; + } + + const agentDir = path.join(workspaceRoot, '.ai-team', 'agents', 'edittest'); + const charterPath = path.join(agentDir, 'charter.md'); + const charterExistedBefore = fs.existsSync(charterPath); + + try { + if (!charterExistedBefore) { + fs.mkdirSync(agentDir, { recursive: true }); + fs.writeFileSync(charterPath, '# EditTest — Tester\n\nTest charter content.\n', 'utf-8'); + } + + // Simulate tree item object (context menu passes tree item) + await vscode.commands.executeCommand('squadui.editCharter', { label: 'EditTest', memberId: 'EditTest' }); + await new Promise(resolve => setTimeout(resolve, 500)); + + const activeEditor = vscode.window.activeTextEditor; + if (activeEditor) { + const activeFilePath = activeEditor.document.uri.fsPath; + assert.ok( + activeFilePath.endsWith(path.join('agents', 'edittest', 'charter.md')), + `Should open charter from tree item object, got: ${activeFilePath}` + ); + } + } finally { + if (!charterExistedBefore) { + try { + fs.rmSync(agentDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + } + } + }); + }); + + // ─── Warnings ────────────────────────────────────────────────────────── + + suite('Warnings', () => { + test('shows warning when charter not found', async function () { + const commands = await vscode.commands.getCommands(true); + if (!commands.includes('squadui.editCharter')) { + this.skip(); + return; + } + + if (!vscode.workspace.workspaceFolders?.length) { + this.skip(); + return; + } + + const origWarn = vscode.window.showWarningMessage; + let warningShown = false; + let warningMessage = ''; + + try { + (vscode.window as any).showWarningMessage = async (msg: string, ..._items: any[]) => { + warningShown = true; + warningMessage = msg; + return undefined; + }; + + await vscode.commands.executeCommand('squadui.editCharter', 'NonExistentMember99999'); + + assert.ok(warningShown, 'Should show a warning when charter file does not exist'); + assert.ok( + warningMessage.toLowerCase().includes('charter') || + warningMessage.toLowerCase().includes('not found'), + `Warning should mention charter or not found, got: "${warningMessage}"` + ); + } finally { + (vscode.window as any).showWarningMessage = origWarn; + } + }); + + test('shows warning when no member selected', async function () { + const commands = await vscode.commands.getCommands(true); + if (!commands.includes('squadui.editCharter')) { + this.skip(); + return; + } + + if (!vscode.workspace.workspaceFolders?.length) { + this.skip(); + return; + } + + const origWarn = vscode.window.showWarningMessage; + let warningShown = false; + + try { + (vscode.window as any).showWarningMessage = async (_message: string, ..._items: any[]) => { + warningShown = true; + return undefined; + }; + + await vscode.commands.executeCommand('squadui.editCharter', ''); + assert.ok(warningShown, 'Should show a warning when no member is selected'); + } finally { + (vscode.window as any).showWarningMessage = origWarn; + } + }); + }); +});