Repository: YishenTu/claudian Branch: main Commit: d171f1781ee6 Files: 375 Total size: 3.7 MB Directory structure: gitextract_g0fawvw1/ ├── .eslintrc.cjs ├── .github/ │ └── workflows/ │ ├── ci.yml │ ├── claude-code-review.yml │ ├── claude.yml │ ├── duplicate-issues.yml │ ├── release.yml │ └── stale.yml ├── .gitignore ├── .npmrc ├── AGENTS.md ├── CLAUDE.md ├── LICENSE ├── README.md ├── esbuild.config.mjs ├── jest.config.js ├── manifest.json ├── package.json ├── scripts/ │ ├── build-css.mjs │ ├── build.mjs │ ├── postinstall.mjs │ ├── run-jest.js │ └── sync-version.js ├── src/ │ ├── core/ │ │ ├── CLAUDE.md │ │ ├── agent/ │ │ │ ├── ClaudianService.ts │ │ │ ├── MessageChannel.ts │ │ │ ├── QueryOptionsBuilder.ts │ │ │ ├── SessionManager.ts │ │ │ ├── customSpawn.ts │ │ │ ├── index.ts │ │ │ └── types.ts │ │ ├── agents/ │ │ │ ├── AgentManager.ts │ │ │ ├── AgentStorage.ts │ │ │ └── index.ts │ │ ├── commands/ │ │ │ ├── builtInCommands.ts │ │ │ └── index.ts │ │ ├── hooks/ │ │ │ ├── SecurityHooks.ts │ │ │ ├── SubagentHooks.ts │ │ │ └── index.ts │ │ ├── mcp/ │ │ │ ├── McpServerManager.ts │ │ │ ├── McpTester.ts │ │ │ └── index.ts │ │ ├── plugins/ │ │ │ ├── PluginManager.ts │ │ │ └── index.ts │ │ ├── prompts/ │ │ │ ├── inlineEdit.ts │ │ │ ├── instructionRefine.ts │ │ │ ├── mainAgent.ts │ │ │ └── titleGeneration.ts │ │ ├── sdk/ │ │ │ ├── index.ts │ │ │ ├── toolResultContent.ts │ │ │ ├── transformSDKMessage.ts │ │ │ ├── typeGuards.ts │ │ │ └── types.ts │ │ ├── security/ │ │ │ ├── ApprovalManager.ts │ │ │ ├── BashPathValidator.ts │ │ │ ├── BlocklistChecker.ts │ │ │ └── index.ts │ │ ├── storage/ │ │ │ ├── AgentVaultStorage.ts │ │ │ ├── CCSettingsStorage.ts │ │ │ ├── ClaudianSettingsStorage.ts │ │ │ ├── McpStorage.ts │ │ │ ├── SessionStorage.ts │ │ │ ├── SkillStorage.ts │ │ │ ├── SlashCommandStorage.ts │ │ │ ├── StorageService.ts │ │ │ ├── VaultFileAdapter.ts │ │ │ ├── index.ts │ │ │ └── migrationConstants.ts │ │ ├── tools/ │ │ │ ├── index.ts │ │ │ ├── todo.ts │ │ │ ├── toolIcons.ts │ │ │ ├── toolInput.ts │ │ │ └── toolNames.ts │ │ └── types/ │ │ ├── agent.ts │ │ ├── chat.ts │ │ ├── diff.ts │ │ ├── index.ts │ │ ├── mcp.ts │ │ ├── models.ts │ │ ├── plugins.ts │ │ ├── sdk.ts │ │ ├── settings.ts │ │ └── tools.ts │ ├── features/ │ │ ├── chat/ │ │ │ ├── CLAUDE.md │ │ │ ├── ClaudianView.ts │ │ │ ├── constants.ts │ │ │ ├── controllers/ │ │ │ │ ├── BrowserSelectionController.ts │ │ │ │ ├── CanvasSelectionController.ts │ │ │ │ ├── ConversationController.ts │ │ │ │ ├── InputController.ts │ │ │ │ ├── NavigationController.ts │ │ │ │ ├── SelectionController.ts │ │ │ │ ├── StreamController.ts │ │ │ │ ├── contextRowVisibility.ts │ │ │ │ └── index.ts │ │ │ ├── rendering/ │ │ │ │ ├── DiffRenderer.ts │ │ │ │ ├── InlineAskUserQuestion.ts │ │ │ │ ├── InlineExitPlanMode.ts │ │ │ │ ├── MessageRenderer.ts │ │ │ │ ├── SubagentRenderer.ts │ │ │ │ ├── ThinkingBlockRenderer.ts │ │ │ │ ├── TodoListRenderer.ts │ │ │ │ ├── ToolCallRenderer.ts │ │ │ │ ├── WriteEditRenderer.ts │ │ │ │ ├── collapsible.ts │ │ │ │ ├── index.ts │ │ │ │ └── todoUtils.ts │ │ │ ├── rewind.ts │ │ │ ├── services/ │ │ │ │ ├── BangBashService.ts │ │ │ │ ├── InstructionRefineService.ts │ │ │ │ ├── SubagentManager.ts │ │ │ │ └── TitleGenerationService.ts │ │ │ ├── state/ │ │ │ │ ├── ChatState.ts │ │ │ │ ├── index.ts │ │ │ │ └── types.ts │ │ │ ├── tabs/ │ │ │ │ ├── Tab.ts │ │ │ │ ├── TabBar.ts │ │ │ │ ├── TabManager.ts │ │ │ │ ├── index.ts │ │ │ │ └── types.ts │ │ │ └── ui/ │ │ │ ├── BangBashModeManager.ts │ │ │ ├── FileContext.ts │ │ │ ├── ImageContext.ts │ │ │ ├── InputToolbar.ts │ │ │ ├── InstructionModeManager.ts │ │ │ ├── NavigationSidebar.ts │ │ │ ├── StatusPanel.ts │ │ │ ├── file-context/ │ │ │ │ ├── state/ │ │ │ │ │ └── FileContextState.ts │ │ │ │ └── view/ │ │ │ │ └── FileChipsView.ts │ │ │ └── index.ts │ │ ├── inline-edit/ │ │ │ ├── InlineEditService.ts │ │ │ └── ui/ │ │ │ └── InlineEditModal.ts │ │ └── settings/ │ │ ├── ClaudianSettings.ts │ │ ├── keyboardNavigation.ts │ │ └── ui/ │ │ ├── AgentSettings.ts │ │ ├── EnvSnippetManager.ts │ │ ├── McpServerModal.ts │ │ ├── McpSettingsManager.ts │ │ ├── McpTestModal.ts │ │ ├── PluginSettingsManager.ts │ │ └── SlashCommandSettings.ts │ ├── i18n/ │ │ ├── constants.ts │ │ ├── i18n.ts │ │ ├── index.ts │ │ ├── locales/ │ │ │ ├── de.json │ │ │ ├── en.json │ │ │ ├── es.json │ │ │ ├── fr.json │ │ │ ├── ja.json │ │ │ ├── ko.json │ │ │ ├── pt.json │ │ │ ├── ru.json │ │ │ ├── zh-CN.json │ │ │ └── zh-TW.json │ │ └── types.ts │ ├── main.ts │ ├── shared/ │ │ ├── components/ │ │ │ ├── ResumeSessionDropdown.ts │ │ │ ├── SelectableDropdown.ts │ │ │ ├── SelectionHighlight.ts │ │ │ └── SlashCommandDropdown.ts │ │ ├── icons.ts │ │ ├── index.ts │ │ ├── mention/ │ │ │ ├── MentionDropdownController.ts │ │ │ ├── VaultMentionCache.ts │ │ │ ├── VaultMentionDataProvider.ts │ │ │ └── types.ts │ │ └── modals/ │ │ ├── ConfirmModal.ts │ │ ├── ForkTargetModal.ts │ │ └── InstructionConfirmModal.ts │ ├── style/ │ │ ├── CLAUDE.md │ │ ├── accessibility.css │ │ ├── base/ │ │ │ ├── animations.css │ │ │ ├── container.css │ │ │ └── variables.css │ │ ├── components/ │ │ │ ├── code.css │ │ │ ├── context-footer.css │ │ │ ├── header.css │ │ │ ├── history.css │ │ │ ├── input.css │ │ │ ├── messages.css │ │ │ ├── nav-sidebar.css │ │ │ ├── status-panel.css │ │ │ ├── subagent.css │ │ │ ├── tabs.css │ │ │ ├── thinking.css │ │ │ └── toolcalls.css │ │ ├── features/ │ │ │ ├── ask-user-question.css │ │ │ ├── diff.css │ │ │ ├── file-context.css │ │ │ ├── file-link.css │ │ │ ├── image-context.css │ │ │ ├── image-embed.css │ │ │ ├── image-modal.css │ │ │ ├── inline-edit.css │ │ │ ├── plan-mode.css │ │ │ ├── resume-session.css │ │ │ └── slash-commands.css │ │ ├── index.css │ │ ├── modals/ │ │ │ ├── fork-target.css │ │ │ ├── instruction.css │ │ │ └── mcp-modal.css │ │ ├── settings/ │ │ │ ├── agent-settings.css │ │ │ ├── base.css │ │ │ ├── env-snippets.css │ │ │ ├── mcp-settings.css │ │ │ ├── plugin-settings.css │ │ │ └── slash-settings.css │ │ └── toolbar/ │ │ ├── external-context.css │ │ ├── mcp-selector.css │ │ ├── model-selector.css │ │ ├── permission-toggle.css │ │ └── thinking-selector.css │ └── utils/ │ ├── agent.ts │ ├── browser.ts │ ├── canvas.ts │ ├── claudeCli.ts │ ├── context.ts │ ├── contextMentionResolver.ts │ ├── date.ts │ ├── diff.ts │ ├── editor.ts │ ├── env.ts │ ├── externalContext.ts │ ├── externalContextScanner.ts │ ├── fileLink.ts │ ├── frontmatter.ts │ ├── imageEmbed.ts │ ├── inlineEdit.ts │ ├── interrupt.ts │ ├── markdown.ts │ ├── mcp.ts │ ├── path.ts │ ├── sdkSession.ts │ ├── session.ts │ ├── slashCommand.ts │ └── subagentJsonl.ts ├── tests/ │ ├── __mocks__/ │ │ ├── claude-agent-sdk.ts │ │ └── obsidian.ts │ ├── helpers/ │ │ ├── mockElement.ts │ │ └── sdkMessages.ts │ ├── integration/ │ │ ├── core/ │ │ │ ├── agent/ │ │ │ │ └── ClaudianService.test.ts │ │ │ └── mcp/ │ │ │ └── mcp.test.ts │ │ ├── features/ │ │ │ └── chat/ │ │ │ └── imagePersistence.test.ts │ │ └── main.test.ts │ ├── tsconfig.json │ └── unit/ │ ├── core/ │ │ ├── agent/ │ │ │ ├── ClaudianService.test.ts │ │ │ ├── MessageChannel.test.ts │ │ │ ├── QueryOptionsBuilder.test.ts │ │ │ ├── SessionManager.test.ts │ │ │ ├── customSpawn.test.ts │ │ │ ├── index.test.ts │ │ │ └── types.test.ts │ │ ├── agents/ │ │ │ ├── AgentManager.test.ts │ │ │ ├── AgentStorage.test.ts │ │ │ └── index.test.ts │ │ ├── commands/ │ │ │ └── builtInCommands.test.ts │ │ ├── hooks/ │ │ │ ├── SecurityHooks.test.ts │ │ │ └── SubagentHooks.test.ts │ │ ├── mcp/ │ │ │ ├── McpServerManager.test.ts │ │ │ ├── McpTester.test.ts │ │ │ └── createNodeFetch.test.ts │ │ ├── plugins/ │ │ │ ├── PluginManager.test.ts │ │ │ └── index.test.ts │ │ ├── prompts/ │ │ │ ├── instructionRefine.test.ts │ │ │ ├── systemPrompt.test.ts │ │ │ └── titleGeneration.test.ts │ │ ├── sdk/ │ │ │ ├── transformSDKMessage.test.ts │ │ │ └── typeGuards.test.ts │ │ ├── security/ │ │ │ ├── ApprovalManager.test.ts │ │ │ ├── BashPathValidator.test.ts │ │ │ └── BlocklistChecker.test.ts │ │ ├── storage/ │ │ │ ├── AgentVaultStorage.test.ts │ │ │ ├── CCSettingsStorage.test.ts │ │ │ ├── ClaudianSettingsStorage.test.ts │ │ │ ├── McpStorage.test.ts │ │ │ ├── SessionStorage.test.ts │ │ │ ├── SkillStorage.test.ts │ │ │ ├── SlashCommandStorage.test.ts │ │ │ ├── VaultFileAdapter.test.ts │ │ │ ├── migrationConstants.test.ts │ │ │ ├── storage.test.ts │ │ │ ├── storageService.convenience.test.ts │ │ │ └── storageService.migration.test.ts │ │ ├── tools/ │ │ │ ├── todo.test.ts │ │ │ ├── toolIcons.test.ts │ │ │ ├── toolInput.test.ts │ │ │ └── toolNames.test.ts │ │ └── types/ │ │ ├── mcp.test.ts │ │ └── types.test.ts │ ├── features/ │ │ ├── chat/ │ │ │ ├── controllers/ │ │ │ │ ├── BrowserSelectionController.test.ts │ │ │ │ ├── CanvasSelectionController.test.ts │ │ │ │ ├── ConversationController.test.ts │ │ │ │ ├── InputController.test.ts │ │ │ │ ├── NavigationController.test.ts │ │ │ │ ├── SelectionController.test.ts │ │ │ │ ├── StreamController.test.ts │ │ │ │ ├── contextRowVisibility.test.ts │ │ │ │ └── index.test.ts │ │ │ ├── rendering/ │ │ │ │ ├── DiffRenderer.test.ts │ │ │ │ ├── InlineAskUserQuestion.test.ts │ │ │ │ ├── InlineExitPlanMode.test.ts │ │ │ │ ├── MessageRenderer.test.ts │ │ │ │ ├── SubagentRenderer.test.ts │ │ │ │ ├── ThinkingBlockRenderer.test.ts │ │ │ │ ├── TodoListRenderer.test.ts │ │ │ │ ├── ToolCallRenderer.test.ts │ │ │ │ ├── WriteEditRenderer.test.ts │ │ │ │ ├── collapsible.test.ts │ │ │ │ └── todoUtils.test.ts │ │ │ ├── rewind.test.ts │ │ │ ├── services/ │ │ │ │ ├── BangBashService.test.ts │ │ │ │ ├── InstructionRefineService.test.ts │ │ │ │ ├── SubagentManager.test.ts │ │ │ │ └── TitleGenerationService.test.ts │ │ │ ├── state/ │ │ │ │ └── ChatState.test.ts │ │ │ ├── tabs/ │ │ │ │ ├── Tab.test.ts │ │ │ │ ├── TabBar.test.ts │ │ │ │ ├── TabManager.test.ts │ │ │ │ └── index.test.ts │ │ │ └── ui/ │ │ │ ├── BangBashModeManager.test.ts │ │ │ ├── ExternalContextSelector.test.ts │ │ │ ├── FileContextManager.test.ts │ │ │ ├── ImageContext.test.ts │ │ │ ├── InputToolbar.test.ts │ │ │ ├── InstructionModeManager.test.ts │ │ │ ├── NavigationSidebar.test.ts │ │ │ ├── StatusPanel.test.ts │ │ │ └── file-context/ │ │ │ └── state/ │ │ │ └── FileContextState.test.ts │ │ ├── inline-edit/ │ │ │ ├── InlineEditService.test.ts │ │ │ └── ui/ │ │ │ ├── InlineEditModal.openAndWait.test.ts │ │ │ └── InlineEditModal.test.ts │ │ └── settings/ │ │ ├── AgentSettings.test.ts │ │ └── keyboardNavigation.test.ts │ ├── i18n/ │ │ ├── constants.test.ts │ │ ├── i18n.test.ts │ │ └── locales.test.ts │ ├── shared/ │ │ ├── components/ │ │ │ ├── ResumeSessionDropdown.test.ts │ │ │ ├── SelectableDropdown.test.ts │ │ │ └── SlashCommandDropdown.test.ts │ │ ├── index.test.ts │ │ ├── mention/ │ │ │ ├── MentionDropdownController.test.ts │ │ │ ├── VaultFileCache.test.ts │ │ │ ├── VaultFolderCache.test.ts │ │ │ └── VaultMentionDataProvider.test.ts │ │ └── modals/ │ │ ├── ConfirmModal.test.ts │ │ ├── ForkTargetModal.test.ts │ │ └── InstructionConfirmModal.test.ts │ └── utils/ │ ├── agent.test.ts │ ├── browser.test.ts │ ├── canvas.test.ts │ ├── claudeCli.test.ts │ ├── context.test.ts │ ├── contextMentionResolver.test.ts │ ├── date.test.ts │ ├── diff.test.ts │ ├── editor.test.ts │ ├── env.test.ts │ ├── externalContext.test.ts │ ├── externalContextScanner.test.ts │ ├── fileLink.dom.test.ts │ ├── fileLink.handler.test.ts │ ├── fileLink.test.ts │ ├── frontmatter.test.ts │ ├── imageEmbed.test.ts │ ├── inlineEdit.test.ts │ ├── interrupt.test.ts │ ├── markdown.test.ts │ ├── mcp.test.ts │ ├── path.test.ts │ ├── sdkSession.test.ts │ ├── session.test.ts │ ├── slashCommand.test.ts │ └── utils.test.ts ├── tsconfig.jest.json ├── tsconfig.json └── versions.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .eslintrc.cjs ================================================ /** @type {import('eslint').Linter.Config} */ module.exports = { root: true, ignorePatterns: ['dist/', 'node_modules/', 'coverage/', 'main.js'], env: { browser: true, node: true, es2021: true, }, parser: '@typescript-eslint/parser', parserOptions: { ecmaVersion: 'latest', sourceType: 'module', }, plugins: ['@typescript-eslint', 'jest', 'simple-import-sort'], extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'], rules: { '@typescript-eslint/consistent-type-imports': [ 'error', { prefer: 'type-imports', fixStyle: 'separate-type-imports' }, ], '@typescript-eslint/no-unused-vars': [ 'error', { args: 'none', ignoreRestSiblings: true }, ], '@typescript-eslint/no-explicit-any': 'off', 'simple-import-sort/imports': 'error', 'simple-import-sort/exports': 'error', }, overrides: [ { files: [ 'src/ClaudianService.ts', 'src/InlineEditService.ts', 'src/InstructionRefineService.ts', 'src/images/**/*.ts', 'src/prompt/**/*.ts', 'src/sdk/**/*.ts', 'src/security/**/*.ts', 'src/tools/**/*.ts', ], rules: { 'no-restricted-imports': [ 'error', { patterns: [ { group: ['./ui', './ui/*', '../ui', '../ui/*'], message: 'Service and shared modules must not import UI modules.', }, { group: ['./ClaudianView', '../ClaudianView'], message: 'Service and shared modules must not import the view.', }, ], }, ], }, }, { files: ['tests/**/*.ts'], env: { jest: true }, extends: ['plugin:jest/recommended'], rules: { '@typescript-eslint/no-explicit-any': 'off', }, }, ], }; ================================================ FILE: .github/workflows/ci.yml ================================================ name: CI on: push: branches: [main] pull_request: branches: [main] jobs: lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Setup Node 22 uses: actions/setup-node@v4 with: node-version: 22 cache: 'npm' - name: Install dependencies run: npm ci - name: Lint run: npm run lint typecheck: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Setup Node 22 uses: actions/setup-node@v4 with: node-version: 22 cache: 'npm' - name: Install dependencies run: npm ci - name: Typecheck run: npm run typecheck test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Setup Node 22 uses: actions/setup-node@v4 with: node-version: 22 cache: 'npm' - name: Install dependencies run: npm ci - name: Test run: npm run test ================================================ FILE: .github/workflows/claude-code-review.yml ================================================ name: Claude Code Review on: pull_request: types: [opened, synchronize] # Optional: Only run on specific file changes # paths: # - "src/**/*.ts" # - "src/**/*.tsx" # - "src/**/*.js" # - "src/**/*.jsx" jobs: claude-review: # Optional: Filter by PR author # if: | # github.event.pull_request.user.login == 'external-contributor' || # github.event.pull_request.user.login == 'new-developer' || # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' runs-on: ubuntu-latest permissions: contents: read pull-requests: read issues: read id-token: write steps: - name: Checkout repository uses: actions/checkout@v4 with: fetch-depth: 1 - name: Run Claude Code Review id: claude-review uses: anthropics/claude-code-action@v1 with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} prompt: | REPO: ${{ github.repository }} PR NUMBER: ${{ github.event.pull_request.number }} Please review this pull request and provide feedback on: - Code quality and best practices - Potential bugs or issues - Performance considerations - Security concerns - Test coverage Use the repository's CLAUDE.md for guidance on style and conventions. Be constructive and helpful in your feedback. Use `gh pr comment` with your Bash tool to leave your review as a comment on the PR. # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md # or https://code.claude.com/docs/en/cli-reference for available options claude_args: '--allowed-tools "Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)"' ================================================ FILE: .github/workflows/claude.yml ================================================ name: Claude Code on: issue_comment: types: [created] pull_request_review_comment: types: [created] issues: types: [opened, assigned] pull_request_review: types: [submitted] jobs: claude: if: | (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) runs-on: ubuntu-latest permissions: contents: read pull-requests: read issues: read id-token: write actions: read # Required for Claude to read CI results on PRs steps: - name: Checkout repository uses: actions/checkout@v4 with: fetch-depth: 1 - name: Run Claude Code id: claude uses: anthropics/claude-code-action@v1 with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} # This is an optional setting that allows Claude to read CI results on PRs additional_permissions: | actions: read # Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it. # prompt: 'Update the pull request description to include a summary of changes.' # Optional: Add claude_args to customize behavior and configuration # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md # or https://code.claude.com/docs/en/cli-reference for available options # claude_args: '--allowed-tools Bash(gh pr:*)' ================================================ FILE: .github/workflows/duplicate-issues.yml ================================================ name: Potential Duplicates on: issues: types: [opened, edited] jobs: check-duplicates: runs-on: ubuntu-latest steps: - uses: wow-actions/potential-duplicates@v1 with: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} threshold: 0.6 state: all label: possible-duplicate comment: > Possible duplicates found: {{#issues}} - #{{number}} ({{similarity}}% similar): {{title}} {{/issues}} If one of these matches your issue, please add your details there instead. ================================================ FILE: .github/workflows/release.yml ================================================ name: Release on: push: tags: - '*' jobs: release: runs-on: ubuntu-latest permissions: contents: write steps: - name: Checkout code uses: actions/checkout@v4 with: fetch-depth: 0 # Full history for changelog generation - name: Setup Node 20 uses: actions/setup-node@v4 with: node-version: 20 cache: 'npm' - name: Install dependencies run: npm ci - name: Build run: npm run build - name: Get previous tag id: prev_tag run: | PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "") echo "tag=$PREV_TAG" >> $GITHUB_OUTPUT - name: Generate changelog id: changelog run: | if [ -n "${{ steps.prev_tag.outputs.tag }}" ]; then CHANGELOG=$(git log ${{ steps.prev_tag.outputs.tag }}..HEAD --pretty=format:"- %s" --no-merges | grep -v "^- ${{ github.ref_name }}$" || true) else CHANGELOG=$(git log --pretty=format:"- %s" --no-merges | head -20) fi # Handle multiline output echo "notes<> $GITHUB_OUTPUT echo "$CHANGELOG" >> $GITHUB_OUTPUT echo "EOF" >> $GITHUB_OUTPUT - name: Create GitHub Release uses: softprops/action-gh-release@v1 with: body: | ## What's Changed ${{ steps.changelog.outputs.notes }} **Full Changelog**: https://github.com/${{ github.repository }}/compare/${{ steps.prev_tag.outputs.tag }}...${{ github.ref_name }} files: | main.js manifest.json styles.css ================================================ FILE: .github/workflows/stale.yml ================================================ name: Close stale issues on: schedule: - cron: '0 0 * * *' # Runs daily at midnight UTC workflow_dispatch: # Allow manual trigger jobs: stale: runs-on: ubuntu-latest permissions: issues: write steps: - uses: actions/stale@v9 with: days-before-stale: 7 days-before-close: 7 stale-issue-message: 'This issue has been inactive for 7 days and will be closed in 7 days if no further activity occurs.' close-issue-message: 'Closed due to 14 days of inactivity.' stale-issue-label: 'stale' exempt-issue-labels: 'pinned,security,bug' ================================================ FILE: .gitignore ================================================ # Build output main.js styles.css *.js.map dist/ build/ # Dependencies node_modules/ npm-debug.log* yarn-debug.log* yarn-error.log* # Environment variables .env .env.local .env.development.local .env.test.local .env.production.local # IDE .idea/ .vscode/ *.swp *.swo *~ # OS .DS_Store .DS_Store? ._* .Spotlight-V100 .Trashes ehthumbs.db Thumbs.db # npm/yarn npm-debug.log* yarn-error.log* .yarn-integrity # TypeScript *.tsbuildinfo # Logs logs *.log # Runtime data pids *.pid *.seed *.pid.lock # Coverage directory used by tools like istanbul coverage/ *.lcov # Optional npm cache directory .npm # Optional eslint cache .eslintcache # Output of 'npm pack' *.tgz # TernJS port file .tern-port # Stores VSCode versions used for testing VSCode extensions .vscode-test # Temporary folders tmp/ temp/ # Obsidian specific *.obsidian data.json # Test artifacts test-results/ # Development files sandbox/ dev # Lock files (uncomment based on package manager) # yarn.lock # package-lock.json .claude/ .codex/ ================================================ FILE: .npmrc ================================================ tag-version-prefix="" loglevel=silent ================================================ FILE: AGENTS.md ================================================ ## Agents Read CLAUDE.md for the agent overview and instructions. ================================================ FILE: CLAUDE.md ================================================ # CLAUDE.md ## Project Overview Claudian - An Obsidian plugin that embeds Claude Code as a sidebar chat interface. The vault directory becomes Claude's working directory, giving it full agentic capabilities: file read/write, bash commands, and multi-step workflows. ## Commands ```bash npm run dev # Development (watch mode) npm run build # Production build npm run typecheck # Type check npm run lint # Lint code npm run lint:fix # Lint and auto-fix npm run test # Run tests npm run test:watch # Run tests in watch mode ``` ## Architecture | Layer | Purpose | Details | |-------|---------|---------| | **core** | Infrastructure (no feature deps) | See [`src/core/CLAUDE.md`](src/core/CLAUDE.md) | | **features/chat** | Main sidebar interface | See [`src/features/chat/CLAUDE.md`](src/features/chat/CLAUDE.md) | | **features/inline-edit** | Inline edit modal | `InlineEditService`, read-only tools | | **features/settings** | Settings tab | UI components for all settings | | **shared** | Reusable UI | Dropdowns, instruction modal, fork target modal, @-mention, icons | | **i18n** | Internationalization | 10 locales | | **utils** | Utility functions | date, path, env, editor, session, markdown, diff, context, sdkSession, frontmatter, slashCommand, mcp, claudeCli, externalContext, externalContextScanner, fileLink, imageEmbed, inlineEdit | | **style** | Modular CSS | See [`src/style/CLAUDE.md`](src/style/CLAUDE.md) | ## Tests ```bash npm run test -- --selectProjects unit # Run unit tests npm run test -- --selectProjects integration # Run integration tests npm run test:coverage -- --selectProjects unit # Unit coverage ``` Tests mirror `src/` structure in `tests/unit/` and `tests/integration/`. ## Storage | File | Contents | |------|----------| | `.claude/settings.json` | CC-compatible: permissions, env, enabledPlugins | | `.claude/claudian-settings.json` | Claudian-specific settings (model, UI, etc.) | | `.claude/settings.local.json` | Local overrides (gitignored) | | `.claude/mcp.json` | MCP server configs | | `.claude/commands/*.md` | Slash commands (YAML frontmatter) | | `.claude/agents/*.md` | Custom agents (YAML frontmatter) | | `.claude/skills/*/SKILL.md` | Skill definitions | | `.claude/sessions/*.meta.json` | Session metadata | | `~/.claude/projects/{vault}/*.jsonl` | SDK-native session messages | ## Development Notes - **SDK-first**: Proactively use native Claude SDK features over custom implementations. If the SDK provides a capability, use it — do not reinvent it. This ensures compatibility with Claude Code. - **SDK exploration**: When developing SDK-related features, write a throwaway test script (e.g., in `dev/`) that calls the real SDK to observe actual response shapes, event sequences, and edge cases. Real output lands in `~/.claude/` or `{vault}/.claude/` — inspect those files to understand patterns and formats. Run this before writing implementation or tests — real output beats guessing at types and formats. This is the default first step for any SDK integration work. - **Comments**: Only comment WHY, not WHAT. No JSDoc that restates the function name (`/** Get servers. */` on `getServers()`), no narrating inline comments (`// Create the channel` before `new Channel()`), no module-level docs on barrel `index.ts` files. Keep JSDoc only when it adds non-obvious context (edge cases, constraints, surprising behavior). - **TDD workflow**: For new functions/modules and bug fixes, follow red-green-refactor: 1. Write a failing test first in the mirrored path under `tests/unit/` (or `tests/integration/`) 2. Run it with `npm run test -- --selectProjects unit --testPathPattern ` to confirm it fails 3. Write the minimal implementation to make it pass 4. Refactor, keeping tests green - For bug fixes, write a test that reproduces the bug before fixing it - Test behavior and public API, not internal implementation details - Skip TDD for trivial changes (renaming, moving files, config tweaks) — but still verify existing tests pass - Run `npm run typecheck && npm run lint && npm run test && npm run build` after editing - No `console.*` in production code - use Obsidian's notification system if user should be notified - use `console.log` for debugging, but remove it before committing - Generated docs/test scripts go in `dev/`. ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2025 Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # Claudian ![GitHub stars](https://img.shields.io/github/stars/YishenTu/claudian?style=social) ![GitHub release](https://img.shields.io/github/v/release/YishenTu/claudian) ![License](https://img.shields.io/github/license/YishenTu/claudian) ![Preview](Preview.png) An Obsidian plugin that embeds Claude Code as an AI collaborator in your vault. Your vault becomes Claude's working directory, giving it full agentic capabilities: file read/write, search, bash commands, and multi-step workflows. ## Features - **Full Agentic Capabilities**: Leverage Claude Code's power to read, write, and edit files, search, and execute bash commands, all within your Obsidian vault. - **Context-Aware**: Automatically attach the focused note, mention files with `@`, exclude notes by tag, include editor selection (Highlight), and access external directories for additional context. - **Vision Support**: Analyze images by sending them via drag-and-drop, paste, or file path. - **Inline Edit**: Edit selected text or insert content at cursor position directly in notes with word-level diff preview and read-only tool access for context. - **Instruction Mode (`#`)**: Add refined custom instructions to your system prompt directly from the chat input, with review/edit in a modal. - **Slash Commands**: Create reusable prompt templates triggered by `/command`, with argument placeholders, `@file` references, and optional inline bash substitutions. - **Skills**: Extend Claudian with reusable capability modules that are automatically invoked based on context, compatible with Claude Code's skill format. - **Custom Agents**: Define custom subagents that Claude can invoke, with support for tool restrictions and model overrides. - **Claude Code Plugins**: Enable Claude Code plugins installed via the CLI, with automatic discovery from `~/.claude/plugins` and per-vault configuration. Plugin skills, agents, and slash commands integrate seamlessly. - **MCP Support**: Connect external tools and data sources via Model Context Protocol servers (stdio, SSE, HTTP) with context-saving mode and `@`-mention activation. - **Advanced Model Control**: Select between Haiku, Sonnet, and Opus, configure custom models via environment variables, fine-tune thinking budget, and enable Opus and Sonnet with 1M context window (requires Max subscription or extra usage). - **Plan Mode**: Toggle plan mode via Shift+Tab in the chat input. Claudian explores and designs before implementing, presenting a plan for approval with options to approve in a new session, continue in the current session, or provide feedback. - **Security**: Permission modes (YOLO/Safe/Plan), safety blocklist, and vault confinement with symlink-safe checks. - **Claude in Chrome**: Allow Claude to interact with Chrome through the `claude-in-chrome` extension. ## Requirements - [Claude Code CLI](https://code.claude.com/docs/en/overview) installed (strongly recommend install Claude Code via Native Install) - Obsidian v1.8.9+ - Claude subscription/API or Custom model provider that supports Anthropic API format ([Openrouter](https://openrouter.ai/docs/guides/guides/claude-code-integration), [Kimi](https://platform.moonshot.ai/docs/guide/agent-support), [GLM](https://docs.z.ai/devpack/tool/claude), [DeepSeek](https://api-docs.deepseek.com/guides/anthropic_api), etc.) - Desktop only (macOS, Linux, Windows) ## Installation ### From GitHub Release (recommended) 1. Download `main.js`, `manifest.json`, and `styles.css` from the [latest release](https://github.com/YishenTu/claudian/releases/latest) 2. Create a folder called `claudian` in your vault's plugins folder: ``` /path/to/vault/.obsidian/plugins/claudian/ ``` 3. Copy the downloaded files into the `claudian` folder 4. Enable the plugin in Obsidian: - Settings → Community plugins → Enable "Claudian" ### Using BRAT [BRAT](https://github.com/TfTHacker/obsidian42-brat) (Beta Reviewers Auto-update Tester) allows you to install and automatically update plugins directly from GitHub. 1. Install the BRAT plugin from Obsidian Community Plugins 2. Enable BRAT in Settings → Community plugins 3. Open BRAT settings and click "Add Beta plugin" 4. Enter the repository URL: `https://github.com/YishenTu/claudian` 5. Click "Add Plugin" and BRAT will install Claudian automatically 6. Enable Claudian in Settings → Community plugins > **Tip**: BRAT will automatically check for updates and notify you when a new version is available. ### From source (development) 1. Clone this repository into your vault's plugins folder: ```bash cd /path/to/vault/.obsidian/plugins git clone https://github.com/YishenTu/claudian.git cd claudian ``` 2. Install dependencies and build: ```bash npm install npm run build ``` 3. Enable the plugin in Obsidian: - Settings → Community plugins → Enable "Claudian" ### Development ```bash # Watch mode npm run dev # Production build npm run build ``` > **Tip**: Copy `.env.local.example` to `.env.local` or `npm install` and setup your vault path to auto-copy files during development. ## Usage **Two modes:** 1. Click the bot icon in ribbon or use command palette to open chat 2. Select text + hotkey for inline edit Use it like Claude Code—read, write, edit, search files in your vault. ### Context - **File**: Auto-attaches focused note; type `@` to attach other files - **@-mention dropdown**: Type `@` to see MCP servers, agents, external contexts, and vault files - `@Agents/` shows custom agents for selection - `@mcp-server` enables context-saving MCP servers - `@folder/` filters to files from that external context (e.g., `@workspace/`) - Vault files shown by default - **Selection**: Select text in editor, or elements in canvas, then chat—selection included automatically - **Images**: Drag-drop, paste, or type path; configure media folder for `![[image]]` embeds - **External contexts**: Click folder icon in toolbar for access to directories outside vault ### Features - **Inline Edit**: Select text + hotkey to edit directly in notes with word-level diff preview - **Instruction Mode**: Type `#` to add refined instructions to system prompt - **Slash Commands**: Type `/` for custom prompt templates or skills - **Skills**: Add `skill/SKILL.md` files to `~/.claude/skills/` or `{vault}/.claude/skills/`, recommended to use Claude Code to manage skills - **Custom Agents**: Add `agent.md` files to `~/.claude/agents/` (global) or `{vault}/.claude/agents/` (vault-specific); select via `@Agents/` in chat, or prompt Claudian to invoke agents - **Claude Code Plugins**: Enable plugins via Settings → Claude Code Plugins, recommended to use Claude Code to manage plugins - **MCP**: Add external tools via Settings → MCP Servers; use `@mcp-server` in chat to activate ## Configuration ### Settings **Customization** - **User name**: Your name for personalized greetings - **Excluded tags**: Tags that prevent notes from auto-loading (e.g., `sensitive`, `private`) - **Media folder**: Configure where vault stores attachments for embedded image support (e.g., `attachments`) - **Custom system prompt**: Additional instructions appended to the default system prompt (Instruction Mode `#` saves here) - **Enable auto-scroll**: Toggle automatic scrolling to bottom during streaming (default: on) - **Auto-generate conversation titles**: Toggle AI-powered title generation after the first user message is sent - **Title generation model**: Model used for auto-generating conversation titles (default: Auto/Haiku) - **Vim-style navigation mappings**: Configure key bindings with lines like `map w scrollUp`, `map s scrollDown`, `map i focusInput` **Hotkeys** - **Inline edit hotkey**: Hotkey to trigger inline edit on selected text - **Open chat hotkey**: Hotkey to open the chat sidebar **Slash Commands** - Create/edit/import/export custom `/commands` (optionally override model and allowed tools) **MCP Servers** - Add/edit/verify/delete MCP server configurations with context-saving mode **Claude Code Plugins** - Enable/disable Claude Code plugins discovered from `~/.claude/plugins` - User-scoped plugins available in all vaults; project-scoped plugins only in matching vault **Safety** - **Load user Claude settings**: Load `~/.claude/settings.json` (user's Claude Code permission rules may bypass Safe mode) - **Enable command blocklist**: Block dangerous bash commands (default: on) - **Blocked commands**: Patterns to block (supports regex, platform-specific) - **Allowed export paths**: Paths outside the vault where files can be exported (default: `~/Desktop`, `~/Downloads`). Supports `~`, `$VAR`, `${VAR}`, and `%VAR%` (Windows). **Environment** - **Custom variables**: Environment variables for Claude SDK (KEY=VALUE format, supports `export ` prefix) - **Environment snippets**: Save and restore environment variable configurations **Advanced** - **Claude CLI path**: Custom path to Claude Code CLI (leave empty for auto-detection) ## Safety and Permissions | Scope | Access | |-------|--------| | **Vault** | Full read/write (symlink-safe via `realpath`) | | **Export paths** | Write-only (e.g., `~/Desktop`, `~/Downloads`) | | **External contexts** | Full read/write (session-only, added via folder icon) | - **YOLO mode**: No approval prompts; all tool calls execute automatically (default) - **Safe mode**: Approval prompt per tool call; Bash requires exact match, file tools allow prefix match - **Plan mode**: Explores and designs a plan before implementing. Toggle via Shift+Tab in the chat input ## Privacy & Data Use - **Sent to API**: Your input, attached files, images, and tool call outputs. Default: Anthropic; custom endpoint via `ANTHROPIC_BASE_URL`. - **Local storage**: Settings, session metadata, and commands stored in `vault/.claude/`; session messages in `~/.claude/projects/` (SDK-native); legacy sessions in `vault/.claude/sessions/`. - **No telemetry**: No tracking beyond your configured API provider. ## Troubleshooting ### Claude CLI not found If you encounter `spawn claude ENOENT` or `Claude CLI not found`, the plugin can't auto-detect your Claude installation. Common with Node version managers (nvm, fnm, volta). **Solution**: Find your CLI path and set it in Settings → Advanced → Claude CLI path. | Platform | Command | Example Path | |----------|---------|--------------| | macOS/Linux | `which claude` | `/Users/you/.volta/bin/claude` | | Windows (native) | `where.exe claude` | `C:\Users\you\AppData\Local\Claude\claude.exe` | | Windows (npm) | `npm root -g` | `{root}\@anthropic-ai\claude-code\cli.js` | > **Note**: On Windows, avoid `.cmd` wrappers. Use `claude.exe` or `cli.js`. **Alternative**: Add your Node.js bin directory to PATH in Settings → Environment → Custom variables. ### npm CLI and Node.js not in same directory If using npm-installed CLI, check if `claude` and `node` are in the same directory: ```bash dirname $(which claude) dirname $(which node) ``` If different, GUI apps like Obsidian may not find Node.js. **Solutions**: 1. Install native binary (recommended) 2. Add Node.js path to Settings → Environment: `PATH=/path/to/node/bin` **Still having issues?** [Open a GitHub issue](https://github.com/YishenTu/claudian/issues) with your platform, CLI path, and error message. ## Architecture ``` src/ ├── main.ts # Plugin entry point ├── core/ # Core infrastructure │ ├── agent/ # Claude Agent SDK wrapper (ClaudianService) │ ├── agents/ # Custom agent management (AgentManager) │ ├── commands/ # Slash command management (SlashCommandManager) │ ├── hooks/ # PreToolUse/PostToolUse hooks │ ├── images/ # Image caching and loading │ ├── mcp/ # MCP server config, service, and testing │ ├── plugins/ # Claude Code plugin discovery and management │ ├── prompts/ # System prompts for agents │ ├── sdk/ # SDK message transformation │ ├── security/ # Approval, blocklist, path validation │ ├── storage/ # Distributed storage system │ ├── tools/ # Tool constants and utilities │ └── types/ # Type definitions ├── features/ # Feature modules │ ├── chat/ # Main chat view + UI, rendering, controllers, tabs │ ├── inline-edit/ # Inline edit service + UI │ └── settings/ # Settings tab UI ├── shared/ # Shared UI components and modals │ ├── components/ # Input toolbar bits, dropdowns, selection highlight │ ├── mention/ # @-mention dropdown controller │ ├── modals/ # Instruction modal │ └── icons.ts # Shared SVG icons ├── i18n/ # Internationalization (10 locales) ├── utils/ # Modular utility functions └── style/ # Modular CSS (→ styles.css) ``` ## Roadmap - [x] Claude Code Plugin support - [x] Custom agent (subagent) support - [x] Claude in Chrome support - [x] `/compact` command - [x] Plan mode - [x] `rewind` and `fork` support (including `/fork` command) - [x] `!command` support - [x] Tool renderers refinement - [x] 1M Opus and Sonnet models - [ ] Codex SDK integration - [ ] Hooks and other advanced features - [ ] More to come! ## License Licensed under the [MIT License](LICENSE). ## Star History Star History Chart ## Acknowledgments - [Obsidian](https://obsidian.md) for the plugin API - [Anthropic](https://anthropic.com) for Claude and the [Claude Agent SDK](https://platform.claude.com/docs/en/agent-sdk/overview) ================================================ FILE: esbuild.config.mjs ================================================ import esbuild from 'esbuild'; import path from 'path'; import process from 'process'; import builtins from 'builtin-modules'; import { copyFileSync, existsSync, mkdirSync, readFileSync } from 'fs'; // Load .env.local if it exists if (existsSync('.env.local')) { const envContent = readFileSync('.env.local', 'utf-8'); for (const line of envContent.split('\n')) { const match = line.match(/^([^=]+)=["']?(.+?)["']?$/); if (match && !process.env[match[1]]) { process.env[match[1]] = match[2]; } } } const prod = process.argv[2] === 'production'; // Obsidian plugin folder path (set via OBSIDIAN_VAULT env var or .env.local) const OBSIDIAN_VAULT = process.env.OBSIDIAN_VAULT; const OBSIDIAN_PLUGIN_PATH = OBSIDIAN_VAULT && existsSync(OBSIDIAN_VAULT) ? path.join(OBSIDIAN_VAULT, '.obsidian', 'plugins', 'claudian') : null; // Plugin to copy built files to Obsidian plugin folder const copyToObsidian = { name: 'copy-to-obsidian', setup(build) { build.onEnd((result) => { if (result.errors.length > 0 || !OBSIDIAN_PLUGIN_PATH) return; if (!existsSync(OBSIDIAN_PLUGIN_PATH)) { mkdirSync(OBSIDIAN_PLUGIN_PATH, { recursive: true }); } const files = ['main.js', 'manifest.json', 'styles.css']; for (const file of files) { if (existsSync(file)) { copyFileSync(file, path.join(OBSIDIAN_PLUGIN_PATH, file)); console.log(`Copied ${file} to Obsidian plugin folder`); } } }); } }; const context = await esbuild.context({ entryPoints: ['src/main.ts'], bundle: true, plugins: [copyToObsidian], external: [ 'obsidian', 'electron', '@codemirror/autocomplete', '@codemirror/collab', '@codemirror/commands', '@codemirror/language', '@codemirror/lint', '@codemirror/search', '@codemirror/state', '@codemirror/view', '@lezer/common', '@lezer/highlight', '@lezer/lr', ...builtins, ...builtins.map(m => `node:${m}`), ], format: 'cjs', target: 'es2018', logLevel: 'info', sourcemap: prod ? false : 'inline', treeShaking: true, outfile: 'main.js', }); if (prod) { await context.rebuild(); process.exit(0); } else { await context.watch(); } ================================================ FILE: jest.config.js ================================================ /** @type {import('ts-jest').JestConfigWithTsJest} */ const baseConfig = { preset: 'ts-jest', testEnvironment: 'node', transform: { '^.+\\.tsx?$': ['ts-jest', { tsconfig: 'tsconfig.jest.json' }], }, moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], moduleNameMapper: { '^@/(.*)$': '/src/$1', '^@test/(.*)$': '/tests/$1', '^@anthropic-ai/claude-agent-sdk$': '/tests/__mocks__/claude-agent-sdk.ts', '^obsidian$': '/tests/__mocks__/obsidian.ts', '^@modelcontextprotocol/sdk/(.*)$': '/node_modules/@modelcontextprotocol/sdk/dist/cjs/$1', }, transformIgnorePatterns: [ 'node_modules/(?!(@anthropic-ai/claude-agent-sdk)/)', ], }; module.exports = { projects: [ { ...baseConfig, displayName: 'unit', testMatch: ['/tests/unit/**/*.test.ts'], }, { ...baseConfig, displayName: 'integration', testMatch: ['/tests/integration/**/*.test.ts'], }, ], collectCoverageFrom: [ 'src/**/*.ts', '!src/**/*.d.ts', ], coverageDirectory: 'coverage', }; ================================================ FILE: manifest.json ================================================ { "id": "claudian", "name": "Claudian", "version": "1.3.70", "minAppVersion": "1.4.5", "description": "Embeds Claude Code as an AI collaborator in your vault. Your vault becomes Claude's working directory, giving it full agentic capabilities: file read/write, search, bash commands, and multi-step workflows.", "author": "Yishen Tu", "authorUrl": "https://github.com/YishenTu", "isDesktopOnly": true } ================================================ FILE: package.json ================================================ { "name": "claudian", "version": "1.3.70", "description": "Claudian - Claude Code embedded in Obsidian sidebar", "main": "main.js", "scripts": { "postinstall": "node scripts/postinstall.mjs", "build:css": "node scripts/build-css.mjs", "dev": "npm run build:css && node esbuild.config.mjs", "build": "node scripts/build.mjs production", "typecheck": "tsc --noEmit", "lint": "eslint \"{src,tests}/**/*.ts\"", "lint:fix": "npm run lint -- --fix", "test": "node scripts/run-jest.js", "test:watch": "node scripts/run-jest.js --watch", "test:coverage": "node scripts/run-jest.js --coverage", "version": "node scripts/sync-version.js && git add manifest.json" }, "keywords": [ "claude-code", "obsidian", "obsidian-plugin", "productivity", "ide" ], "author": "Yishen Tu", "license": "MIT", "devDependencies": { "@types/jest": "^30.0.0", "@types/node": "^20.0.0", "@typescript-eslint/eslint-plugin": "^8.30.0", "@typescript-eslint/parser": "^8.30.0", "builtin-modules": "^3.3.0", "esbuild": "^0.27.1", "eslint": "^8.57.0", "eslint-plugin-jest": "^28.11.0", "eslint-plugin-simple-import-sort": "^12.1.1", "jest": "^30.2.0", "jest-environment-jsdom": "^30.2.0", "obsidian": "latest", "ts-jest": "^29.4.6", "typescript": "^5.0.0" }, "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.76", "@modelcontextprotocol/sdk": "~1.25.3", "tslib": "^2.8.1" } } ================================================ FILE: scripts/build-css.mjs ================================================ #!/usr/bin/env node /** * CSS Build Script * Concatenates modular CSS files from src/style/ into root styles.css */ import { readFileSync, writeFileSync, existsSync, readdirSync } from 'fs'; import { join, dirname, resolve, relative } from 'path'; import { fileURLToPath } from 'url'; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = join(__dirname, '..'); const STYLE_DIR = join(ROOT, 'src', 'style'); const OUTPUT = join(ROOT, 'styles.css'); const INDEX_FILE = join(STYLE_DIR, 'index.css'); const IMPORT_PATTERN = /^\s*@import\s+(?:url\()?['"]([^'"]+)['"]\)?\s*;/gm; function getModuleOrder() { if (!existsSync(INDEX_FILE)) { console.error('Missing src/style/index.css'); process.exit(1); } const content = readFileSync(INDEX_FILE, 'utf-8'); const matches = [...content.matchAll(IMPORT_PATTERN)]; if (matches.length === 0) { console.error('No @import entries found in src/style/index.css'); process.exit(1); } return matches.map((match) => match[1]); } function listCssFiles(dir, baseDir = dir) { const entries = readdirSync(dir, { withFileTypes: true }); const files = []; for (const entry of entries) { const entryPath = join(dir, entry.name); if (entry.isDirectory()) { files.push(...listCssFiles(entryPath, baseDir)); continue; } if (entry.isFile() && entry.name.endsWith('.css')) { const relativePath = relative(baseDir, entryPath).split('\\').join('/'); files.push(relativePath); } } return files; } function build() { const moduleOrder = getModuleOrder(); const parts = ['/* Claudian Plugin Styles */\n/* Built from src/style/ modules */\n']; const missingFiles = []; const invalidImports = []; const normalizedImports = []; for (const modulePath of moduleOrder) { const resolvedPath = resolve(STYLE_DIR, modulePath); const relativePath = relative(STYLE_DIR, resolvedPath); if (relativePath.startsWith('..') || !relativePath.endsWith('.css')) { invalidImports.push(modulePath); continue; } const normalizedPath = relativePath.split('\\').join('/'); normalizedImports.push(normalizedPath); if (!existsSync(resolvedPath)) { missingFiles.push(normalizedPath); continue; } const content = readFileSync(resolvedPath, 'utf-8'); const header = `\n/* ============================================\n ${normalizedPath}\n ============================================ */\n`; parts.push(header + content); } let hasErrors = false; if (invalidImports.length > 0) { console.error('Invalid @import entries in src/style/index.css:'); invalidImports.forEach((modulePath) => console.error(` - ${modulePath}`)); hasErrors = true; } if (missingFiles.length > 0) { console.error('Missing CSS files:'); missingFiles.forEach((f) => console.error(` - ${f}`)); hasErrors = true; } const allCssFiles = listCssFiles(STYLE_DIR).filter((file) => file !== 'index.css'); const importedSet = new Set(normalizedImports); const unlistedFiles = allCssFiles.filter((file) => !importedSet.has(file)); if (unlistedFiles.length > 0) { console.error('Unlisted CSS files (not imported in src/style/index.css):'); unlistedFiles.forEach((file) => console.error(` - ${file}`)); hasErrors = true; } if (hasErrors) { process.exit(1); } const output = parts.join('\n'); writeFileSync(OUTPUT, output); console.log(`Built styles.css (${(output.length / 1024).toFixed(1)} KB)`); } build(); ================================================ FILE: scripts/build.mjs ================================================ #!/usr/bin/env node /** * Combined build script - runs CSS build then esbuild * Avoids npm echoing commands */ import { execSync } from 'child_process'; import { dirname, join } from 'path'; import { fileURLToPath } from 'url'; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = join(__dirname, '..'); // Run CSS build silently execSync('node scripts/build-css.mjs', { cwd: ROOT, stdio: 'inherit' }); // Run esbuild with args passed through const args = process.argv.slice(2).join(' '); execSync(`node esbuild.config.mjs ${args}`, { cwd: ROOT, stdio: 'inherit' }); ================================================ FILE: scripts/postinstall.mjs ================================================ #!/usr/bin/env node /** * Post-install script - copies .env.local.example to .env.local if it doesn't exist */ import { copyFileSync, existsSync } from 'fs'; import { dirname, join } from 'path'; import { fileURLToPath } from 'url'; // Skip in CI environments if (process.env.CI) { process.exit(0); } const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = join(__dirname, '..'); const example = join(ROOT, '.env.local.example'); const target = join(ROOT, '.env.local'); if (existsSync(example) && !existsSync(target)) { copyFileSync(example, target); console.log('Created .env.local from .env.local.example'); console.log('Edit it to set your OBSIDIAN_VAULT path for auto-copy during development.'); } ================================================ FILE: scripts/run-jest.js ================================================ const { spawnSync } = require('child_process'); const os = require('os'); const path = require('path'); const jestPath = require.resolve('jest/bin/jest'); const localStorageFile = path.join(os.tmpdir(), 'claudian-localstorage'); const result = spawnSync( process.execPath, [`--localstorage-file=${localStorageFile}`, jestPath, ...process.argv.slice(2)], { stdio: 'inherit' } ); if (result.error) { console.error(result.error); process.exit(1); } process.exit(result.status ?? 1); ================================================ FILE: scripts/sync-version.js ================================================ #!/usr/bin/env node const fs = require('fs'); const path = require('path'); const packagePath = path.join(__dirname, '..', 'package.json'); const manifestPath = path.join(__dirname, '..', 'manifest.json'); const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8')); const manifestJson = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); manifestJson.version = packageJson.version; fs.writeFileSync(manifestPath, JSON.stringify(manifestJson, null, 2) + '\n'); console.log(`Synced version to ${packageJson.version}`); ================================================ FILE: src/core/CLAUDE.md ================================================ # Core Infrastructure Core modules have **no feature dependencies**. Features depend on core, never the reverse. ## Modules | Module | Purpose | Key Files | |--------|---------|-----------| | `agent/` | Claude Agent SDK wrapper | `ClaudianService` (incl. fork session tracking), `SessionManager`, `QueryOptionsBuilder` (incl. `resumeSessionAt`), `MessageChannel`, `customSpawn` | | `agents/` | Custom agent discovery | `AgentManager`, `AgentStorage` | | `commands/` | Built-in command actions | `builtInCommands` | | `hooks/` | Security hooks | `SecurityHooks` | | `images/` | Image caching | SHA-256 dedup, base64 encoding | | `mcp/` | Model Context Protocol | `McpServerManager`, `McpTester` | | `plugins/` | Claude Code plugins | `PluginManager` | | `prompts/` | System prompts | `mainAgent`, `inlineEdit`, `instructionRefine`, `titleGeneration` | | `sdk/` | SDK message transform | `transformSDKMessage`, `typeGuards`, `types` | | `security/` | Access control | `ApprovalManager` (permission utilities), `BashPathValidator`, `BlocklistChecker` | | `storage/` | Persistence layer | `StorageService`, `SessionStorage`, `CCSettingsStorage`, `ClaudianSettingsStorage`, `McpStorage`, `SkillStorage`, `SlashCommandStorage`, `VaultFileAdapter` | | `tools/` | Tool utilities | `toolNames` (incl. plan mode tools), `toolIcons`, `toolInput`, `todo` | | `types/` | Type definitions | `settings`, `agent`, `mcp`, `chat` (incl. `forkSource?: { sessionId, resumeAt }`), `tools`, `models`, `sdk`, `plugins`, `diff` | ## Dependency Rules ``` types/ ← (all modules can import) storage/ ← security/, agent/, mcp/ security/ ← agent/ sdk/ ← agent/ hooks/ ← agent/ prompts/ ← agent/ ``` ## Key Patterns ### ClaudianService ```typescript // One instance per tab (lazy init on first query) const service = new ClaudianService(plugin, vaultPath); await service.query(prompt, options); // Returns async iterator service.abort(); // Cancel streaming ``` ### QueryOptionsBuilder ```typescript // Builds SDK Options from settings const builder = new QueryOptionsBuilder(plugin, settings); const options = builder.build({ sessionId, maxThinkingTokens }); ``` ### Storage (Claude Code pattern) ```typescript // Settings in vault/.claude/settings.json await CCSettingsStorage.load(vaultPath); await CCSettingsStorage.save(vaultPath, settings); // Sessions: SDK-native (~/.claude/projects/) + metadata overlay (.meta.json) await SessionStorage.loadSession(vaultPath, sessionId); ``` ### Security - `BashPathValidator`: Vault-only by default, symlink-safe via `realpath` - `ApprovalManager`: Permission utility functions (`buildPermissionUpdates`, `matchesRulePattern`, etc.) - `BlocklistChecker`: Platform-specific dangerous commands ## Gotchas - `ClaudianService` must be disposed on tab close (abort + cleanup) - `SessionManager` handles SDK session resume via `sessionId` - Fork uses `pendingForkSession` + `pendingResumeAt` on `ClaudianService` to pass `resumeSessionAt` to SDK; these are one-shot flags consumed on the next query - Storage paths are encoded: non-alphanumeric → `-` - `customSpawn` handles cross-platform process spawning - Plan mode uses dedicated callbacks (`exitPlanModeCallback`, `permissionModeSyncCallback`) that bypass normal approval flow in `canUseTool`. `EnterPlanMode` is auto-approved by the SDK; the stream event is detected to sync UI state. ================================================ FILE: src/core/agent/ClaudianService.ts ================================================ /** * Claudian - Claude Agent SDK wrapper * * Handles communication with Claude via the Agent SDK. Manages streaming, * session persistence, permission modes, and security hooks. * * Architecture: * - Persistent query for active chat conversation (eliminates cold-start latency) * - Cold-start queries for inline edit, title generation * - MessageChannel for message queueing and turn management * - Dynamic updates (model, thinking tokens, permission mode, MCP servers) */ import type { CanUseTool, McpServerConfig, Options, PermissionMode as SDKPermissionMode, PermissionResult, Query, RewindFilesResult, SDKMessage, SDKUserMessage, SlashCommand as SDKSlashCommand, } from '@anthropic-ai/claude-agent-sdk'; import { query as agentQuery } from '@anthropic-ai/claude-agent-sdk'; import { randomUUID } from 'crypto'; import * as fs from 'fs'; import { Notice } from 'obsidian'; import * as os from 'os'; import * as path from 'path'; import type ClaudianPlugin from '../../main'; import { stripCurrentNoteContext } from '../../utils/context'; import { getEnhancedPath, getMissingNodeError, parseEnvironmentVariables } from '../../utils/env'; import { getPathAccessType, getVaultPath } from '../../utils/path'; import { buildContextFromHistory, buildPromptWithHistoryContext, getLastUserMessage, isSessionExpiredError, } from '../../utils/session'; import { createBlocklistHook, createStopSubagentHook, createVaultRestrictionHook, type SubagentHookState, } from '../hooks'; import type { McpServerManager } from '../mcp'; import { isSessionInitEvent, isStreamChunk, transformSDKMessage } from '../sdk'; import { buildPermissionUpdates, getActionDescription, } from '../security'; import { TOOL_ASK_USER_QUESTION, TOOL_ENTER_PLAN_MODE, TOOL_EXIT_PLAN_MODE, TOOL_SKILL } from '../tools/toolNames'; import type { ApprovalDecision, ChatMessage, Conversation, ExitPlanModeCallback, ExitPlanModeDecision, ImageAttachment, PermissionMode, SlashCommand, StreamChunk, } from '../types'; import { isAdaptiveThinkingModel, THINKING_BUDGETS } from '../types'; import { MessageChannel } from './MessageChannel'; import { type ColdStartQueryContext, type PersistentQueryContext, QueryOptionsBuilder, type QueryOptionsContext, } from './QueryOptionsBuilder'; import { SessionManager } from './SessionManager'; import { type ClosePersistentQueryOptions, createResponseHandler, isTurnCompleteMessage, type PersistentQueryConfig, type ResponseHandler, type UserContentBlock, } from './types'; export type { ApprovalDecision }; export interface ApprovalCallbackOptions { decisionReason?: string; blockedPath?: string; agentID?: string; } export type ApprovalCallback = ( toolName: string, input: Record, description: string, options?: ApprovalCallbackOptions, ) => Promise; export type AskUserQuestionCallback = ( input: Record, signal?: AbortSignal, ) => Promise | null>; export interface QueryOptions { allowedTools?: string[]; model?: string; /** MCP servers @-mentioned in the prompt. */ mcpMentions?: Set; /** MCP servers enabled via UI selector (in addition to @-mentioned servers). */ enabledMcpServers?: Set; /** Force cold-start query (bypass persistent query). */ forceColdStart?: boolean; /** Session-specific external context paths (directories with full access). */ externalContextPaths?: string[]; } export interface EnsureReadyOptions { /** Session ID to resume. Auto-resolved from sessionManager if not provided. */ sessionId?: string; /** External context paths to include. */ externalContextPaths?: string[]; /** Force restart even if query is running (for session switch, crash recovery). */ force?: boolean; /** Preserve response handlers across restart (for mid-turn crash recovery). */ preserveHandlers?: boolean; } export class ClaudianService { private plugin: ClaudianPlugin; private abortController: AbortController | null = null; private approvalCallback: ApprovalCallback | null = null; private approvalDismisser: (() => void) | null = null; private askUserQuestionCallback: AskUserQuestionCallback | null = null; private exitPlanModeCallback: ExitPlanModeCallback | null = null; private permissionModeSyncCallback: ((sdkMode: string) => void) | null = null; private vaultPath: string | null = null; private currentExternalContextPaths: string[] = []; private readyStateListeners = new Set<(ready: boolean) => void>(); // Modular components private sessionManager = new SessionManager(); private mcpManager: McpServerManager; private persistentQuery: Query | null = null; private messageChannel: MessageChannel | null = null; private queryAbortController: AbortController | null = null; private responseHandlers: ResponseHandler[] = []; private responseConsumerRunning = false; private responseConsumerPromise: Promise | null = null; private shuttingDown = false; // Tracked configuration for detecting changes that require restart private currentConfig: PersistentQueryConfig | null = null; // Current allowed tools for canUseTool enforcement (null = no restriction) private currentAllowedTools: string[] | null = null; private pendingResumeAt?: string; private pendingForkSession = false; // Last sent message for crash recovery (Phase 1.3) private lastSentMessage: SDKUserMessage | null = null; private lastSentQueryOptions: QueryOptions | null = null; private crashRecoveryAttempted = false; private coldStartInProgress = false; // Prevent consumer error restarts during cold-start // Subagent hook state provider (set from feature layer to avoid core→feature dependency) private _subagentStateProvider: (() => SubagentHookState) | null = null; // Auto-triggered turn handling (e.g., task-notification delivery by the SDK) private _autoTurnBuffer: StreamChunk[] = []; private _autoTurnSawStreamText = false; private _autoTurnCallback: ((chunks: StreamChunk[]) => void) | null = null; constructor(plugin: ClaudianPlugin, mcpManager: McpServerManager) { this.plugin = plugin; this.mcpManager = mcpManager; } onReadyStateChange(listener: (ready: boolean) => void): () => void { this.readyStateListeners.add(listener); try { listener(this.isReady()); } catch { // Ignore listener errors } return () => { this.readyStateListeners.delete(listener); }; } private notifyReadyStateChange(): void { if (this.readyStateListeners.size === 0) { return; } const isReady = this.isReady(); for (const listener of this.readyStateListeners) { try { listener(isReady); } catch { // Ignore listener errors } } } setPendingResumeAt(uuid: string | undefined): void { this.pendingResumeAt = uuid; } /** One-shot: consumed on the next query, then cleared by routeMessage on session init. */ applyForkState(conv: Pick): string | null { const isPending = !conv.sessionId && !conv.sdkSessionId && !!conv.forkSource; this.pendingForkSession = isPending; if (isPending) { this.pendingResumeAt = conv.forkSource!.resumeAt; } else { this.pendingResumeAt = undefined; } return conv.sessionId ?? conv.forkSource?.sessionId ?? null; } async reloadMcpServers(): Promise { await this.mcpManager.loadServers(); } /** * Ensures the persistent query is running with current configuration. * Unified API that replaces preWarm() and restartPersistentQuery(). * * Behavior: * - If not running → start (if paths available) * - If running and force=true → close and restart * - If running and config changed → close and restart * - If running and config unchanged → no-op * * Note: When restart is needed, the query is closed BEFORE checking if we can * start a new one. This ensures fallback to cold-start if CLI becomes unavailable. * * @returns true if the query was (re)started, false otherwise */ async ensureReady(options?: EnsureReadyOptions): Promise { const vaultPath = getVaultPath(this.plugin.app); // Track external context paths for dynamic updates (empty list clears) if (options && options.externalContextPaths !== undefined) { this.currentExternalContextPaths = options.externalContextPaths; } // Auto-resolve session ID from sessionManager if not explicitly provided const effectiveSessionId = options?.sessionId ?? this.sessionManager.getSessionId() ?? undefined; const externalContextPaths = options?.externalContextPaths ?? this.currentExternalContextPaths; // Case 1: Not running → try to start if (!this.persistentQuery) { if (!vaultPath) return false; const cliPath = this.plugin.getResolvedClaudeCliPath(); if (!cliPath) return false; await this.startPersistentQuery(vaultPath, cliPath, effectiveSessionId, externalContextPaths); return true; } // Case 2: Force restart (session switch, crash recovery) // Close FIRST, then try to start new one (allows fallback if CLI unavailable) if (options?.force) { this.closePersistentQuery('forced restart', { preserveHandlers: options.preserveHandlers }); if (!vaultPath) return false; const cliPath = this.plugin.getResolvedClaudeCliPath(); if (!cliPath) return false; await this.startPersistentQuery(vaultPath, cliPath, effectiveSessionId, externalContextPaths); return true; } // Case 3: Check if config changed → restart if needed // We need vaultPath and cliPath to build config for comparison if (!vaultPath) return false; const cliPath = this.plugin.getResolvedClaudeCliPath(); if (!cliPath) return false; const newConfig = this.buildPersistentQueryConfig(vaultPath, cliPath, externalContextPaths); if (this.needsRestart(newConfig)) { // Close FIRST, then try to start new one (allows fallback if CLI unavailable) this.closePersistentQuery('config changed', { preserveHandlers: options?.preserveHandlers }); // Re-check CLI path as it might have changed during close const cliPathAfterClose = this.plugin.getResolvedClaudeCliPath(); if (cliPathAfterClose) { await this.startPersistentQuery(vaultPath, cliPathAfterClose, effectiveSessionId, externalContextPaths); return true; } // CLI unavailable after close - query is closed, will fallback to cold-start return false; } // Case 4: Running and config unchanged → no-op return false; } /** * Starts the persistent query for the active chat conversation. */ private async startPersistentQuery( vaultPath: string, cliPath: string, resumeSessionId?: string, externalContextPaths?: string[] ): Promise { if (this.persistentQuery) { return; } this.shuttingDown = false; this.vaultPath = vaultPath; this.messageChannel = new MessageChannel(); if (resumeSessionId) { this.messageChannel.setSessionId(resumeSessionId); this.sessionManager.setSessionId(resumeSessionId, this.plugin.settings.model); } this.queryAbortController = new AbortController(); const config = this.buildPersistentQueryConfig(vaultPath, cliPath, externalContextPaths); this.currentConfig = config; // await is intentional: yields to microtask queue so fire-and-forget callers // (e.g. setSessionId → ensureReady) don't synchronously set persistentQuery const resumeSessionAt = this.pendingResumeAt; const options = await this.buildPersistentQueryOptions( vaultPath, cliPath, resumeSessionId, resumeSessionAt, externalContextPaths ); this.persistentQuery = agentQuery({ prompt: this.messageChannel, options, }); if (this.pendingResumeAt === resumeSessionAt) { this.pendingResumeAt = undefined; } this.attachPersistentQueryStdinErrorHandler(this.persistentQuery); this.startResponseConsumer(); this.notifyReadyStateChange(); } private attachPersistentQueryStdinErrorHandler(query: Query): void { const stdin = (query as { transport?: { processStdin?: NodeJS.WritableStream } }).transport?.processStdin; if (!stdin || typeof stdin.on !== 'function' || typeof stdin.once !== 'function') { return; } const handler = (error: NodeJS.ErrnoException) => { if (this.shuttingDown || this.isPipeError(error)) { return; } this.closePersistentQuery('stdin error'); }; stdin.on('error', handler); stdin.once('close', () => { stdin.removeListener('error', handler); }); } private isPipeError(error: unknown): boolean { if (!error || typeof error !== 'object') return false; const e = error as { code?: string; message?: string }; return e.code === 'EPIPE' || (typeof e.message === 'string' && e.message.includes('EPIPE')); } /** * Closes the persistent query and cleans up resources. */ closePersistentQuery(_reason?: string, options?: ClosePersistentQueryOptions): void { if (!this.persistentQuery) { return; } const preserveHandlers = options?.preserveHandlers ?? false; this.shuttingDown = true; // Close the message channel (ends the async iterable) this.messageChannel?.close(); // Interrupt the query void this.persistentQuery.interrupt().catch(() => { // Silence abort/interrupt errors during shutdown }); // Abort as backup this.queryAbortController?.abort(); if (!preserveHandlers) { // Notify all handlers before clearing so generators don't hang forever. // This ensures queryViaPersistent() exits its while(!state.done) loop. for (const handler of this.responseHandlers) { handler.onDone(); } } // Reset shuttingDown synchronously. The consumer loop sees shuttingDown=true // on its next iteration check (line 549) and breaks. The messageChannel.close() // above also terminates the for-await loop. Resetting here allows new queries // to proceed immediately without waiting for consumer loop teardown. this.shuttingDown = false; this.notifyReadyStateChange(); // Clear state this.persistentQuery = null; this.messageChannel = null; this.queryAbortController = null; this.responseConsumerRunning = false; this.responseConsumerPromise = null; this.currentConfig = null; this._autoTurnBuffer = []; this._autoTurnSawStreamText = false; if (!preserveHandlers) { this.responseHandlers = []; this.currentAllowedTools = null; } // NOTE: Do NOT reset crashRecoveryAttempted here. // It's reset in queryViaPersistent after a successful message send, // or in resetSession/setSessionId when switching sessions. // Resetting it here would cause infinite restart loops on persistent errors. } /** * Checks if the persistent query needs to be restarted based on configuration changes. */ private needsRestart(newConfig: PersistentQueryConfig): boolean { return QueryOptionsBuilder.needsRestart(this.currentConfig, newConfig); } /** * Builds configuration object for tracking changes. */ private buildPersistentQueryConfig( vaultPath: string, cliPath: string, externalContextPaths?: string[] ): PersistentQueryConfig { return QueryOptionsBuilder.buildPersistentQueryConfig( this.buildQueryOptionsContext(vaultPath, cliPath), externalContextPaths ); } /** * Builds the base query options context from current state. */ private buildQueryOptionsContext(vaultPath: string, cliPath: string): QueryOptionsContext { const customEnv = parseEnvironmentVariables(this.plugin.getActiveEnvironmentVariables()); const enhancedPath = getEnhancedPath(customEnv.PATH, cliPath); return { vaultPath, cliPath, settings: this.plugin.settings, customEnv, enhancedPath, mcpManager: this.mcpManager, pluginManager: this.plugin.pluginManager, }; } /** * Builds SDK options for the persistent query. */ private buildPersistentQueryOptions( vaultPath: string, cliPath: string, resumeSessionId?: string, resumeSessionAt?: string, externalContextPaths?: string[] ): Options { const baseContext = this.buildQueryOptionsContext(vaultPath, cliPath); const hooks = this.buildHooks(); const ctx: PersistentQueryContext = { ...baseContext, abortController: this.queryAbortController ?? undefined, resume: resumeSessionId ? { sessionId: resumeSessionId, sessionAt: resumeSessionAt, fork: this.pendingForkSession || undefined } : undefined, canUseTool: this.createApprovalCallback(), hooks, externalContextPaths, }; return QueryOptionsBuilder.buildPersistentQueryOptions(ctx); } /** * Builds the hooks for SDK options. * Hooks need access to `this` for dynamic settings, so they're built here. * * @param externalContextPaths - Optional external context paths for cold-start queries. * If not provided, the closure reads this.currentExternalContextPaths at execution * time (for persistent queries where the value may change dynamically). */ private buildHooks(externalContextPaths?: string[]) { const blocklistHook = createBlocklistHook(() => ({ blockedCommands: this.plugin.settings.blockedCommands, enableBlocklist: this.plugin.settings.enableBlocklist, })); const hooks: Options['hooks'] = {}; if (this.plugin.settings.allowExternalAccess) { hooks.PreToolUse = [blocklistHook]; } else { const vaultRestrictionHook = createVaultRestrictionHook({ getPathAccessType: (p) => { if (!this.vaultPath) return 'vault'; // For cold-start queries, use the passed externalContextPaths. // For persistent queries, read this.currentExternalContextPaths at execution time // so dynamic updates are reflected. const paths = externalContextPaths ?? this.currentExternalContextPaths; return getPathAccessType( p, paths, this.plugin.settings.allowedExportPaths, this.vaultPath ); }, }); hooks.PreToolUse = [blocklistHook, vaultRestrictionHook]; } // Always register subagent hooks — closures resolve provider at execution time // so hooks work even when provider is set after the persistent query starts. hooks.Stop = [createStopSubagentHook( () => this._subagentStateProvider?.() ?? { hasRunning: false } )]; return hooks; } /** * Starts the background consumer loop that routes chunks to handlers. */ private startResponseConsumer(): void { if (this.responseConsumerRunning) { return; } this.responseConsumerRunning = true; // Track which query this consumer is for, to detect if we were replaced const queryForThisConsumer = this.persistentQuery; this.responseConsumerPromise = (async () => { if (!this.persistentQuery) return; try { for await (const message of this.persistentQuery) { if (this.shuttingDown) break; await this.routeMessage(message); } } catch (error) { // Skip error handling if this consumer was replaced by a new one. // This prevents race conditions where the OLD consumer's error handler // interferes with the NEW handler after a restart (e.g., from applyDynamicUpdates). if (this.persistentQuery !== queryForThisConsumer && this.persistentQuery !== null) { return; } // Skip restart if cold-start is in progress (it will handle session capture) if (!this.shuttingDown && !this.coldStartInProgress) { const handler = this.responseHandlers[this.responseHandlers.length - 1]; const errorInstance = error instanceof Error ? error : new Error(String(error)); const messageToReplay = this.lastSentMessage; if (!this.crashRecoveryAttempted && messageToReplay && handler && !handler.sawAnyChunk) { this.crashRecoveryAttempted = true; try { await this.ensureReady({ force: true, preserveHandlers: true }); if (!this.messageChannel) { throw new Error('Persistent query restart did not create message channel'); } await this.applyDynamicUpdates(this.lastSentQueryOptions ?? undefined, { preserveHandlers: true }); this.messageChannel.enqueue(messageToReplay); return; } catch (restartError) { // If restart failed due to session expiration, invalidate session // so next query triggers noSessionButHasHistory → history rebuild if (isSessionExpiredError(restartError)) { this.sessionManager.invalidateSession(); } handler.onError(errorInstance); return; } } // Notify active handler of error if (handler) { handler.onError(errorInstance); } // Crash recovery: restart persistent query to prepare for next user message. if (!this.crashRecoveryAttempted) { this.crashRecoveryAttempted = true; try { await this.ensureReady({ force: true }); } catch (restartError) { // If restart failed due to session expiration, invalidate session // so next query triggers noSessionButHasHistory → history rebuild if (isSessionExpiredError(restartError)) { this.sessionManager.invalidateSession(); } // Restart failed - next query will start fresh. } } } } finally { // Only clear the flag if this consumer wasn't replaced by a new one (e.g., after restart) // If ensureReady() restarted, it starts a new consumer which sets the flag true, // so we shouldn't clear it here. if (this.persistentQuery === queryForThisConsumer || this.persistentQuery === null) { this.responseConsumerRunning = false; } } })(); } /** @param modelOverride - Optional model override for cold-start queries */ private getTransformOptions(modelOverride?: string) { return { intendedModel: modelOverride ?? this.plugin.settings.model, customContextLimits: this.plugin.settings.customContextLimits, }; } /** * Routes an SDK message to the active response handler. * * Design: Only one handler exists at a time because MessageChannel enforces * single-turn processing. When a turn is active, new messages are queued/merged. * The next message only dequeues after onTurnComplete(), which calls onDone() * on the current handler. A new handler is registered only when the next query starts. */ private async routeMessage(message: SDKMessage): Promise { // Note: Session expiration errors are handled in catch blocks (queryViaSDK, handleAbort) // The SDK throws errors as exceptions, not as message types // Safe to use last handler - design guarantees single handler at a time const handler = this.responseHandlers[this.responseHandlers.length - 1]; if (this.isStreamTextEvent(message)) { if (handler) { handler.markStreamTextSeen(); } else { this._autoTurnSawStreamText = true; } } // Transform SDK message to StreamChunks for (const event of transformSDKMessage(message, this.getTransformOptions())) { if (isSessionInitEvent(event)) { // Fork: suppress needsHistoryRebuild since SDK returns a different session ID by design const wasFork = this.pendingForkSession; this.sessionManager.captureSession(event.sessionId); if (wasFork) { this.sessionManager.clearHistoryRebuild(); this.pendingForkSession = false; } this.messageChannel?.setSessionId(event.sessionId); if (event.agents) { try { this.plugin.agentManager.setBuiltinAgentNames(event.agents); } catch { /* non-critical */ } } if (event.permissionMode && this.permissionModeSyncCallback) { try { this.permissionModeSyncCallback(event.permissionMode); } catch { /* non-critical */ } } } else if (isStreamChunk(event)) { // Dedup: SDK delivers text via stream_events (incremental) AND the assistant message // (complete). Skip the assistant message text if stream text was already seen. if (message.type === 'assistant' && event.type === 'text') { if (handler?.sawStreamText || (!handler && this._autoTurnSawStreamText)) { continue; } } // SDK auto-approves EnterPlanMode (checkPermissions → allow), // so canUseTool is never called. Detect the tool_use in the stream // and fire the sync callback to update the UI. if (event.type === 'tool_use' && event.name === TOOL_ENTER_PLAN_MODE) { if (this.currentConfig) { this.currentConfig.permissionMode = 'plan'; } if (this.permissionModeSyncCallback) { try { this.permissionModeSyncCallback('plan'); } catch { /* non-critical */ } } } if (handler) { // Add sessionId to usage chunks (consistent with cold-start path) if (event.type === 'usage') { handler.onChunk({ ...event, sessionId: this.sessionManager.getSessionId() }); } else { handler.onChunk(event); } } else { // No handler — buffer for auto-triggered turn (e.g., task-notification delivery) this._autoTurnBuffer.push(event); } } } if (message.type === 'assistant' && message.uuid) { const uuidChunk: StreamChunk = { type: 'sdk_assistant_uuid', uuid: message.uuid }; if (handler) { handler.onChunk(uuidChunk); } else { this._autoTurnBuffer.push(uuidChunk); } } // Check for turn completion if (isTurnCompleteMessage(message)) { // Signal turn complete to message channel this.messageChannel?.onTurnComplete(); // Notify handler if (handler) { handler.resetStreamText(); handler.onDone(); } else { this._autoTurnSawStreamText = false; if (this._autoTurnBuffer.length === 0) { return; } // Flush buffered chunks from auto-triggered turn (no handler was registered) const chunks = [...this._autoTurnBuffer]; this._autoTurnBuffer = []; try { this._autoTurnCallback?.(chunks); } catch { new Notice('Background task completed, but the result could not be rendered.'); } } } } private registerResponseHandler(handler: ResponseHandler): void { this.responseHandlers.push(handler); } private unregisterResponseHandler(handlerId: string): void { const idx = this.responseHandlers.findIndex(h => h.id === handlerId); if (idx >= 0) { this.responseHandlers.splice(idx, 1); } } isPersistentQueryActive(): boolean { return this.persistentQuery !== null && !this.shuttingDown; } /** * Sends a query to Claude and streams the response. * * Query selection: * - Persistent query: default chat conversation * - Cold-start query: only when forceColdStart is set */ async *query( prompt: string, images?: ImageAttachment[], conversationHistory?: ChatMessage[], queryOptions?: QueryOptions ): AsyncGenerator { const vaultPath = getVaultPath(this.plugin.app); if (!vaultPath) { yield { type: 'error', content: 'Could not determine vault path' }; return; } const resolvedClaudePath = this.plugin.getResolvedClaudeCliPath(); if (!resolvedClaudePath) { yield { type: 'error', content: 'Claude CLI not found. Please install Claude Code CLI.' }; return; } const customEnv = parseEnvironmentVariables(this.plugin.getActiveEnvironmentVariables()); const enhancedPath = getEnhancedPath(customEnv.PATH, resolvedClaudePath); const missingNodeError = getMissingNodeError(resolvedClaudePath, enhancedPath); if (missingNodeError) { yield { type: 'error', content: missingNodeError }; return; } // Rebuild history if needed before choosing persistent vs cold-start let promptToSend = prompt; let forceColdStart = false; // Clear interrupted flag - persistent query handles interruption gracefully, // no need to force cold-start just because user cancelled previous response if (this.sessionManager.wasInterrupted()) { this.sessionManager.clearInterrupted(); } // Session mismatch recovery: SDK returned a different session ID (context lost) // Inject history to restore context without forcing cold-start if (this.sessionManager.needsHistoryRebuild() && conversationHistory && conversationHistory.length > 0) { const historyContext = buildContextFromHistory(conversationHistory); const actualPrompt = stripCurrentNoteContext(prompt); promptToSend = buildPromptWithHistoryContext(historyContext, prompt, actualPrompt, conversationHistory); this.sessionManager.clearHistoryRebuild(); } const noSessionButHasHistory = !this.sessionManager.getSessionId() && conversationHistory && conversationHistory.length > 0; if (noSessionButHasHistory) { const historyContext = buildContextFromHistory(conversationHistory!); const actualPrompt = stripCurrentNoteContext(prompt); promptToSend = buildPromptWithHistoryContext(historyContext, prompt, actualPrompt, conversationHistory!); // Note: Do NOT call invalidateSession() here. The cold-start will capture // a new session ID anyway, and invalidating would break any persistent query // restart that happens during the cold-start (causing SESSION MISMATCH). forceColdStart = true; } const effectiveQueryOptions = forceColdStart ? { ...queryOptions, forceColdStart: true } : queryOptions; if (forceColdStart) { // Set flag BEFORE closing to prevent consumer error from triggering restart this.coldStartInProgress = true; this.closePersistentQuery('session invalidated'); } // Determine query path: persistent vs cold-start const shouldUsePersistent = !effectiveQueryOptions?.forceColdStart; if (shouldUsePersistent) { // Start persistent query if not running if (!this.persistentQuery && !this.shuttingDown) { await this.startPersistentQuery( vaultPath, resolvedClaudePath, this.sessionManager.getSessionId() ?? undefined ); } if (this.persistentQuery && !this.shuttingDown) { // Use persistent query path try { yield* this.queryViaPersistent(promptToSend, images, vaultPath, resolvedClaudePath, effectiveQueryOptions); return; } catch (error) { if (isSessionExpiredError(error) && conversationHistory && conversationHistory.length > 0) { this.sessionManager.invalidateSession(); const retryRequest = this.buildHistoryRebuildRequest(prompt, conversationHistory); this.coldStartInProgress = true; this.abortController = new AbortController(); try { yield* this.queryViaSDK( retryRequest.prompt, vaultPath, resolvedClaudePath, // Use current message's images, fallback to history images images ?? retryRequest.images, effectiveQueryOptions ); } catch (retryError) { const msg = retryError instanceof Error ? retryError.message : 'Unknown error'; yield { type: 'error', content: msg }; } finally { this.coldStartInProgress = false; this.abortController = null; } return; } throw error; } } } // Cold-start path (existing logic) // Set flag to prevent consumer error restarts from interfering this.coldStartInProgress = true; this.abortController = new AbortController(); try { yield* this.queryViaSDK(promptToSend, vaultPath, resolvedClaudePath, images, effectiveQueryOptions); } catch (error) { if (isSessionExpiredError(error) && conversationHistory && conversationHistory.length > 0) { this.sessionManager.invalidateSession(); const retryRequest = this.buildHistoryRebuildRequest(prompt, conversationHistory); try { yield* this.queryViaSDK( retryRequest.prompt, vaultPath, resolvedClaudePath, // Use current message's images, fallback to history images images ?? retryRequest.images, effectiveQueryOptions ); } catch (retryError) { const msg = retryError instanceof Error ? retryError.message : 'Unknown error'; yield { type: 'error', content: msg }; } return; } const msg = error instanceof Error ? error.message : 'Unknown error'; yield { type: 'error', content: msg }; } finally { this.coldStartInProgress = false; this.abortController = null; } } private buildHistoryRebuildRequest( prompt: string, conversationHistory: ChatMessage[] ): { prompt: string; images?: ImageAttachment[] } { const historyContext = buildContextFromHistory(conversationHistory); const actualPrompt = stripCurrentNoteContext(prompt); const fullPrompt = buildPromptWithHistoryContext(historyContext, prompt, actualPrompt, conversationHistory); const lastUserMessage = getLastUserMessage(conversationHistory); return { prompt: fullPrompt, images: lastUserMessage?.images, }; } /** * Query via persistent query (Phase 1.5). * Uses the message channel to send messages without cold-start latency. */ private async *queryViaPersistent( prompt: string, images: ImageAttachment[] | undefined, vaultPath: string, cliPath: string, queryOptions?: QueryOptions ): AsyncGenerator { if (!this.persistentQuery || !this.messageChannel) { // Fallback to cold-start if persistent query not available yield* this.queryViaSDK(prompt, vaultPath, cliPath, images, queryOptions); return; } // Set allowed tools for canUseTool enforcement // undefined = no restriction, [] = no tools, [...] = restricted if (queryOptions?.allowedTools !== undefined) { this.currentAllowedTools = queryOptions.allowedTools.length > 0 ? [...queryOptions.allowedTools, TOOL_SKILL] : []; } else { this.currentAllowedTools = null; } // Save allowedTools before applyDynamicUpdates - restart would clear it const savedAllowedTools = this.currentAllowedTools; // Apply dynamic updates before sending (Phase 1.6) await this.applyDynamicUpdates(queryOptions); // Restore allowedTools in case restart cleared it this.currentAllowedTools = savedAllowedTools; // Check if applyDynamicUpdates triggered a restart that failed // (e.g., CLI path not found, vault path missing) if (!this.persistentQuery || !this.messageChannel) { yield* this.queryViaSDK(prompt, vaultPath, cliPath, images, queryOptions); return; } if (!this.responseConsumerRunning) { yield* this.queryViaSDK(prompt, vaultPath, cliPath, images, queryOptions); return; } const message = this.buildSDKUserMessage(prompt, images); yield { type: 'sdk_user_uuid', uuid: message.uuid! }; // Create a promise-based handler to yield chunks // Use a mutable state object to work around TypeScript's control flow analysis const state = { chunks: [] as StreamChunk[], resolveChunk: null as ((chunk: StreamChunk | null) => void) | null, done: false, error: null as Error | null, }; const handlerId = `handler-${Date.now()}-${Math.random().toString(36).slice(2)}`; const handler = createResponseHandler({ id: handlerId, onChunk: (chunk) => { handler.markChunkSeen(); if (state.resolveChunk) { state.resolveChunk(chunk); state.resolveChunk = null; } else { state.chunks.push(chunk); } }, onDone: () => { state.done = true; if (state.resolveChunk) { state.resolveChunk(null); state.resolveChunk = null; } }, onError: (err) => { state.error = err; state.done = true; if (state.resolveChunk) { state.resolveChunk(null); state.resolveChunk = null; } }, }); this.registerResponseHandler(handler); try { // Track message for crash recovery (Phase 1.3) this.lastSentMessage = message; this.lastSentQueryOptions = queryOptions ?? null; this.crashRecoveryAttempted = false; // Enqueue the message with race condition protection // The channel could close between our null check above and this call try { this.messageChannel.enqueue(message); } catch (error) { if (error instanceof Error && error.message.includes('closed')) { yield* this.queryViaSDK(prompt, vaultPath, cliPath, images, queryOptions); return; } throw error; } yield { type: 'sdk_user_sent', uuid: message.uuid! }; // Yield chunks as they arrive while (!state.done) { if (state.chunks.length > 0) { yield state.chunks.shift()!; } else { const chunk = await new Promise((resolve) => { state.resolveChunk = resolve; }); if (chunk) { yield chunk; } } } // Yield any remaining chunks while (state.chunks.length > 0) { yield state.chunks.shift()!; } // Check if an error occurred (assigned in onError callback) if (state.error) { // Re-throw session expired errors for outer retry logic to handle if (isSessionExpiredError(state.error)) { throw state.error; } yield { type: 'error', content: state.error.message }; } // Clear message tracking after completion this.lastSentMessage = null; this.lastSentQueryOptions = null; yield { type: 'done' }; } finally { this.unregisterResponseHandler(handlerId); this.currentAllowedTools = null; } } private buildSDKUserMessage(prompt: string, images?: ImageAttachment[]): SDKUserMessage { const sessionId = this.sessionManager.getSessionId() || ''; if (!images || images.length === 0) { return { type: 'user', message: { role: 'user', content: prompt, }, parent_tool_use_id: null, session_id: sessionId, uuid: randomUUID(), }; } const content: UserContentBlock[] = []; for (const image of images) { content.push({ type: 'image', source: { type: 'base64', media_type: image.mediaType, data: image.data, }, }); } if (prompt.trim()) { content.push({ type: 'text', text: prompt, }); } return { type: 'user', message: { role: 'user', content, }, parent_tool_use_id: null, session_id: sessionId, uuid: randomUUID(), }; } /** * Apply dynamic updates to the persistent query before sending a message (Phase 1.6). */ private async applyDynamicUpdates( queryOptions?: QueryOptions, restartOptions?: ClosePersistentQueryOptions, allowRestart = true ): Promise { if (!this.persistentQuery) return; if (!this.vaultPath) { return; } const cliPath = this.plugin.getResolvedClaudeCliPath(); if (!cliPath) { return; } const selectedModel = queryOptions?.model || this.plugin.settings.model; const permissionMode = this.plugin.settings.permissionMode; // Model can always be updated dynamically if (this.currentConfig && selectedModel !== this.currentConfig.model) { try { await this.persistentQuery.setModel(selectedModel); this.currentConfig.model = selectedModel; } catch { new Notice('Failed to update model'); } } // Update thinking tokens for custom models (adaptive models don't need dynamic updates) if (!isAdaptiveThinkingModel(selectedModel)) { const budgetConfig = THINKING_BUDGETS.find(b => b.value === this.plugin.settings.thinkingBudget); const thinkingTokens = budgetConfig?.tokens ?? null; const currentThinking = this.currentConfig?.thinkingTokens ?? null; if (thinkingTokens !== currentThinking) { try { await this.persistentQuery.setMaxThinkingTokens(thinkingTokens); if (this.currentConfig) { this.currentConfig.thinkingTokens = thinkingTokens; } } catch { new Notice('Failed to update thinking budget'); } } } // Update permission mode if changed // Since we always start with allowDangerouslySkipPermissions: true, // we can dynamically switch between modes without restarting if (this.currentConfig && permissionMode !== this.currentConfig.permissionMode) { const sdkMode = this.mapToSDKPermissionMode(permissionMode); try { await this.persistentQuery.setPermissionMode(sdkMode); this.currentConfig.permissionMode = permissionMode; } catch { new Notice('Failed to update permission mode'); } } // Update MCP servers if changed const mcpMentions = queryOptions?.mcpMentions || new Set(); const uiEnabledServers = queryOptions?.enabledMcpServers || new Set(); const combinedMentions = new Set([...mcpMentions, ...uiEnabledServers]); const mcpServers = this.mcpManager.getActiveServers(combinedMentions); // Include full config in key so config changes (not just name changes) trigger update const mcpServersKey = JSON.stringify(mcpServers); if (this.currentConfig && mcpServersKey !== this.currentConfig.mcpServersKey) { // Convert to McpServerConfig format const serverConfigs: Record = {}; for (const [name, config] of Object.entries(mcpServers)) { serverConfigs[name] = config as McpServerConfig; } try { await this.persistentQuery.setMcpServers(serverConfigs); this.currentConfig.mcpServersKey = mcpServersKey; } catch { new Notice('Failed to update MCP servers'); } } // Track external context paths (used by hooks and for restart detection) const newExternalContextPaths = queryOptions?.externalContextPaths || []; this.currentExternalContextPaths = newExternalContextPaths; // Check for config changes that require restart if (!allowRestart) { return; } // Check if restart is needed using the valid cliPath we already have const newConfig = this.buildPersistentQueryConfig(this.vaultPath, cliPath, newExternalContextPaths); if (!this.needsRestart(newConfig)) { return; } // Restart is needed - use force to ensure query is closed even if CLI becomes unavailable const restarted = await this.ensureReady({ externalContextPaths: newExternalContextPaths, preserveHandlers: restartOptions?.preserveHandlers, force: true, }); // After restart, apply dynamic updates to the new process if (restarted && this.persistentQuery) { await this.applyDynamicUpdates(queryOptions, restartOptions, false); } } private isStreamTextEvent(message: SDKMessage): boolean { if (message.type !== 'stream_event') return false; const event = message.event; if (!event) return false; if (event.type === 'content_block_start') { return event.content_block?.type === 'text'; } if (event.type === 'content_block_delta') { return event.delta?.type === 'text_delta'; } return false; } private buildPromptWithImages(prompt: string, images?: ImageAttachment[]): string | AsyncGenerator { if (!images || images.length === 0) { return prompt; } const content: UserContentBlock[] = []; // Images before text (Claude recommendation for best quality) for (const image of images) { content.push({ type: 'image', source: { type: 'base64', media_type: image.mediaType, data: image.data, }, }); } if (prompt.trim()) { content.push({ type: 'text', text: prompt, }); } async function* messageGenerator() { yield { type: 'user', message: { role: 'user', content, }, }; } return messageGenerator(); } private async *queryViaSDK( prompt: string, cwd: string, cliPath: string, images?: ImageAttachment[], queryOptions?: QueryOptions ): AsyncGenerator { const selectedModel = queryOptions?.model || this.plugin.settings.model; this.sessionManager.setPendingModel(selectedModel); this.vaultPath = cwd; const queryPrompt = this.buildPromptWithImages(prompt, images); const baseContext = this.buildQueryOptionsContext(cwd, cliPath); const externalContextPaths = queryOptions?.externalContextPaths || []; const hooks = this.buildHooks(externalContextPaths); const hasEditorContext = prompt.includes(' 0) { const toolSet = new Set([...queryOptions.allowedTools, TOOL_SKILL]); allowedTools = [...toolSet]; } const ctx: ColdStartQueryContext = { ...baseContext, abortController: this.abortController ?? undefined, sessionId: this.sessionManager.getSessionId() ?? undefined, modelOverride: queryOptions?.model, canUseTool: this.createApprovalCallback(), hooks, mcpMentions: queryOptions?.mcpMentions, enabledMcpServers: queryOptions?.enabledMcpServers, allowedTools, hasEditorContext, externalContextPaths, }; const options = QueryOptionsBuilder.buildColdStartQueryOptions(ctx); let sawStreamText = false; try { const response = agentQuery({ prompt: queryPrompt, options }); let streamSessionId: string | null = this.sessionManager.getSessionId(); for await (const message of response) { if (this.isStreamTextEvent(message)) { sawStreamText = true; } if (this.abortController?.signal.aborted) { await response.interrupt(); break; } for (const event of transformSDKMessage(message, this.getTransformOptions(selectedModel))) { if (isSessionInitEvent(event)) { this.sessionManager.captureSession(event.sessionId); streamSessionId = event.sessionId; } else if (isStreamChunk(event)) { if (message.type === 'assistant' && sawStreamText && event.type === 'text') { continue; } if (event.type === 'usage') { yield { ...event, sessionId: streamSessionId }; } else { yield event; } } } if (message.type === 'result') { sawStreamText = false; } } } catch (error) { // Re-throw session expired errors for outer retry logic to handle if (isSessionExpiredError(error)) { throw error; } const msg = error instanceof Error ? error.message : 'Unknown error'; yield { type: 'error', content: msg }; } finally { this.sessionManager.clearPendingModel(); this.currentAllowedTools = null; // Clear tool restriction after query } yield { type: 'done' }; } cancel() { this.approvalDismisser?.(); if (this.abortController) { this.abortController.abort(); this.sessionManager.markInterrupted(); } // Interrupt persistent query (Phase 1.9) if (this.persistentQuery && !this.shuttingDown) { void this.persistentQuery.interrupt().catch(() => { // Silence abort/interrupt errors }); } } /** * Reset the conversation session. * Closes the persistent query since session is changing. */ resetSession() { // Close persistent query (new session will use cold-start resume) this.closePersistentQuery('session reset'); // Reset crash recovery for fresh start this.crashRecoveryAttempted = false; this.sessionManager.reset(); } getSessionId(): string | null { return this.sessionManager.getSessionId(); } /** Consume session invalidation flag for persistence updates. */ consumeSessionInvalidation(): boolean { return this.sessionManager.consumeInvalidation(); } /** * Check if the service is ready (persistent query is active). * Used to determine if SDK skills are available. */ isReady(): boolean { return this.isPersistentQueryActive(); } /** * Get supported commands (SDK skills) from the persistent query. * Returns an empty array if the query is not ready. */ async getSupportedCommands(): Promise { if (!this.persistentQuery) { return []; } try { const sdkCommands: SDKSlashCommand[] = await this.persistentQuery.supportedCommands(); return sdkCommands.map((cmd) => ({ id: `sdk:${cmd.name}`, name: cmd.name, description: cmd.description, argumentHint: cmd.argumentHint, content: '', // SDK skills don't need content - they're handled by the SDK source: 'sdk' as const, })); } catch { // Empty array on error is intentional: callers (SlashCommandDropdown) keep // sdkSkillsFetched=false on empty results, allowing automatic retry. return []; } } /** * Set the session ID (for restoring from saved conversation). * Closes persistent query synchronously if session is changing, then ensures query is ready. * * @param id - Session ID to restore, or null for new session * @param externalContextPaths - External context paths for the session (prevents stale contexts) */ setSessionId(id: string | null, externalContextPaths?: string[]): void { const currentId = this.sessionManager.getSessionId(); const sessionChanged = currentId !== id; // Close synchronously when session changes (maintains backwards compatibility) if (sessionChanged) { this.closePersistentQuery('session switch'); this.crashRecoveryAttempted = false; } this.sessionManager.setSessionId(id, this.plugin.settings.model); // Ensure query is ready with the new session ID and external contexts // Passing external contexts here prevents stale contexts from previous session this.ensureReady({ sessionId: id ?? undefined, externalContextPaths, }).catch(() => { // Best-effort, ignore failures }); } /** * Cleanup resources (Phase 5). * Called on plugin unload to close persistent query and abort any cold-start query. */ cleanup() { // Close persistent query this.closePersistentQuery('plugin cleanup'); // Cancel any in-flight cold-start query this.cancel(); this.resetSession(); } async rewindFiles(sdkUserUuid: string, dryRun?: boolean): Promise { if (!this.persistentQuery) throw new Error('No active query'); if (this.shuttingDown) throw new Error('Service is shutting down'); return this.persistentQuery.rewindFiles(sdkUserUuid, { dryRun }); } private resolveRewindFilePath(filePath: string): string { if (path.isAbsolute(filePath)) { return filePath; } if (this.vaultPath) { return path.join(this.vaultPath, filePath); } return filePath; } private async createRewindBackup(filesChanged: string[] | undefined): Promise<{ restore: () => Promise; cleanup: () => Promise; } | null> { if (!filesChanged || filesChanged.length === 0) { return null; } const backupRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'claudian-rewind-')); type BackupEntry = | { originalPath: string; existedBefore: false } | { originalPath: string; existedBefore: true; kind: 'file' | 'dir'; backupPath: string } | { originalPath: string; existedBefore: true; kind: 'symlink'; symlinkTarget: string }; const entries: BackupEntry[] = []; const copyDir = async (from: string, to: string): Promise => { await fs.promises.mkdir(to, { recursive: true }); const dirents = await fs.promises.readdir(from, { withFileTypes: true }); for (const dirent of dirents) { const srcPath = path.join(from, dirent.name); const destPath = path.join(to, dirent.name); if (dirent.isDirectory()) { await copyDir(srcPath, destPath); continue; } if (dirent.isSymbolicLink()) { const target = await fs.promises.readlink(srcPath); await fs.promises.symlink(target, destPath); continue; } if (dirent.isFile()) { await fs.promises.copyFile(srcPath, destPath); } } }; const backupPathForIndex = (i: number) => path.join(backupRoot, String(i)); for (let i = 0; i < filesChanged.length; i++) { const originalPath = this.resolveRewindFilePath(filesChanged[i]); try { const stats = await fs.promises.lstat(originalPath); if (stats.isSymbolicLink()) { const target = await fs.promises.readlink(originalPath); entries.push({ originalPath, existedBefore: true, kind: 'symlink', symlinkTarget: target }); continue; } const backupPath = backupPathForIndex(i); if (stats.isDirectory()) { await copyDir(originalPath, backupPath); entries.push({ originalPath, existedBefore: true, kind: 'dir', backupPath }); continue; } if (stats.isFile()) { await fs.promises.copyFile(originalPath, backupPath); entries.push({ originalPath, existedBefore: true, kind: 'file', backupPath }); continue; } // Unsupported file type; treat as non-existent for rollback purposes. entries.push({ originalPath, existedBefore: false }); } catch (error) { const err = error as NodeJS.ErrnoException; if (err && err.code === 'ENOENT') { entries.push({ originalPath, existedBefore: false }); continue; } await fs.promises.rm(backupRoot, { recursive: true, force: true }); throw error; } } const restore = async () => { const errors: unknown[] = []; for (const entry of entries) { try { if (!entry.existedBefore) { await fs.promises.rm(entry.originalPath, { recursive: true, force: true }); continue; } await fs.promises.rm(entry.originalPath, { recursive: true, force: true }); await fs.promises.mkdir(path.dirname(entry.originalPath), { recursive: true }); if (entry.kind === 'symlink') { await fs.promises.symlink(entry.symlinkTarget, entry.originalPath); continue; } if (entry.kind === 'dir') { await copyDir(entry.backupPath, entry.originalPath); continue; } await fs.promises.copyFile(entry.backupPath, entry.originalPath); } catch (error) { errors.push(error); } } if (errors.length > 0) { throw new Error(`Failed to restore ${errors.length} file(s) after rewind failure.`); } }; const cleanup = async () => { await fs.promises.rm(backupRoot, { recursive: true, force: true }); }; return { restore, cleanup }; } async rewind(sdkUserUuid: string, sdkAssistantUuid: string): Promise { // SDK only returns filesChanged/insertions/deletions on dry runs const preview = await this.rewindFiles(sdkUserUuid, true); if (!preview.canRewind) return preview; const backup = await this.createRewindBackup(preview.filesChanged); try { const result = await this.rewindFiles(sdkUserUuid); if (!result.canRewind) { await backup?.restore(); this.closePersistentQuery('rewind failed'); return result; } this.pendingResumeAt = sdkAssistantUuid; this.closePersistentQuery('rewind'); return { ...result, filesChanged: preview.filesChanged, insertions: preview.insertions, deletions: preview.deletions, }; } catch (error) { try { await backup?.restore(); } catch (rollbackError) { this.closePersistentQuery('rewind failed'); throw new Error( `Rewind failed and files could not be fully restored: ${rollbackError instanceof Error ? rollbackError.message : 'Unknown error'}` ); } this.closePersistentQuery('rewind failed'); throw new Error(`Rewind failed but files were restored: ${error instanceof Error ? error.message : 'Unknown error'}`); } finally { await backup?.cleanup(); } } setApprovalCallback(callback: ApprovalCallback | null) { this.approvalCallback = callback; } setApprovalDismisser(dismisser: (() => void) | null) { this.approvalDismisser = dismisser; } setAskUserQuestionCallback(callback: AskUserQuestionCallback | null) { this.askUserQuestionCallback = callback; } setExitPlanModeCallback(callback: ExitPlanModeCallback | null): void { this.exitPlanModeCallback = callback; } setPermissionModeSyncCallback(callback: ((sdkMode: string) => void) | null): void { this.permissionModeSyncCallback = callback; } setSubagentHookProvider(getState: () => SubagentHookState): void { this._subagentStateProvider = getState; } setAutoTurnCallback(callback: ((chunks: StreamChunk[]) => void) | null): void { this._autoTurnCallback = callback; } private createApprovalCallback(): CanUseTool { return async (toolName, input, options): Promise => { if (this.currentAllowedTools !== null) { if (!this.currentAllowedTools.includes(toolName) && toolName !== TOOL_SKILL) { const allowedList = this.currentAllowedTools.length > 0 ? ` Allowed tools: ${this.currentAllowedTools.join(', ')}.` : ' No tools are allowed for this query type.'; return { behavior: 'deny', message: `Tool "${toolName}" is not allowed for this query.${allowedList}`, }; } } // ExitPlanMode uses a dedicated callback — bypasses normal approval flow if (toolName === TOOL_EXIT_PLAN_MODE && this.exitPlanModeCallback) { try { const decision: ExitPlanModeDecision | null = await this.exitPlanModeCallback(input, options.signal); if (decision === null) { return { behavior: 'deny', message: 'User cancelled.', interrupt: true }; } if (decision.type === 'feedback') { return { behavior: 'deny', message: decision.text, interrupt: false }; } // Callback already restored plugin.settings.permissionMode const sdkMode = this.mapToSDKPermissionMode(this.plugin.settings.permissionMode); // Sync config so applyDynamicUpdates doesn't re-send if (this.currentConfig) { this.currentConfig.permissionMode = this.plugin.settings.permissionMode; } return { behavior: 'allow', updatedInput: input, updatedPermissions: [ { type: 'setMode', mode: sdkMode, destination: 'session' }, ], }; } catch (error) { return { behavior: 'deny', message: `Failed to handle plan mode exit: ${error instanceof Error ? error.message : 'Unknown error'}`, interrupt: true, }; } } // AskUserQuestion uses a dedicated callback — bypasses normal approval flow if (toolName === TOOL_ASK_USER_QUESTION && this.askUserQuestionCallback) { try { const answers = await this.askUserQuestionCallback(input, options.signal); if (answers === null) { return { behavior: 'deny', message: 'User declined to answer.', interrupt: true }; } return { behavior: 'allow', updatedInput: { ...input, answers } }; } catch (error) { return { behavior: 'deny', message: `Failed to get user answers: ${error instanceof Error ? error.message : 'Unknown error'}`, interrupt: true, }; } } // No pre-check — SDK already checked permanent rules before calling canUseTool if (!this.approvalCallback) { return { behavior: 'deny', message: 'No approval handler available.' }; } try { const { decisionReason, blockedPath, agentID } = options; const description = getActionDescription(toolName, input); const decision = await this.approvalCallback( toolName, input, description, { decisionReason, blockedPath, agentID } ); if (decision === 'cancel') { return { behavior: 'deny', message: 'User interrupted.', interrupt: true }; } if (decision === 'allow' || decision === 'allow-always') { const updatedPermissions = buildPermissionUpdates( toolName, input, decision, options.suggestions ); return { behavior: 'allow', updatedInput: input, updatedPermissions }; } return { behavior: 'deny', message: 'User denied this action.', interrupt: false }; } catch (error) { // Don't interrupt session — the deny message is sufficient for Claude // to try an alternative approach or ask the user. return { behavior: 'deny', message: `Approval request failed: ${error instanceof Error ? error.message : 'Unknown error'}`, interrupt: false, }; } }; } private mapToSDKPermissionMode(mode: PermissionMode): SDKPermissionMode { if (mode === 'yolo') return 'bypassPermissions'; if (mode === 'plan') return 'plan'; return 'acceptEdits'; } } ================================================ FILE: src/core/agent/MessageChannel.ts ================================================ /** * Message Channel * * Queue-based async iterable for persistent queries. * Handles message queuing, turn management, and text merging. */ import type { SDKUserMessage } from '@anthropic-ai/claude-agent-sdk'; import { MESSAGE_CHANNEL_CONFIG, type PendingMessage, type PendingTextMessage, } from './types'; /** * MessageChannel - Queue-based async iterable for persistent queries. * * Rules: * - Single in-flight turn at a time * - Text-only messages merge with \n\n while a turn is active * - Attachment messages (with images) queue one at a time; newer replaces older while turn is active * - Overflow policy: drop newest and warn */ export class MessageChannel implements AsyncIterable { private queue: PendingMessage[] = []; private turnActive = false; private closed = false; private resolveNext: ((value: IteratorResult) => void) | null = null; private currentSessionId: string | null = null; private onWarning: (message: string) => void; constructor(onWarning: (message: string) => void = () => {}) { this.onWarning = onWarning; } setSessionId(sessionId: string): void { this.currentSessionId = sessionId; } isTurnActive(): boolean { return this.turnActive; } isClosed(): boolean { return this.closed; } /** * Enqueue a message. If a turn is active: * - Text-only: merge with queued text (up to MAX_MERGED_CHARS) * - With attachments: replace any existing queued attachment (one at a time) */ enqueue(message: SDKUserMessage): void { if (this.closed) { throw new Error('MessageChannel is closed'); } const hasAttachments = this.messageHasAttachments(message); if (!this.turnActive) { if (this.resolveNext) { // Consumer is waiting - deliver immediately and mark turn active this.turnActive = true; const resolve = this.resolveNext; this.resolveNext = null; resolve({ value: message, done: false }); } else { // No consumer waiting yet - queue for later pickup by next() // Don't set turnActive here; next() will set it when it dequeues if (this.queue.length >= MESSAGE_CHANNEL_CONFIG.MAX_QUEUED_MESSAGES) { this.onWarning(`[MessageChannel] Queue full (${MESSAGE_CHANNEL_CONFIG.MAX_QUEUED_MESSAGES}), dropping newest`); return; } if (hasAttachments) { this.queue.push({ type: 'attachment', message }); } else { this.queue.push({ type: 'text', content: this.extractTextContent(message) }); } } return; } // Turn is active - queue the message if (hasAttachments) { // Non-text messages are deferred as-is (one at a time) // Find existing attachment message or add new one const existingIdx = this.queue.findIndex(m => m.type === 'attachment'); if (existingIdx >= 0) { // Replace existing (newer takes precedence for attachments) this.queue[existingIdx] = { type: 'attachment', message }; this.onWarning('[MessageChannel] Attachment message replaced (only one can be queued)'); } else { this.queue.push({ type: 'attachment', message }); } return; } // Text-only - merge with existing text in queue const textContent = this.extractTextContent(message); const existingTextIdx = this.queue.findIndex(m => m.type === 'text'); if (existingTextIdx >= 0) { const existing = this.queue[existingTextIdx] as PendingTextMessage; const mergedContent = existing.content + '\n\n' + textContent; // Check merged size if (mergedContent.length > MESSAGE_CHANNEL_CONFIG.MAX_MERGED_CHARS) { this.onWarning(`[MessageChannel] Merged content exceeds ${MESSAGE_CHANNEL_CONFIG.MAX_MERGED_CHARS} chars, dropping newest`); return; } existing.content = mergedContent; } else { // No existing text - add new if (this.queue.length >= MESSAGE_CHANNEL_CONFIG.MAX_QUEUED_MESSAGES) { this.onWarning(`[MessageChannel] Queue full (${MESSAGE_CHANNEL_CONFIG.MAX_QUEUED_MESSAGES}), dropping newest`); return; } this.queue.push({ type: 'text', content: textContent }); } } onTurnComplete(): void { this.turnActive = false; if (this.queue.length > 0 && this.resolveNext) { const pending = this.queue.shift()!; this.turnActive = true; const resolve = this.resolveNext; this.resolveNext = null; resolve({ value: this.pendingToMessage(pending), done: false }); } } close(): void { this.closed = true; this.queue = []; if (this.resolveNext) { const resolve = this.resolveNext; this.resolveNext = null; resolve({ value: undefined, done: true } as IteratorResult); } } reset(): void { this.queue = []; this.turnActive = false; this.closed = false; this.resolveNext = null; } getQueueLength(): number { return this.queue.length; } [Symbol.asyncIterator](): AsyncIterator { return { next: (): Promise> => { if (this.closed) { return Promise.resolve({ value: undefined, done: true } as IteratorResult); } // If there's a queued message and no active turn, return it if (this.queue.length > 0 && !this.turnActive) { const pending = this.queue.shift()!; this.turnActive = true; return Promise.resolve({ value: this.pendingToMessage(pending), done: false }); } // Wait for next message return new Promise((resolve) => { this.resolveNext = resolve; }); }, }; } private messageHasAttachments(message: SDKUserMessage): boolean { if (!message.message?.content) return false; if (typeof message.message.content === 'string') return false; return message.message.content.some((block: { type: string }) => block.type === 'image'); } private extractTextContent(message: SDKUserMessage): string { if (!message.message?.content) return ''; if (typeof message.message.content === 'string') return message.message.content; return message.message.content .filter((block: { type: string }): block is { type: 'text'; text: string } => block.type === 'text') .map((block: { type: 'text'; text: string }) => block.text) .join('\n\n'); } private pendingToMessage(pending: PendingMessage): SDKUserMessage { if (pending.type === 'attachment') { return pending.message; } return { type: 'user', message: { role: 'user', content: pending.content, }, parent_tool_use_id: null, session_id: this.currentSessionId || '', }; } } ================================================ FILE: src/core/agent/QueryOptionsBuilder.ts ================================================ /** * QueryOptionsBuilder - SDK Options Construction * * Extracts options-building logic from ClaudianService for: * - Persistent query options (warm path) * - Cold-start query options * - Configuration change detection * * Design: Static builder methods that take a context object containing * all required dependencies (settings, managers, paths). */ import type { CanUseTool, Options, } from '@anthropic-ai/claude-agent-sdk'; import type { McpServerManager } from '../mcp'; import type { PluginManager } from '../plugins'; import { buildSystemPrompt, type SystemPromptSettings } from '../prompts/mainAgent'; import type { ClaudianSettings, PermissionMode } from '../types'; import { isAdaptiveThinkingModel, THINKING_BUDGETS } from '../types'; import { createCustomSpawnFunction } from './customSpawn'; import { computeSystemPromptKey, DISABLED_BUILTIN_SUBAGENTS, type PersistentQueryConfig, UNSUPPORTED_SDK_TOOLS, } from './types'; /** * Context required for building SDK options. * Passed to builder methods to avoid direct dependencies on ClaudianService. */ export interface QueryOptionsContext { /** Absolute path to the vault root. */ vaultPath: string; /** Path to the Claude CLI executable. */ cliPath: string; /** Current plugin settings. */ settings: ClaudianSettings; /** Parsed environment variables (from settings). */ customEnv: Record; /** Enhanced PATH with CLI directories. */ enhancedPath: string; /** MCP server manager for server configuration. */ mcpManager: McpServerManager; /** Plugin manager for Claude Code plugins. */ pluginManager: PluginManager; } /** * Additional context for persistent query options. */ export interface PersistentQueryContext extends QueryOptionsContext { /** AbortController for the query. */ abortController?: AbortController; /** Session resume state (sessionId required; sessionAt and fork only meaningful with a session). */ resume?: { sessionId: string; /** Assistant UUID for resumeSessionAt after rewind. */ sessionAt?: string; /** Fork the session (non-destructive branch). */ fork?: boolean; }; /** Approval callback for normal mode. */ canUseTool?: CanUseTool; /** Pre-built hooks array. */ hooks: Options['hooks']; /** External context paths for additionalDirectories SDK option. */ externalContextPaths?: string[]; } /** * Additional context for cold-start query options. */ export interface ColdStartQueryContext extends QueryOptionsContext { /** AbortController for the query. */ abortController?: AbortController; /** Session ID for resuming a conversation. */ sessionId?: string; /** Optional model override for cold-start queries. */ modelOverride?: string; /** Approval callback for normal mode. */ canUseTool?: CanUseTool; /** Pre-built hooks array. */ hooks: Options['hooks']; /** MCP server @-mentions from the query. */ mcpMentions?: Set; /** MCP servers enabled via UI selector. */ enabledMcpServers?: Set; /** Allowed tools restriction (undefined = no restriction). */ allowedTools?: string[]; /** Whether the query has editor context. */ hasEditorContext: boolean; /** External context paths for additionalDirectories SDK option. */ externalContextPaths?: string[]; } /** Static builder for SDK Options and configuration objects. */ export class QueryOptionsBuilder { /** * Some changes (model, thinking tokens) can be updated dynamically; others require restart. */ static needsRestart( currentConfig: PersistentQueryConfig | null, newConfig: PersistentQueryConfig ): boolean { if (!currentConfig) return true; // These require restart (cannot be updated dynamically) if (currentConfig.systemPromptKey !== newConfig.systemPromptKey) return true; if (currentConfig.disallowedToolsKey !== newConfig.disallowedToolsKey) return true; if (currentConfig.pluginsKey !== newConfig.pluginsKey) return true; if (currentConfig.settingSources !== newConfig.settingSources) return true; if (currentConfig.claudeCliPath !== newConfig.claudeCliPath) return true; // Note: Permission mode is handled dynamically via setPermissionMode() in ClaudianService. // Since allowDangerouslySkipPermissions is always true, both directions work without restart. if (currentConfig.enableChrome !== newConfig.enableChrome) return true; // Effort level requires restart (no setEffort() on persistent query) if (currentConfig.effortLevel !== newConfig.effortLevel) return true; // Export paths affect system prompt if (QueryOptionsBuilder.pathsChanged(currentConfig.allowedExportPaths, newConfig.allowedExportPaths)) { return true; } // External context paths require restart (additionalDirectories can't be updated dynamically) if (QueryOptionsBuilder.pathsChanged(currentConfig.externalContextPaths, newConfig.externalContextPaths)) { return true; } return false; } /** Builds configuration snapshot for restart detection. */ static buildPersistentQueryConfig( ctx: QueryOptionsContext, externalContextPaths?: string[] ): PersistentQueryConfig { const systemPromptSettings: SystemPromptSettings = { mediaFolder: ctx.settings.mediaFolder, customPrompt: ctx.settings.systemPrompt, allowedExportPaths: ctx.settings.allowedExportPaths, allowExternalAccess: ctx.settings.allowExternalAccess, vaultPath: ctx.vaultPath, userName: ctx.settings.userName, }; const budgetSetting = ctx.settings.thinkingBudget; const budgetConfig = THINKING_BUDGETS.find(b => b.value === budgetSetting); const thinkingTokens = budgetConfig?.tokens ?? null; // Compute disallowedToolsKey from all disabled MCP tools (pre-registered upfront) const allDisallowedTools = ctx.mcpManager.getAllDisallowedMcpTools(); const disallowedToolsKey = allDisallowedTools.join('|'); // Compute pluginsKey from active plugins const pluginsKey = ctx.pluginManager.getPluginsKey(); return { model: ctx.settings.model, thinkingTokens: thinkingTokens && thinkingTokens > 0 ? thinkingTokens : null, effortLevel: isAdaptiveThinkingModel(ctx.settings.model) ? ctx.settings.effortLevel : null, permissionMode: ctx.settings.permissionMode, systemPromptKey: computeSystemPromptKey(systemPromptSettings), disallowedToolsKey, mcpServersKey: '', // Dynamic via setMcpServers, not tracked for restart pluginsKey, externalContextPaths: externalContextPaths || [], allowedExportPaths: ctx.settings.allowedExportPaths, settingSources: ctx.settings.loadUserClaudeSettings ? 'user,project' : 'project', claudeCliPath: ctx.cliPath, enableChrome: ctx.settings.enableChrome, }; } /** Builds SDK options for the persistent query. */ static buildPersistentQueryOptions(ctx: PersistentQueryContext): Options { const permissionMode = ctx.settings.permissionMode; const systemPrompt = buildSystemPrompt({ mediaFolder: ctx.settings.mediaFolder, customPrompt: ctx.settings.systemPrompt, allowedExportPaths: ctx.settings.allowedExportPaths, allowExternalAccess: ctx.settings.allowExternalAccess, vaultPath: ctx.vaultPath, userName: ctx.settings.userName, }); const options: Options = { cwd: ctx.vaultPath, systemPrompt, model: ctx.settings.model, abortController: ctx.abortController, pathToClaudeCodeExecutable: ctx.cliPath, settingSources: ctx.settings.loadUserClaudeSettings ? ['user', 'project'] : ['project'], env: { ...process.env, ...ctx.customEnv, PATH: ctx.enhancedPath, }, includePartialMessages: true, }; QueryOptionsBuilder.applyExtraArgs(options, ctx.settings); options.disallowedTools = [ ...ctx.mcpManager.getAllDisallowedMcpTools(), ...UNSUPPORTED_SDK_TOOLS, ...DISABLED_BUILTIN_SUBAGENTS, ]; QueryOptionsBuilder.applyPermissionMode(options, permissionMode, ctx.canUseTool); QueryOptionsBuilder.applyThinking(options, ctx.settings, ctx.settings.model); options.hooks = ctx.hooks; options.enableFileCheckpointing = true; if (ctx.resume) { options.resume = ctx.resume.sessionId; if (ctx.resume.sessionAt) { options.resumeSessionAt = ctx.resume.sessionAt; } if (ctx.resume.fork) { options.forkSession = true; } } if (ctx.externalContextPaths && ctx.externalContextPaths.length > 0) { options.additionalDirectories = ctx.externalContextPaths; } options.spawnClaudeCodeProcess = createCustomSpawnFunction(ctx.enhancedPath); return options; } /** Builds SDK options for a cold-start query. */ static buildColdStartQueryOptions(ctx: ColdStartQueryContext): Options { const permissionMode = ctx.settings.permissionMode; const selectedModel = ctx.modelOverride ?? ctx.settings.model; const systemPrompt = buildSystemPrompt({ mediaFolder: ctx.settings.mediaFolder, customPrompt: ctx.settings.systemPrompt, allowedExportPaths: ctx.settings.allowedExportPaths, allowExternalAccess: ctx.settings.allowExternalAccess, vaultPath: ctx.vaultPath, userName: ctx.settings.userName, }); const options: Options = { cwd: ctx.vaultPath, systemPrompt, model: selectedModel, abortController: ctx.abortController, pathToClaudeCodeExecutable: ctx.cliPath, // User settings may contain permission rules that bypass Claudian's permission system settingSources: ctx.settings.loadUserClaudeSettings ? ['user', 'project'] : ['project'], env: { ...process.env, ...ctx.customEnv, PATH: ctx.enhancedPath, }, includePartialMessages: true, }; QueryOptionsBuilder.applyExtraArgs(options, ctx.settings); const mcpMentions = ctx.mcpMentions || new Set(); const uiEnabledServers = ctx.enabledMcpServers || new Set(); const combinedMentions = new Set([...mcpMentions, ...uiEnabledServers]); const mcpServers = ctx.mcpManager.getActiveServers(combinedMentions); if (Object.keys(mcpServers).length > 0) { options.mcpServers = mcpServers; } const disallowedMcpTools = ctx.mcpManager.getDisallowedMcpTools(combinedMentions); options.disallowedTools = [ ...disallowedMcpTools, ...UNSUPPORTED_SDK_TOOLS, ...DISABLED_BUILTIN_SUBAGENTS, ]; QueryOptionsBuilder.applyPermissionMode(options, permissionMode, ctx.canUseTool); options.hooks = ctx.hooks; QueryOptionsBuilder.applyThinking(options, ctx.settings, ctx.modelOverride ?? ctx.settings.model); if (ctx.allowedTools !== undefined && ctx.allowedTools.length > 0) { options.tools = ctx.allowedTools; } if (ctx.sessionId) { options.resume = ctx.sessionId; } if (ctx.externalContextPaths && ctx.externalContextPaths.length > 0) { options.additionalDirectories = ctx.externalContextPaths; } options.spawnClaudeCodeProcess = createCustomSpawnFunction(ctx.enhancedPath); return options; } /** * Always sets allowDangerouslySkipPermissions: true to enable dynamic * switching between permission modes without requiring a process restart. */ private static applyPermissionMode( options: Options, permissionMode: PermissionMode, canUseTool?: CanUseTool ): void { options.allowDangerouslySkipPermissions = true; if (canUseTool) { options.canUseTool = canUseTool; } if (permissionMode === 'yolo') { options.permissionMode = 'bypassPermissions'; } else if (permissionMode === 'plan') { options.permissionMode = 'plan'; } else { options.permissionMode = 'acceptEdits'; } } private static applyExtraArgs(options: Options, settings: ClaudianSettings): void { if (settings.enableChrome) { options.extraArgs = { ...options.extraArgs, chrome: null }; } } private static applyThinking( options: Options, settings: ClaudianSettings, model: string ): void { if (isAdaptiveThinkingModel(model)) { options.thinking = { type: 'adaptive' }; options.effort = settings.effortLevel; } else { const budgetConfig = THINKING_BUDGETS.find(b => b.value === settings.thinkingBudget); if (budgetConfig && budgetConfig.tokens > 0) { options.maxThinkingTokens = budgetConfig.tokens; } } } private static pathsChanged(a?: string[], b?: string[]): boolean { const aKey = [...(a || [])].sort().join('|'); const bKey = [...(b || [])].sort().join('|'); return aKey !== bKey; } } ================================================ FILE: src/core/agent/SessionManager.ts ================================================ /** * Session Manager * * Manages SDK session state including session ID, model tracking, * and interruption state. */ import type { ClaudeModel } from '../types'; import type { SessionState } from './types'; /** * Manages session state for the Claude Agent SDK. * * Tracks: * - Session ID: Unique identifier for the conversation * - Session model: The model used for this session * - Pending model: Model to use when session is captured * - Interrupted state: Whether the session was interrupted * * Typical flow: * 1. setPendingModel() - before starting a query * 2. captureSession() - when session_id received from SDK * 3. invalidateSession() - when session expires or errors occur */ export class SessionManager { private state: SessionState = { sessionId: null, sessionModel: null, pendingSessionModel: null, wasInterrupted: false, needsHistoryRebuild: false, sessionInvalidated: false, }; getSessionId(): string | null { return this.state.sessionId; } setSessionId(id: string | null, defaultModel?: ClaudeModel): void { this.state.sessionId = id; this.state.sessionModel = id ? (defaultModel ?? null) : null; // Clear rebuild flag when switching sessions to prevent carrying over to different conversation this.state.needsHistoryRebuild = false; // Clear invalidation flag when explicitly setting session this.state.sessionInvalidated = false; } wasInterrupted(): boolean { return this.state.wasInterrupted; } markInterrupted(): void { this.state.wasInterrupted = true; } clearInterrupted(): void { this.state.wasInterrupted = false; } setPendingModel(model: ClaudeModel): void { this.state.pendingSessionModel = model; } clearPendingModel(): void { this.state.pendingSessionModel = null; } /** * Captures a session ID from SDK response. * Detects mismatch if we had a different session ID before (context lost). */ captureSession(sessionId: string): void { // Detect mismatch: we had a session, but SDK gave us a different one const hadSession = this.state.sessionId !== null; const isDifferent = this.state.sessionId !== sessionId; if (hadSession && isDifferent) { // SDK lost our session context - need to rebuild history on next message this.state.needsHistoryRebuild = true; } this.state.sessionId = sessionId; this.state.sessionModel = this.state.pendingSessionModel; this.state.pendingSessionModel = null; this.state.sessionInvalidated = false; } needsHistoryRebuild(): boolean { return this.state.needsHistoryRebuild; } clearHistoryRebuild(): void { this.state.needsHistoryRebuild = false; } invalidateSession(): void { this.state.sessionId = null; this.state.sessionModel = null; this.state.sessionInvalidated = true; } /** Consume the invalidation flag (returns true once). */ consumeInvalidation(): boolean { const wasInvalidated = this.state.sessionInvalidated; this.state.sessionInvalidated = false; return wasInvalidated; } reset(): void { this.state = { sessionId: null, sessionModel: null, pendingSessionModel: null, wasInterrupted: false, needsHistoryRebuild: false, sessionInvalidated: false, }; } } ================================================ FILE: src/core/agent/customSpawn.ts ================================================ /** * Custom spawn logic for Claude Agent SDK. * * Provides a custom spawn function that resolves the full path to Node.js * instead of relying on PATH lookup. This fixes issues in GUI apps (like Obsidian) * where the minimal PATH doesn't include Node.js. */ import type { SpawnedProcess, SpawnOptions } from '@anthropic-ai/claude-agent-sdk'; import { spawn } from 'child_process'; import { findNodeExecutable } from '../../utils/env'; export function createCustomSpawnFunction( enhancedPath: string ): (options: SpawnOptions) => SpawnedProcess { return (options: SpawnOptions): SpawnedProcess => { let { command } = options; const { args, cwd, env, signal } = options; const shouldPipeStderr = !!env?.DEBUG_CLAUDE_AGENT_SDK; // Resolve full path to avoid PATH lookup issues in GUI apps if (command === 'node') { const nodeFullPath = findNodeExecutable(enhancedPath); if (nodeFullPath) { command = nodeFullPath; } } // Do not pass `signal` directly to spawn() — Obsidian's Electron runtime // uses a different realm for AbortSignal, causing `instanceof EventTarget` // checks inside Node's internals to fail. Handle abort manually instead. const child = spawn(command, args, { cwd, env: env as NodeJS.ProcessEnv, stdio: ['pipe', 'pipe', shouldPipeStderr ? 'pipe' : 'ignore'], windowsHide: true, }); if (signal) { if (signal.aborted) { child.kill(); } else { signal.addEventListener('abort', () => child.kill(), { once: true }); } } if (shouldPipeStderr && child.stderr && typeof child.stderr.on === 'function') { child.stderr.on('data', () => {}); } if (!child.stdin || !child.stdout) { throw new Error('Failed to create process streams'); } return child as unknown as SpawnedProcess; }; } ================================================ FILE: src/core/agent/index.ts ================================================ export { type ApprovalCallback, type ApprovalCallbackOptions, ClaudianService, type QueryOptions } from './ClaudianService'; export { MessageChannel } from './MessageChannel'; export { type ColdStartQueryContext, type PersistentQueryContext, QueryOptionsBuilder, type QueryOptionsContext, } from './QueryOptionsBuilder'; export { SessionManager } from './SessionManager'; export type { ClosePersistentQueryOptions, PersistentQueryConfig, ResponseHandler, SessionState, UserContentBlock, } from './types'; ================================================ FILE: src/core/agent/types.ts ================================================ /** * Types and constants for the ClaudianService module. */ import type { SDKMessage, SDKUserMessage } from '@anthropic-ai/claude-agent-sdk'; import type { SystemPromptSettings } from '../prompts/mainAgent'; import type { ClaudeModel, EffortLevel, PermissionMode, StreamChunk } from '../types'; export interface TextContentBlock { type: 'text'; text: string; } export interface ImageContentBlock { type: 'image'; source: { type: 'base64'; media_type: string; data: string; }; } export type UserContentBlock = TextContentBlock | ImageContentBlock; /** Overflow: newest message is dropped with a warning. */ export const MESSAGE_CHANNEL_CONFIG = { MAX_QUEUED_MESSAGES: 8, // Memory protection from rapid user input MAX_MERGED_CHARS: 12000, // ~3k tokens — batch size under context limits } as const; /** Pending message in the queue (text-only for merging). */ export interface PendingTextMessage { type: 'text'; content: string; } /** Pending message with attachments (cannot be merged). */ export interface PendingAttachmentMessage { type: 'attachment'; message: SDKUserMessage; } export type PendingMessage = PendingTextMessage | PendingAttachmentMessage; export interface ClosePersistentQueryOptions { preserveHandlers?: boolean; } /** * Handler for routing stream chunks to the appropriate query caller. * * Lifecycle: * 1. Created: Handler is registered via registerResponseHandler() when a query starts * 2. Receiving: Chunks arrive via onChunk(), sawAnyChunk and sawStreamText track state * 3. Terminated: Exactly one of onDone() or onError() is called when the turn ends * * Invariants: * - Only one handler is active at a time (MessageChannel enforces single-turn) * - After onDone()/onError(), the handler is unregistered and should not receive more chunks * - sawAnyChunk is used for crash recovery (restart if no chunks seen before error) * - sawStreamText prevents duplicate text from non-streamed assistant messages */ export interface ResponseHandler { readonly id: string; onChunk: (chunk: StreamChunk) => void; onDone: () => void; onError: (error: Error) => void; readonly sawStreamText: boolean; readonly sawAnyChunk: boolean; markStreamTextSeen(): void; resetStreamText(): void; markChunkSeen(): void; } export interface ResponseHandlerOptions { id: string; onChunk: (chunk: StreamChunk) => void; onDone: () => void; onError: (error: Error) => void; } export function createResponseHandler(options: ResponseHandlerOptions): ResponseHandler { let _sawStreamText = false; let _sawAnyChunk = false; return { id: options.id, onChunk: options.onChunk, onDone: options.onDone, onError: options.onError, get sawStreamText() { return _sawStreamText; }, get sawAnyChunk() { return _sawAnyChunk; }, markStreamTextSeen() { _sawStreamText = true; }, resetStreamText() { _sawStreamText = false; }, markChunkSeen() { _sawAnyChunk = true; }, }; } /** Tracked configuration for detecting changes that require restart. */ export interface PersistentQueryConfig { model: string | null; thinkingTokens: number | null; effortLevel: EffortLevel | null; permissionMode: PermissionMode | null; systemPromptKey: string; disallowedToolsKey: string; mcpServersKey: string; pluginsKey: string; externalContextPaths: string[]; allowedExportPaths: string[]; settingSources: string; claudeCliPath: string; enableChrome: boolean; // Whether --chrome flag is passed to CLI } export interface SessionState { sessionId: string | null; sessionModel: ClaudeModel | null; pendingSessionModel: ClaudeModel | null; wasInterrupted: boolean; /** Set when SDK returns a different session ID than expected (context lost). */ needsHistoryRebuild: boolean; /** Set when the current session is invalidated by SDK errors. */ sessionInvalidated: boolean; } export const UNSUPPORTED_SDK_TOOLS = [] as const; /** Built-in subagents that don't apply to Obsidian context. */ export const DISABLED_BUILTIN_SUBAGENTS = [ 'Task(statusline-setup)', ] as const; export function isTurnCompleteMessage(message: SDKMessage): boolean { return message.type === 'result'; } export function computeSystemPromptKey(settings: SystemPromptSettings): string { // Include only fields surfaced in the system prompt to avoid stale cache hits. const parts = [ settings.mediaFolder || '', settings.customPrompt || '', (settings.allowedExportPaths || []).sort().join('|'), settings.vaultPath || '', (settings.userName || '').trim(), String(settings.allowExternalAccess ?? false), // Note: hasEditorContext is per-message, not tracked here ]; return parts.join('::'); } ================================================ FILE: src/core/agents/AgentManager.ts ================================================ /** * Agent load order (earlier sources take precedence for duplicate IDs): * 0. Built-in agents: dynamically provided via SDK init message * 1. Plugin agents: {installPath}/agents/*.md (namespaced as plugin-name:agent-name) * 2. Vault agents: {vaultPath}/.claude/agents/*.md * 3. Global agents: ~/.claude/agents/*.md */ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import type { PluginManager } from '../plugins'; import type { AgentDefinition } from '../types'; import { buildAgentFromFrontmatter, parseAgentFile } from './AgentStorage'; const GLOBAL_AGENTS_DIR = path.join(os.homedir(), '.claude', 'agents'); const VAULT_AGENTS_DIR = '.claude/agents'; const PLUGIN_AGENTS_DIR = 'agents'; // Fallback built-in agent names for before the init message arrives. const FALLBACK_BUILTIN_AGENT_NAMES = ['Explore', 'Plan', 'Bash', 'general-purpose']; const BUILTIN_AGENT_DESCRIPTIONS: Record = { 'Explore': 'Fast codebase exploration and search', 'Plan': 'Implementation planning and architecture', 'Bash': 'Command execution specialist', 'general-purpose': 'Multi-step tasks and complex workflows', }; function makeBuiltinAgent(name: string): AgentDefinition { return { id: name, name: name.replace(/-/g, ' ').replace(/\b\w/g, l => l.toUpperCase()), description: BUILTIN_AGENT_DESCRIPTIONS[name] ?? '', prompt: '', // Built-in — prompt managed by SDK source: 'builtin', }; } function normalizePluginName(name: string): string { return name.toLowerCase().replace(/\s+/g, '-'); } export class AgentManager { private agents: AgentDefinition[] = []; private builtinAgentNames: string[] = FALLBACK_BUILTIN_AGENT_NAMES; private vaultPath: string; private pluginManager: PluginManager; constructor(vaultPath: string, pluginManager: PluginManager) { this.vaultPath = vaultPath; this.pluginManager = pluginManager; } /** Built-in agents are those from init that are NOT loaded from files. */ setBuiltinAgentNames(names: string[]): void { this.builtinAgentNames = names; // Rebuild agents to reflect the new built-in list const fileAgentIds = new Set( this.agents.filter(a => a.source !== 'builtin').map(a => a.id) ); // Replace built-in entries with updated list this.agents = [ ...names.filter(n => !fileAgentIds.has(n)).map(makeBuiltinAgent), ...this.agents.filter(a => a.source !== 'builtin'), ]; } async loadAgents(): Promise { this.agents = []; // 0. Add built-in agents first (from init message or fallback) this.agents.push(...this.builtinAgentNames.map(makeBuiltinAgent)); // Each category is independently try-caught so one failure doesn't block others try { await this.loadPluginAgents(); } catch { /* non-critical */ } try { await this.loadVaultAgents(); } catch { /* non-critical */ } try { await this.loadGlobalAgents(); } catch { /* non-critical */ } } getAvailableAgents(): AgentDefinition[] { return [...this.agents]; } getAgentById(id: string): AgentDefinition | undefined { return this.agents.find(a => a.id === id); } /** Used for @-mention filtering in the chat input. */ searchAgents(query: string): AgentDefinition[] { const q = query.toLowerCase(); return this.agents.filter(a => a.name.toLowerCase().includes(q) || a.id.toLowerCase().includes(q) || a.description.toLowerCase().includes(q) ); } private async loadPluginAgents(): Promise { for (const plugin of this.pluginManager.getPlugins()) { if (!plugin.enabled) continue; const agentsDir = path.join(plugin.installPath, PLUGIN_AGENTS_DIR); if (!fs.existsSync(agentsDir)) continue; for (const filePath of this.listMarkdownFiles(agentsDir)) { const agent = await this.parsePluginAgentFromFile(filePath, plugin.name); if (agent) this.agents.push(agent); } } } private async loadVaultAgents(): Promise { await this.loadAgentsFromDirectory(path.join(this.vaultPath, VAULT_AGENTS_DIR), 'vault'); } private async loadGlobalAgents(): Promise { await this.loadAgentsFromDirectory(GLOBAL_AGENTS_DIR, 'global'); } private async loadAgentsFromDirectory( dir: string, source: 'vault' | 'global' ): Promise { if (!fs.existsSync(dir)) return; for (const filePath of this.listMarkdownFiles(dir)) { const agent = await this.parseAgentFromFile(filePath, source); if (agent) this.agents.push(agent); } } private listMarkdownFiles(dir: string): string[] { const files: string[] = []; try { const entries = fs.readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { if (entry.isFile() && entry.name.endsWith('.md')) { files.push(path.join(dir, entry.name)); } } } catch { // Non-critical: directory may be unreadable } return files; } private async parsePluginAgentFromFile( filePath: string, pluginName: string ): Promise { try { const content = fs.readFileSync(filePath, 'utf-8'); const parsed = parseAgentFile(content); if (!parsed) return null; const { frontmatter, body } = parsed; const normalizedPluginName = normalizePluginName(pluginName); const id = `${normalizedPluginName}:${frontmatter.name}`; if (this.agents.find(a => a.id === id)) return null; return buildAgentFromFrontmatter(frontmatter, body, { id, source: 'plugin', pluginName, filePath, }); } catch { return null; } } private async parseAgentFromFile( filePath: string, source: 'vault' | 'global' ): Promise { try { const content = fs.readFileSync(filePath, 'utf-8'); const parsed = parseAgentFile(content); if (!parsed) return null; const { frontmatter, body } = parsed; const id = frontmatter.name; if (this.agents.find(a => a.id === id)) return null; return buildAgentFromFrontmatter(frontmatter, body, { id, source, filePath, }); } catch { return null; } } } ================================================ FILE: src/core/agents/AgentStorage.ts ================================================ import { extractStringArray, isRecord, normalizeStringArray, parseFrontmatter } from '../../utils/frontmatter'; import { AGENT_PERMISSION_MODES, type AgentDefinition, type AgentFrontmatter, type AgentPermissionMode } from '../types'; const KNOWN_AGENT_KEYS = new Set([ 'name', 'description', 'tools', 'disallowedTools', 'model', 'skills', 'permissionMode', 'hooks', ]); export function parseAgentFile(content: string): { frontmatter: AgentFrontmatter; body: string } | null { const parsed = parseFrontmatter(content); if (!parsed) return null; const { frontmatter: fm, body } = parsed; const name = fm.name; const description = fm.description; if (typeof name !== 'string' || !name.trim()) return null; if (typeof description !== 'string' || !description.trim()) return null; const tools = fm.tools; const disallowedTools = fm.disallowedTools; if (tools !== undefined && !isStringOrArray(tools)) return null; if (disallowedTools !== undefined && !isStringOrArray(disallowedTools)) return null; const model = typeof fm.model === 'string' ? fm.model : undefined; const extra: Record = {}; for (const key of Object.keys(fm)) { if (!KNOWN_AGENT_KEYS.has(key)) { extra[key] = fm[key]; } } const frontmatter: AgentFrontmatter = { name, description, tools, disallowedTools, model, skills: extractStringArray(fm, 'skills'), permissionMode: typeof fm.permissionMode === 'string' ? fm.permissionMode : undefined, hooks: isRecord(fm.hooks) ? fm.hooks : undefined, extraFrontmatter: Object.keys(extra).length > 0 ? extra : undefined, }; return { frontmatter, body: body.trim() }; } function isStringOrArray(value: unknown): value is string | string[] { return typeof value === 'string' || Array.isArray(value); } export function parseToolsList(tools?: string | string[]): string[] | undefined { return normalizeStringArray(tools); } export function parsePermissionMode(mode?: string): AgentPermissionMode | undefined { if (!mode) return undefined; const trimmed = mode.trim(); if ((AGENT_PERMISSION_MODES as readonly string[]).includes(trimmed)) { return trimmed as AgentPermissionMode; } return undefined; } const VALID_MODELS = ['sonnet', 'opus', 'haiku', 'inherit'] as const; export function parseModel(model?: string): 'sonnet' | 'opus' | 'haiku' | 'inherit' { if (!model) return 'inherit'; const normalized = model.toLowerCase().trim(); if (VALID_MODELS.includes(normalized as typeof VALID_MODELS[number])) { return normalized as 'sonnet' | 'opus' | 'haiku' | 'inherit'; } return 'inherit'; } export function buildAgentFromFrontmatter( frontmatter: AgentFrontmatter, body: string, meta: { id: string; source: AgentDefinition['source']; filePath?: string; pluginName?: string } ): AgentDefinition { return { id: meta.id, name: frontmatter.name, description: frontmatter.description, prompt: body, tools: parseToolsList(frontmatter.tools), disallowedTools: parseToolsList(frontmatter.disallowedTools), model: parseModel(frontmatter.model), source: meta.source, filePath: meta.filePath, pluginName: meta.pluginName, skills: frontmatter.skills, permissionMode: parsePermissionMode(frontmatter.permissionMode), hooks: frontmatter.hooks, extraFrontmatter: frontmatter.extraFrontmatter, }; } ================================================ FILE: src/core/agents/index.ts ================================================ export { AgentManager } from './AgentManager'; export { buildAgentFromFrontmatter, parseAgentFile } from './AgentStorage'; ================================================ FILE: src/core/commands/builtInCommands.ts ================================================ /** * Claudian - Built-in slash commands * * System commands that perform actions (not prompt expansions). * These are handled separately from user-defined slash commands. */ export type BuiltInCommandAction = 'clear' | 'add-dir' | 'resume' | 'fork'; export interface BuiltInCommand { name: string; aliases?: string[]; description: string; action: BuiltInCommandAction; /** Whether this command accepts arguments. */ hasArgs?: boolean; /** Hint for arguments shown in dropdown (e.g., "path"). */ argumentHint?: string; } export interface BuiltInCommandResult { command: BuiltInCommand; /** Arguments passed to the command (trimmed, after command name). */ args: string; } export const BUILT_IN_COMMANDS: BuiltInCommand[] = [ { name: 'clear', aliases: ['new'], description: 'Start a new conversation', action: 'clear', }, { name: 'add-dir', description: 'Add external context directory', action: 'add-dir', hasArgs: true, argumentHint: '[path/to/directory]', }, { name: 'resume', description: 'Resume a previous conversation', action: 'resume', }, { name: 'fork', description: 'Fork entire conversation to new session', action: 'fork', }, ]; /** Map of command names/aliases to their definitions. */ const commandMap = new Map(); for (const cmd of BUILT_IN_COMMANDS) { commandMap.set(cmd.name.toLowerCase(), cmd); if (cmd.aliases) { for (const alias of cmd.aliases) { commandMap.set(alias.toLowerCase(), cmd); } } } /** * Checks if input is a built-in command. * Returns the command and arguments if found, null otherwise. */ export function detectBuiltInCommand(input: string): BuiltInCommandResult | null { const trimmed = input.trim(); if (!trimmed.startsWith('/')) return null; // Extract command name (first word after /) const match = trimmed.match(/^\/([a-zA-Z0-9_-]+)(?:\s(.*))?$/); if (!match) return null; const cmdName = match[1].toLowerCase(); const command = commandMap.get(cmdName); if (!command) return null; const args = (match[2] || '').trim(); return { command, args }; } /** * Gets all built-in commands for dropdown display. * Returns commands in a format compatible with SlashCommand interface. */ export function getBuiltInCommandsForDropdown(): Array<{ id: string; name: string; description: string; content: string; argumentHint?: string; }> { return BUILT_IN_COMMANDS.map((cmd) => ({ id: `builtin:${cmd.name}`, name: cmd.name, description: cmd.description, content: '', // Built-in commands don't have prompt content argumentHint: cmd.argumentHint, })); } ================================================ FILE: src/core/commands/index.ts ================================================ export { BUILT_IN_COMMANDS, type BuiltInCommand, type BuiltInCommandAction, type BuiltInCommandResult, detectBuiltInCommand, getBuiltInCommandsForDropdown, } from './builtInCommands'; ================================================ FILE: src/core/hooks/SecurityHooks.ts ================================================ /** * Security Hooks * * PreToolUse hooks for enforcing blocklist and vault restriction. */ import type { HookCallbackMatcher } from '@anthropic-ai/claude-agent-sdk'; import { Notice } from 'obsidian'; import type { PathAccessType } from '../../utils/path'; import type { PathCheckContext } from '../security/BashPathValidator'; import { findBashCommandPathViolation } from '../security/BashPathValidator'; import { isCommandBlocked } from '../security/BlocklistChecker'; import { getPathFromToolInput } from '../tools/toolInput'; import { isEditTool, isFileTool, TOOL_BASH } from '../tools/toolNames'; import { getBashToolBlockedCommands, type PlatformBlockedCommands } from '../types'; export interface BlocklistContext { blockedCommands: PlatformBlockedCommands; enableBlocklist: boolean; } export interface VaultRestrictionContext { getPathAccessType: (filePath: string) => PathAccessType; } /** * Create a PreToolUse hook to enforce the command blocklist. */ export function createBlocklistHook(getContext: () => BlocklistContext): HookCallbackMatcher { return { matcher: TOOL_BASH, hooks: [ async (hookInput) => { const input = hookInput as { tool_name: string; tool_input: { command?: string }; }; const command = input.tool_input?.command || ''; const context = getContext(); const bashToolCommands = getBashToolBlockedCommands(context.blockedCommands); if (isCommandBlocked(command, bashToolCommands, context.enableBlocklist)) { new Notice('Command blocked by security policy'); return { continue: false, hookSpecificOutput: { hookEventName: 'PreToolUse' as const, permissionDecision: 'deny' as const, permissionDecisionReason: `Command blocked by blocklist: ${command}`, }, }; } return { continue: true }; }, ], }; } /** * Create a PreToolUse hook to restrict file access to the vault. */ export function createVaultRestrictionHook(context: VaultRestrictionContext): HookCallbackMatcher { return { hooks: [ async (hookInput) => { const input = hookInput as { tool_name: string; tool_input: Record; }; const toolName = input.tool_name; // Bash: inspect command for paths that escape the vault if (toolName === TOOL_BASH) { const command = (input.tool_input?.command as string) || ''; const pathCheckContext: PathCheckContext = { getPathAccessType: (p) => context.getPathAccessType(p), }; const violation = findBashCommandPathViolation(command, pathCheckContext); if (violation) { const reason = violation.type === 'export_path_read' ? `Access denied: Command path "${violation.path}" is in an allowed export directory, but export paths are write-only.` : `Access denied: Command path "${violation.path}" is outside the vault. Agent is restricted to vault directory only.`; return { continue: false, hookSpecificOutput: { hookEventName: 'PreToolUse' as const, permissionDecision: 'deny' as const, permissionDecisionReason: reason, }, }; } return { continue: true }; } if (!isFileTool(toolName)) { return { continue: true }; } const filePath = getPathFromToolInput(toolName, input.tool_input); if (filePath) { const accessType = context.getPathAccessType(filePath); // Allow full access to vault, readwrite, and context paths if (accessType === 'vault' || accessType === 'readwrite' || accessType === 'context') { return { continue: true }; } // Export paths are write-only if (isEditTool(toolName) && accessType === 'export') { return { continue: true }; } if (!isEditTool(toolName) && accessType === 'export') { return { continue: false, hookSpecificOutput: { hookEventName: 'PreToolUse' as const, permissionDecision: 'deny' as const, permissionDecisionReason: `Access denied: Path "${filePath}" is in an allowed export directory, but export paths are write-only.`, }, }; } return { continue: false, hookSpecificOutput: { hookEventName: 'PreToolUse' as const, permissionDecision: 'deny' as const, permissionDecisionReason: `Access denied: Path "${filePath}" is outside the vault. Agent is restricted to vault directory only.`, }, }; } return { continue: true }; }, ], }; } ================================================ FILE: src/core/hooks/SubagentHooks.ts ================================================ import type { HookCallbackMatcher } from '@anthropic-ai/claude-agent-sdk'; export interface SubagentHookState { hasRunning: boolean; } const STOP_BLOCK_REASON = 'Background subagents are still running. Use `TaskOutput task_id="..." block=true` to wait for their results before ending your turn.'; export function createStopSubagentHook( getState: () => SubagentHookState ): HookCallbackMatcher { return { hooks: [ async () => { let hasRunning: boolean; try { hasRunning = getState().hasRunning; } catch { // Provider failed — assume subagents are running to be safe hasRunning = true; } if (hasRunning) { return { decision: 'block' as const, reason: STOP_BLOCK_REASON }; } return {}; }, ], }; } ================================================ FILE: src/core/hooks/index.ts ================================================ export { type BlocklistContext, createBlocklistHook, createVaultRestrictionHook, type VaultRestrictionContext, } from './SecurityHooks'; export { createStopSubagentHook, type SubagentHookState, } from './SubagentHooks'; ================================================ FILE: src/core/mcp/McpServerManager.ts ================================================ /** * McpServerManager - Core MCP server configuration management. * * Infrastructure layer for loading, filtering, and querying MCP server configurations. */ import { extractMcpMentions, transformMcpMentions } from '../../utils/mcp'; import type { ClaudianMcpServer, McpServerConfig } from '../types'; /** Storage interface for loading MCP servers. */ export interface McpStorageAdapter { load(): Promise; } export class McpServerManager { private servers: ClaudianMcpServer[] = []; private storage: McpStorageAdapter; constructor(storage: McpStorageAdapter) { this.storage = storage; } async loadServers(): Promise { this.servers = await this.storage.load(); } getServers(): ClaudianMcpServer[] { return this.servers; } getEnabledCount(): number { return this.servers.filter((s) => s.enabled).length; } /** * Get servers to include in SDK options. * * A server is included if: * - It is enabled AND * - Either context-saving is disabled OR the server is @-mentioned * * @param mentionedNames Set of server names that were @-mentioned in the prompt */ getActiveServers(mentionedNames: Set): Record { const result: Record = {}; for (const server of this.servers) { if (!server.enabled) continue; // If context-saving is enabled, only include if @-mentioned if (server.contextSaving && !mentionedNames.has(server.name)) { continue; } result[server.name] = server.config; } return result; } /** * Get disabled MCP tools formatted for SDK disallowedTools option. * * Only returns disabled tools from servers that would be active (same filter as getActiveServers). * * @param mentionedNames Set of server names that were @-mentioned in the prompt */ getDisallowedMcpTools(mentionedNames: Set): string[] { return this.collectDisallowedTools( (s) => !s.contextSaving || mentionedNames.has(s.name) ); } /** * Get all disabled MCP tools from ALL enabled servers (ignoring @-mentions). * * Used for persistent queries to pre-register all disabled tools upfront, * so @-mentioning servers doesn't require cold start. */ getAllDisallowedMcpTools(): string[] { return this.collectDisallowedTools().sort(); } private collectDisallowedTools(filter?: (server: ClaudianMcpServer) => boolean): string[] { const disallowed = new Set(); for (const server of this.servers) { if (!server.enabled) continue; if (filter && !filter(server)) continue; if (!server.disabledTools || server.disabledTools.length === 0) continue; for (const tool of server.disabledTools) { const normalized = tool.trim(); if (!normalized) continue; disallowed.add(`mcp__${server.name}__${normalized}`); } } return Array.from(disallowed); } hasServers(): boolean { return this.servers.length > 0; } getContextSavingServers(): ClaudianMcpServer[] { return this.servers.filter((s) => s.enabled && s.contextSaving); } private getContextSavingNames(): Set { return new Set(this.getContextSavingServers().map((s) => s.name)); } /** Only matches against enabled servers with context-saving mode. */ extractMentions(text: string): Set { return extractMcpMentions(text, this.getContextSavingNames()); } /** * Appends " MCP" after each valid @mention. Applied to API requests only, not shown in UI. */ transformMentions(text: string): string { return transformMcpMentions(text, this.getContextSavingNames()); } } ================================================ FILE: src/core/mcp/McpTester.ts ================================================ import { Client } from '@modelcontextprotocol/sdk/client'; import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp'; import * as http from 'http'; import * as https from 'https'; import { getEnhancedPath } from '../../utils/env'; import { parseCommand } from '../../utils/mcp'; import type { ClaudianMcpServer } from '../types'; import { getMcpServerType } from '../types'; export interface McpTool { name: string; description?: string; inputSchema?: Record; } export interface McpTestResult { success: boolean; serverName?: string; serverVersion?: string; tools: McpTool[]; error?: string; } interface UrlServerConfig { url: string; headers?: Record; } /** * Use Node's HTTP stack for MCP server verification to avoid renderer CORS restrictions. * We still rely on official SDK transports for MCP protocol semantics. */ export function createNodeFetch(): (input: string | URL | Request, init?: RequestInit) => Promise { return async (input: string | URL | Request, init?: RequestInit): Promise => { const requestUrl = getRequestUrl(input); const method = init?.method ?? (input instanceof Request ? input.method : 'GET'); const headers = mergeHeaders(input, init); const signal = init?.signal ?? (input instanceof Request ? input.signal : undefined); const body = await getRequestBody(init?.body ?? (input instanceof Request ? input.body : undefined)); const transport = requestUrl.protocol === 'https:' ? https : http; return new Promise((resolve, reject) => { let settled = false; const fail = (error: unknown) => { if (settled) return; settled = true; signal?.removeEventListener('abort', onAbort); reject(error instanceof Error ? error : new Error(String(error))); }; const onAbort = () => { req.destroy(new Error('Request aborted')); fail(signal?.reason ?? new Error('Request aborted')); }; const requestHeaders = Object.fromEntries(headers.entries()); if (body) { requestHeaders['content-length'] = String(body.byteLength); } const req = transport.request( requestUrl, { method, headers: requestHeaders, }, (res: http.IncomingMessage) => { if (settled) return; settled = true; signal?.removeEventListener('abort', onAbort); resolve(createFetchResponse(res) as Response); }, ); req.on('error', (error: Error) => fail(error)); if (signal) { if (signal.aborted) { onAbort(); return; } signal.addEventListener('abort', onAbort, { once: true }); } if (body) { req.end(body); } else { req.end(); } }); }; } interface MinimalFetchResponse { ok: boolean; status: number; statusText: string; headers: Headers; body: ReadableStream | null; text: () => Promise; json: () => Promise; } function createFetchResponse(res: http.IncomingMessage): MinimalFetchResponse { const responseHeaders = new Headers(); for (const [key, value] of Object.entries(res.headers)) { if (value === undefined) continue; if (Array.isArray(value)) { for (const headerValue of value) { responseHeaders.append(key, headerValue); } } else { responseHeaders.append(key, value); } } const body = new ReadableStream({ start(controller) { res.on('data', (chunk: Buffer | string) => { const buffer = typeof chunk === 'string' ? Buffer.from(chunk) : chunk; controller.enqueue(new Uint8Array(buffer)); }); res.on('end', () => controller.close()); res.on('error', (error: Error) => controller.error(error)); }, cancel(reason?: unknown) { res.destroy(reason instanceof Error ? reason : new Error('Response body cancelled')); }, }); let bodyUsed = false; const readAsText = async (): Promise => { if (bodyUsed) { throw new TypeError('Body has already been consumed'); } bodyUsed = true; const reader = body.getReader(); const chunks: Uint8Array[] = []; let total = 0; let done = false; try { while (!done) { const { value, done: streamDone } = await reader.read(); done = streamDone; if (done) break; if (value) { chunks.push(value); total += value.byteLength; } } } finally { reader.releaseLock(); } const merged = new Uint8Array(total); let offset = 0; for (const chunk of chunks) { merged.set(chunk, offset); offset += chunk.byteLength; } return new TextDecoder().decode(merged); }; return { ok: (res.statusCode ?? 500) >= 200 && (res.statusCode ?? 500) < 300, status: res.statusCode ?? 500, statusText: res.statusMessage ?? '', headers: responseHeaders, body, text: readAsText, json: async () => JSON.parse(await readAsText()), }; } function getRequestUrl(input: string | URL | Request): URL { if (input instanceof URL) { return input; } if (typeof input === 'string') { return new URL(input); } return new URL(input.url); } function mergeHeaders(input: string | URL | Request, init?: RequestInit): Headers { const headers = new Headers(input instanceof Request ? input.headers : undefined); if (init?.headers) { const initHeaders = new Headers(init.headers); for (const [key, value] of initHeaders.entries()) { headers.set(key, value); } } return headers; } async function getRequestBody(body: BodyInit | null | undefined): Promise { if (body === undefined || body === null) { return undefined; } const serialized = await new Response(body).arrayBuffer(); return Buffer.from(serialized); } const nodeFetch = createNodeFetch(); export async function testMcpServer(server: ClaudianMcpServer): Promise { const type = getMcpServerType(server.config); let transport; try { if (type === 'stdio') { const config = server.config as { command: string; args?: string[]; env?: Record }; const { cmd, args } = parseCommand(config.command, config.args); if (!cmd) { return { success: false, tools: [], error: 'Missing command' }; } transport = new StdioClientTransport({ command: cmd, args, env: { ...process.env, ...config.env, PATH: getEnhancedPath(config.env?.PATH) } as Record, stderr: 'ignore', }); } else { const config = server.config as UrlServerConfig; const url = new URL(config.url); const options = { fetch: nodeFetch, requestInit: config.headers ? { headers: config.headers } : undefined, }; transport = type === 'sse' ? new SSEClientTransport(url, options) : new StreamableHTTPClientTransport(url, options); } } catch (error) { return { success: false, tools: [], error: error instanceof Error ? error.message : 'Invalid server configuration', }; } const client = new Client({ name: 'claudian-tester', version: '1.0.0' }); const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 10000); try { await client.connect(transport, { signal: controller.signal }); const serverVersion = client.getServerVersion(); let tools: McpTool[] = []; try { const result = await client.listTools(undefined, { signal: controller.signal }); tools = result.tools.map((t: { name: string; description?: string; inputSchema?: Record }) => ({ name: t.name, description: t.description, inputSchema: t.inputSchema as Record, })); } catch { // listTools failure after successful connect = partial success } return { success: true, serverName: serverVersion?.name, serverVersion: serverVersion?.version, tools, }; } catch (error) { if (controller.signal.aborted) { return { success: false, tools: [], error: 'Connection timeout (10s)' }; } return { success: false, tools: [], error: error instanceof Error ? error.message : 'Unknown error', }; } finally { clearTimeout(timeout); try { await client.close(); } catch { // Ignore close errors } } } ================================================ FILE: src/core/mcp/index.ts ================================================ export { McpServerManager, type McpStorageAdapter } from './McpServerManager'; export { type McpTestResult, type McpTool, testMcpServer } from './McpTester'; ================================================ FILE: src/core/plugins/PluginManager.ts ================================================ /** * PluginManager - Discover and manage Claude Code plugins. * * Plugins are discovered from two sources: * - installed_plugins.json: install paths for scanning agents * - settings.json: enabled state (project overrides global) */ import * as fs from 'fs'; import { Notice } from 'obsidian'; import * as os from 'os'; import * as path from 'path'; import type { CCSettingsStorage } from '../storage/CCSettingsStorage'; import type { ClaudianPlugin, InstalledPluginEntry, InstalledPluginsFile, PluginScope } from '../types'; const INSTALLED_PLUGINS_PATH = path.join(os.homedir(), '.claude', 'plugins', 'installed_plugins.json'); const GLOBAL_SETTINGS_PATH = path.join(os.homedir(), '.claude', 'settings.json'); interface SettingsFile { enabledPlugins?: Record; } function readJsonFile(filePath: string): T | null { try { if (!fs.existsSync(filePath)) { return null; } const content = fs.readFileSync(filePath, 'utf-8'); return JSON.parse(content) as T; } catch { return null; } } function normalizePathForComparison(p: string): string { try { const resolved = fs.realpathSync(p); if (typeof resolved === 'string' && resolved.length > 0) { return resolved; } } catch { // ignore } return path.resolve(p); } function selectInstalledPluginEntry( entries: InstalledPluginEntry[], normalizedVaultPath: string ): InstalledPluginEntry | null { for (const entry of entries) { if (entry.scope !== 'project') continue; if (!entry.projectPath) continue; if (normalizePathForComparison(entry.projectPath) === normalizedVaultPath) { return entry; } } return entries.find(e => e.scope === 'user') ?? null; } function extractPluginName(pluginId: string): string { const atIndex = pluginId.indexOf('@'); if (atIndex > 0) { return pluginId.substring(0, atIndex); } return pluginId; } export class PluginManager { private ccSettingsStorage: CCSettingsStorage; private vaultPath: string; private plugins: ClaudianPlugin[] = []; constructor(vaultPath: string, ccSettingsStorage: CCSettingsStorage) { this.vaultPath = vaultPath; this.ccSettingsStorage = ccSettingsStorage; } async loadPlugins(): Promise { const installedPlugins = readJsonFile(INSTALLED_PLUGINS_PATH); const globalSettings = readJsonFile(GLOBAL_SETTINGS_PATH); const projectSettings = await this.loadProjectSettings(); const globalEnabled = globalSettings?.enabledPlugins ?? {}; const projectEnabled = projectSettings?.enabledPlugins ?? {}; const plugins: ClaudianPlugin[] = []; const normalizedVaultPath = normalizePathForComparison(this.vaultPath); if (installedPlugins?.plugins) { for (const [pluginId, entries] of Object.entries(installedPlugins.plugins)) { if (!entries || entries.length === 0) continue; const entriesArray = Array.isArray(entries) ? entries : [entries]; if (!Array.isArray(entries)) { new Notice(`Claudian: plugin "${pluginId}" has malformed entry in installed_plugins.json (expected array, got ${typeof entries})`); } const entry = selectInstalledPluginEntry(entriesArray, normalizedVaultPath); if (!entry) continue; const scope: PluginScope = entry.scope === 'project' ? 'project' : 'user'; // Project setting takes precedence, then global, then default enabled const enabled = projectEnabled[pluginId] ?? globalEnabled[pluginId] ?? true; plugins.push({ id: pluginId, name: extractPluginName(pluginId), enabled, scope, installPath: entry.installPath, }); } } this.plugins = plugins.sort((a, b) => { if (a.scope !== b.scope) { return a.scope === 'project' ? -1 : 1; } return a.id.localeCompare(b.id); }); } private async loadProjectSettings(): Promise { const projectSettingsPath = path.join(this.vaultPath, '.claude', 'settings.json'); return readJsonFile(projectSettingsPath); } getPlugins(): ClaudianPlugin[] { return [...this.plugins]; } hasPlugins(): boolean { return this.plugins.length > 0; } hasEnabledPlugins(): boolean { return this.plugins.some((p) => p.enabled); } getEnabledCount(): number { return this.plugins.filter((p) => p.enabled).length; } /** Used to detect changes that require restarting the persistent query. */ getPluginsKey(): string { const enabledPlugins = this.plugins .filter((p) => p.enabled) .sort((a, b) => a.id.localeCompare(b.id)); if (enabledPlugins.length === 0) { return ''; } return enabledPlugins.map((p) => `${p.id}:${p.installPath}`).join('|'); } /** Writes to project .claude/settings.json so CLI respects the state. */ async togglePlugin(pluginId: string): Promise { const plugin = this.plugins.find((p) => p.id === pluginId); if (!plugin) { return; } const newEnabled = !plugin.enabled; plugin.enabled = newEnabled; await this.ccSettingsStorage.setPluginEnabled(pluginId, newEnabled); } async enablePlugin(pluginId: string): Promise { const plugin = this.plugins.find((p) => p.id === pluginId); if (!plugin || plugin.enabled) { return; } plugin.enabled = true; await this.ccSettingsStorage.setPluginEnabled(pluginId, true); } async disablePlugin(pluginId: string): Promise { const plugin = this.plugins.find((p) => p.id === pluginId); if (!plugin || !plugin.enabled) { return; } plugin.enabled = false; await this.ccSettingsStorage.setPluginEnabled(pluginId, false); } } ================================================ FILE: src/core/plugins/index.ts ================================================ export { PluginManager } from './PluginManager'; ================================================ FILE: src/core/prompts/inlineEdit.ts ================================================ /** * Claudian - Inline Edit System Prompt * * Builds the system prompt for inline text editing (read-only tools). */ import { getTodayDate } from '../../utils/date'; export function getInlineEditSystemPrompt(allowExternalAccess: boolean = false): string { const pathRules = allowExternalAccess ? '- **Paths**: Prefer RELATIVE paths for vault files. Use absolute or `~` paths only when you intentionally need files outside the vault.' : '- **Paths**: Must be RELATIVE to vault root (e.g., "notes/file.md").'; return `Today is ${getTodayDate()}. You are **Claudian**, an expert editor and writing assistant embedded in Obsidian. You help users refine their text, answer questions, and generate content with high precision. ## Core Directives 1. **Style Matching**: Mimic the user's tone, voice, and formatting style (indentation, bullet points, capitalization). 2. **Context Awareness**: Always Read the full file (or significant context) to understand the broader topic before editing. Do not rely solely on the selection. 3. **Silent Execution**: Use tools (Read, WebSearch) silently. Your final output must be ONLY the result. 4. **No Fluff**: No pleasantries, no "Here is the text", no "I have updated...". Just the content. ## Input Format User messages have the instruction first, followed by XML context tags: ### Selection Mode \`\`\` user's instruction selected text here \`\`\` Use \`\` tags for edits. ### Cursor Mode \`\`\` user's instruction text before|text after #inline \`\`\` Or between paragraphs: \`\`\` user's instruction Previous paragraph | #inbetween Next paragraph \`\`\` Use \`\` tags to insert new content at the cursor position (\`|\`). ## Tools & Path Rules - **Tools**: Read, Grep, Glob, LS, WebSearch, WebFetch. (All read-only). ${pathRules} ## Thinking Process Before generating the final output, mentally check: 1. **Context**: Have I read enough of the file to understand the *topic* and *structure*? 2. **Style**: What is the user's indentation (2 vs 4 spaces, tabs)? What is their tone? 3. **Type**: Is this **Prose** (flow, grammar, clarity) or **Code** (syntax, logic, variable names)? - *Prose*: Ensure smooth transitions. - *Code*: Preserve syntax validity; do not break surrounding brackets/indentation. ## Output Rules - CRITICAL **ABSOLUTE RULE**: Your text output must contain ONLY the final answer, replacement, or insertion. NEVER output: - "I'll read the file..." / "Let me check..." / "I will..." - "I'm asked about..." / "The user wants..." - "Based on my analysis..." / "After reading..." - "Here's..." / "The answer is..." - ANY announcement of what you're about to do or did Use tools silently. Your text output = final result only. ### When Replacing Selected Text (Selection Mode) If the user wants to MODIFY or REPLACE the selected text, wrap the replacement in tags: your replacement text here The content inside the tags should be ONLY the replacement text - no explanation. ### When Inserting at Cursor (Cursor Mode) If the user wants to INSERT new content at the cursor position, wrap the insertion in tags: your inserted text here The content inside the tags should be ONLY the text to insert - no explanation. ### When Answering Questions or Providing Information If the user is asking a QUESTION, respond WITHOUT tags. Output the answer directly. WRONG: "I'll read the full context of this file to give you a better explanation. This is a guide about..." CORRECT: "This is a guide about..." ### When Clarification is Needed If the request is ambiguous, ask a clarifying question. Keep questions concise and specific. ## Examples ### Selection Mode Input: \`\`\` translate to French Hello world \`\`\` CORRECT (replacement): Bonjour le monde Input: \`\`\` what does this do? const x = arr.reduce((a, b) => a + b, 0); \`\`\` CORRECT (question - no tags): This code sums all numbers in the array \`arr\`. It uses \`reduce\` to iterate through the array, accumulating the total starting from 0. ### Cursor Mode Input: \`\`\` what animal? The quick brown | jumps over the lazy dog. #inline \`\`\` CORRECT (insertion): fox ### Q&A Input: \`\`\` add a brief description section # Introduction This is my project. | #inbetween ## Features \`\`\` CORRECT (insertion): ## Description This project provides tools for managing your notes efficiently. Input: \`\`\` translate to Spanish The bank was steep. \`\`\` CORRECT (asking for clarification): "Bank" can mean a financial institution (banco) or a river bank (orilla). Which meaning should I use? Then after user clarifies "river bank": La orilla era empinada.`; } ================================================ FILE: src/core/prompts/instructionRefine.ts ================================================ /** * Claudian - Instruction Refine System Prompt * * Builds the system prompt for instruction refinement. */ export function buildRefineSystemPrompt(existingInstructions: string): string { const existingSection = existingInstructions.trim() ? `\n\nEXISTING INSTRUCTIONS (already in the user's system prompt): \`\`\` ${existingInstructions.trim()} \`\`\` When refining the new instruction: - Consider how it fits with existing instructions - Avoid duplicating existing instructions - If the new instruction conflicts with an existing one, refine it to be complementary or note the conflict - Match the format of existing instructions (section, heading, bullet points, style, etc.)` : ''; return `You are an expert Prompt Engineer. You help users craft precise, effective system instructions for their AI assistant. **Your Goal**: Transform vague or simple user requests into **high-quality, actionable, and non-conflicting** system prompt instructions. **Process**: 1. **Analyze Intent**: What behavior does the user want to enforce or change? 2. **Check Context**: Does this conflict with existing instructions? - *No Conflict*: Add as new. - *Conflict*: Propose a **merged instruction** that resolves the contradiction (or ask if unsure). 3. **Refine**: Draft a clear, positive instruction (e.g., "Do X" instead of "Don't do Y"). 4. **Format**: Return *only* the Markdown snippet wrapped in \`\` tags. **Guidelines**: - **Clarity**: Use precise language. Avoid ambiguity. - **Scope**: Keep it focused. Don't add unrelated rules. - **Format**: Valid Markdown (bullets \`-\` or sections \`##\`). - **No Header**: Do NOT include a top-level header like \`# Custom Instructions\`. - **Conflict Handling**: If the new rule directly contradicts an existing one, rewrite the *new* one to override specific cases or ask for clarification. **Output Format**: - **Success**: \`...markdown content...\` - **Ambiguity**: Plain text question. ${existingSection} **Examples**: Input: "typescript for code" Output: - **Code Language**: Always use TypeScript for code examples. Include proper type annotations and interfaces. Input: "be concise" Output: - **Conciseness**: Provide brief, direct responses. Omit conversational filler and unnecessary explanations. Input: "organize coding style rules" Output: ## Coding Standards\n\n- **Language**: Use TypeScript.\n- **Style**: Prefer functional patterns.\n- **Review**: Keep diffs small. Input: "use that thing from before" Output: I'm not sure what you're referring to. Could you please clarify?`; } ================================================ FILE: src/core/prompts/mainAgent.ts ================================================ /** * Claudian - Main Agent System Prompt * * Builds the system prompt for the Claude Agent SDK including * Obsidian-specific instructions, tool guidance, and image handling. */ import { getTodayDate } from '../../utils/date'; export interface SystemPromptSettings { mediaFolder?: string; customPrompt?: string; allowedExportPaths?: string[]; allowExternalAccess?: boolean; vaultPath?: string; userName?: string; } function getPathRules(vaultPath?: string, allowExternalAccess: boolean = false): string { if (!allowExternalAccess) { return `## Path Rules (MUST FOLLOW) | Location | Access | Path Format | Example | |----------|--------|-------------|---------| | **Vault** | Read/Write | Relative from vault root | \`notes/my-note.md\`, \`.\` | | **Export paths** | Write-only | \`~\` or absolute | \`~/Desktop/output.docx\` | | **External contexts** | Full access | Absolute path | \`/Users/me/Workspace/file.ts\` | **Vault files** (default): - ✓ Correct: \`notes/my-note.md\`, \`my-note.md\`, \`folder/subfolder/file.md\`, \`.\` - ✗ WRONG: \`/notes/my-note.md\`, \`${vaultPath || '/absolute/path'}/file.md\` - A leading slash or absolute path will FAIL for vault operations. **Path specificity**: When paths overlap, the **more specific path wins**: - If \`~/Desktop\` is export (write-only) and \`~/Desktop/Workspace\` is external context (full access) - → Files in \`~/Desktop/Workspace\` have full read/write access - → Files directly in \`~/Desktop\` remain write-only`; } return `## Path Rules (MUST FOLLOW) | Location | Access | Path Format | Example | |----------|--------|-------------|---------| | **Vault** | Read/Write | Relative from vault root preferred | \`notes/my-note.md\`, \`.\` | | **External paths** | Read/Write | \`~\` or absolute | \`~/Desktop/output.docx\`, \`/Users/me/Workspace/file.ts\` | | **Session external contexts** | Full access | Absolute path | \`/Users/me/Workspace\` | **Vault files**: - Prefer relative paths for files inside the vault. - Absolute vault paths are allowed when needed, but relative paths are usually simpler and less error-prone. **External files**: - Use absolute or \`~\` paths for files outside the vault. - Be explicit about the target path and avoid broad filesystem operations unless they are necessary. **Path specificity**: - When multiple directories could match, use the narrowest path that fits the task. - Prefer the most specific external directory instead of a broad parent path.`; } function getSubagentPathRules(allowExternalAccess: boolean = false): string { if (!allowExternalAccess) { return `**CRITICAL - Subagent Path Rules:** - Subagents inherit the vault as their working directory. - Reference files using **RELATIVE** paths. - NEVER use absolute paths in subagent prompts.`; } return `**CRITICAL - Subagent Path Rules:** - Subagents inherit the vault as their working directory. - Reference vault files using **RELATIVE** paths. - Use absolute or \`~\` paths only when you intentionally need files outside the vault.`; } function getBaseSystemPrompt( vaultPath?: string, userName?: string, allowExternalAccess: boolean = false ): string { const vaultInfo = vaultPath ? `\n\nVault absolute path: ${vaultPath}` : ''; const trimmedUserName = userName?.trim(); const userContext = trimmedUserName ? `## User Context\n\nYou are collaborating with **${trimmedUserName}**.\n\n` : ''; const pathRules = getPathRules(vaultPath, allowExternalAccess); const subagentPathRules = getSubagentPathRules(allowExternalAccess); return `${userContext}## Time Context - **Current Date**: ${getTodayDate()} - **Knowledge Status**: You possess extensive internal knowledge up to your training cutoff. You do not know the exact date of your cutoff, but you must assume that your internal weights are static and "past," while the Current Date is "present." ## Identity & Role You are **Claudian**, an expert AI assistant specialized in Obsidian vault management, knowledge organization, and code analysis. You operate directly inside the user's Obsidian vault. **Core Principles:** 1. **Obsidian Native**: You understand Markdown, YAML frontmatter, Wiki-links, and the "second brain" philosophy. 2. **Safety First**: You never overwrite data without understanding context. You always use relative paths. 3. **Proactive Thinking**: You do not just execute; you *plan* and *verify*. You anticipate potential issues (like broken links or missing files). 4. **Clarity**: Your changes are precise, minimizing "noise" in the user's notes or code. The current working directory is the user's vault root.${vaultInfo} ${pathRules} ## User Message Format User messages have the query first, followed by optional XML context tags: \`\`\` User's question or request here path/to/note.md selected text content selected content from an Obsidian browser view \`\`\` - The user's query/instruction always comes first in the message. - \`\`: The note the user is currently viewing/focused on. Read this to understand context. - \`\`: Text currently selected in the editor, with file path and line numbers. - \`\`: Text selected in an Obsidian browser/web view (for example Surfing), including optional source/title/url metadata. - \`@filename.md\`: Files mentioned with @ in the query. Read these files when referenced. ## Obsidian Context - **Structure**: Files are Markdown (.md). Folders organize content. - **Frontmatter**: YAML at the top of files (metadata). Respect existing fields. - **Links**: Internal Wiki-links \`[[note-name]]\` or \`[[folder/note-name]]\`. External links \`[text](url)\`. - When reading a note with wikilinks, consider reading linked notes—they often contain related context that helps understand the current note. - **Tags**: #tag-name for categorization. - **Dataview**: You may encounter Dataview queries (in \`\`\`dataview\`\`\` blocks). Do not break them unless asked. - **Vault Config**: \`.obsidian/\` contains internal config. Touch only if you know what you are doing. **File References in Responses:** When mentioning vault files in your responses, use wikilink format so users can click to open them: - ✓ Use: \`[[folder/note.md]]\` or \`[[note]]\` - ✗ Avoid: plain paths like \`folder/note.md\` (not clickable) **Image embeds:** Use \`![[image.png]]\` to display images directly in chat. Images render visually, making it easy to show diagrams, screenshots, or visual content you're discussing. Examples: - "I found your notes in [[30.areas/finance/Investment lessons/2024.Current trading lessons.md]]" - "See [[daily notes/2024-01-15]] for more details" - "Here's the diagram: ![[attachments/architecture.png]]" ## Tool Usage Guidelines Standard tools (Read, Write, Edit, Glob, Grep, LS, Bash, WebSearch, WebFetch, Skills) work as expected. **Thinking Process:** Before taking action, explicitly THINK about: 1. **Context**: Do I have enough information? (Use Read/Search if not). 2. **Impact**: What will this change affect? (Links, other files). 3. **Plan**: What are the steps? (Use TodoWrite for >2 steps). **Tool-Specific Rules:** - **Read**: - Always Read a file before Editing it. - Read can view images (PNG, JPG, GIF, WebP) for visual analysis. - **Edit**: - Requires **EXACT** \`old_string\` match including whitespace/indentation. - If Edit fails, Read the file again to check the current content. - **Bash**: - Runs with vault as working directory. - **Prefer** Read/Write/Edit over shell commands for file operations (safer). - **Stdout-capable tools** (pandoc, jq, imagemagick): Prefer piping output directly instead of creating temporary files when the result will be used immediately. - Use BashOutput/KillShell to manage background processes. - **LS**: Uses "." for vault root. - **WebFetch**: For text/HTML/PDF only. Avoid binaries. ### WebSearch Use WebSearch strictly according to the following logic: 1. **Static/Historical**: Rely on internal knowledge for established facts, history, or older code libraries. Use WebSearch to confirm or expand on your knowledge. 2. **Dynamic/Recent**: **MUST** search for: - "Latest" news, versions, docs. - Events in the current/previous year. - Volatile data (prices, weather). 3. **Date Awareness**: If user says "yesterday", calculate the date relative to **Current Date**. 4. **Ambiguity**: If unsure whether knowledge is outdated, SEARCH. ### Agent (Subagents) Spawn subagents for complex multi-step tasks. Parameters: \`prompt\`, \`description\`, \`subagent_type\`, \`run_in_background\`. ${subagentPathRules} **When to use:** - Parallelizable work (main + subagent or multiple subagents) - Preserve main agent's context window - Offload contained tasks while continuing other work **IMPORTANT:** Always explicitly set \`run_in_background\` - never omit it: - \`run_in_background=false\` for sync (inline) tasks - \`run_in_background=true\` for async (background) tasks **Sync Mode (\`run_in_background=false\`)**: - Runs inline, result returned directly. - **DEFAULT** to this unless explicitly asked or the task is very long-running. **Async Mode (\`run_in_background=true\`)**: - Use ONLY when explicitly requested or task is clearly long-running. - Returns \`task_id\` immediately. - You **cannot end your turn** while async subagents are still running. The system will block you and remind you to retrieve results. **Async workflow:** 1. Launch: \`Agent prompt="..." run_in_background=true\` → get \`task_id\` 2. Continue working on other tasks 3. Use \`TaskOutput task_id="..." block=true\` to wait for completion (blocks until result is ready) 4. Process the result and report to the user **When to retrieve results:** - Mid-turn between other tasks: use \`TaskOutput block=false\` to poll without blocking - Idle with no other work: use \`TaskOutput block=true\` to wait ### TodoWrite Track task progress. Parameter: \`todos\` (array of {content, status, activeForm}). - Statuses: \`pending\`, \`in_progress\`, \`completed\` - \`content\`: imperative ("Fix the bug") - \`activeForm\`: present continuous ("Fixing the bug") **Use for:** Tasks with 2+ steps, multi-file changes, complex operations. Use proactively for any task meeting these criteria to keep progress visible. **Workflow:** 1. **Plan**: Create the todo list at the start. 2. **Execute**: Mark \`in_progress\` -> do work -> Mark \`completed\`. 3. **Update**: If new tasks arise, add them. **Example:** User asks "refactor auth and add tests" \`\`\` [ {content: "Analyze auth module", status: "in_progress", activeForm: "Analyzing auth module"}, {content: "Refactor auth code", status: "pending", activeForm: "Refactoring auth code"}, {content: "Add unit tests", status: "pending", activeForm: "Adding unit tests"} ] \`\`\` ### Skills Reusable capability modules. Use the \`Skill\` tool to invoke them when their description matches the user's need. ## Selection Context User messages may include an \`\` tag showing text the user selected: \`\`\`xml selected text here possibly multiple lines \`\`\` User messages may also include a \`\` tag when selection comes from an Obsidian browser view: \`\`\`xml selected webpage content \`\`\` **When present:** The user selected this text before sending their message. Use this context to understand what they're referring to.`; } function getImageInstructions(mediaFolder: string): string { const folder = mediaFolder.trim(); const mediaPath = folder ? './' + folder : '.'; const examplePath = folder ? folder + '/' : ''; return ` ## Embedded Images in Notes **Proactive image reading**: When reading a note with embedded images, read them alongside text for full context. Images often contain critical information (diagrams, screenshots, charts). **Local images** (\`![[image.jpg]]\`): - Located in media folder: \`${mediaPath}\` - Read with: \`Read file_path="${examplePath}image.jpg"\` - Formats: PNG, JPG/JPEG, GIF, WebP **External images** (\`![alt](url)\`): - WebFetch does NOT support images - Download to media folder → Read → Replace URL with wiki-link: \`\`\`bash # Download to media folder with descriptive name mkdir -p ${mediaPath} img_name="downloaded_\\$(date +%s).png" curl -sfo "${examplePath}$img_name" 'URL' \`\`\` Then read with \`Read file_path="${examplePath}$img_name"\`, and replace the markdown link \`![alt](url)\` with \`![[${examplePath}$img_name]]\` in the note. **Benefits**: Image becomes a permanent vault asset, works offline, and uses Obsidian's native embed syntax.`; } function getExportInstructions( allowedExportPaths: string[], allowExternalAccess: boolean = false ): string { if (allowedExportPaths.length === 0) { return ''; } const uniquePaths = Array.from(new Set(allowedExportPaths.map((p) => p.trim()).filter(Boolean))); if (uniquePaths.length === 0) { return ''; } const formattedPaths = uniquePaths.map((p) => `- ${p}`).join('\n'); const heading = allowExternalAccess ? 'Preferred Export Paths' : 'Allowed Export Paths'; const description = allowExternalAccess ? 'Suggested destinations for exports outside the vault:' : 'Write-only destinations outside the vault:'; return ` ## ${heading} ${description} ${formattedPaths} Examples: \`\`\`bash pandoc ./note.md -o ~/Desktop/note.docx # Direct export pandoc ./note.md | head -100 # Pipe to stdout (no temp file) cp ./note.md ~/Desktop/note.md \`\`\``; } export function buildSystemPrompt(settings: SystemPromptSettings = {}): string { const allowExternalAccess = settings.allowExternalAccess ?? false; let prompt = getBaseSystemPrompt(settings.vaultPath, settings.userName, allowExternalAccess); // Stable content (ordered for context cache optimization) prompt += getImageInstructions(settings.mediaFolder || ''); prompt += getExportInstructions(settings.allowedExportPaths || [], allowExternalAccess); if (settings.customPrompt?.trim()) { prompt += '\n\n## Custom Instructions\n\n' + settings.customPrompt.trim(); } return prompt; } ================================================ FILE: src/core/prompts/titleGeneration.ts ================================================ /** * Claudian - Title Generation System Prompt * * System prompt for generating conversation titles. */ export const TITLE_GENERATION_SYSTEM_PROMPT = `You are a specialist in summarizing user intent. **Task**: Generate a **concise, descriptive title** (max 50 chars) summarizing the user's task/request. **Rules**: 1. **Format**: Sentence case. No periods/quotes. 2. **Structure**: Start with a **strong verb** (e.g., Create, Fix, Debug, Explain, Analyze). 3. **Forbidden**: "Conversation with...", "Help me...", "Question about...", "I need...". 4. **Tech Context**: Detect and include the primary language/framework if code is present (e.g., "Debug Python script", "Refactor React hook"). **Output**: Return ONLY the raw title text.`; ================================================ FILE: src/core/sdk/index.ts ================================================ export type { TransformOptions } from './transformSDKMessage'; export { transformSDKMessage } from './transformSDKMessage'; export { isSessionInitEvent, isStreamChunk } from './typeGuards'; export type { SessionInitEvent, TransformEvent } from './types'; ================================================ FILE: src/core/sdk/toolResultContent.ts ================================================ interface ToolResultContentOptions { fallbackIndent?: number; } /** * Agent/Subagent tool results can arrive as text blocks instead of a plain string. * Keep streaming and history parsing aligned so live output matches reloaded output. */ export function extractToolResultContent( content: unknown, options?: ToolResultContentOptions ): string { if (typeof content === 'string') return content; if (content == null) return ''; if (Array.isArray(content)) { const textParts = content.filter(isTextBlock).map((block) => block.text); if (textParts.length > 0) return textParts.join('\n'); if (content.length > 0) return JSON.stringify(content, null, options?.fallbackIndent); return ''; } return JSON.stringify(content, null, options?.fallbackIndent); } function isTextBlock(block: unknown): block is { type: 'text'; text: string } { if (!block || typeof block !== 'object') return false; const record = block as Record; return record.type === 'text' && typeof record.text === 'string'; } ================================================ FILE: src/core/sdk/transformSDKMessage.ts ================================================ import type { SDKMessage, SDKResultError } from '@anthropic-ai/claude-agent-sdk'; import type { SDKToolUseResult, UsageInfo } from '../types'; import { getContextWindowSize } from '../types'; import { isBlockedMessage } from '../types/sdk'; import { extractToolResultContent } from './toolResultContent'; import type { TransformEvent } from './types'; export interface TransformOptions { /** The intended model from settings/query (used for context window size). */ intendedModel?: string; /** Custom context limits from settings (model ID → tokens). */ customContextLimits?: Record; } interface MessageUsage { input_tokens?: number; cache_creation_input_tokens?: number; cache_read_input_tokens?: number; } interface ContextWindowEntry { model: string; contextWindow: number; } function isResultError(message: { type: 'result'; subtype: string }): message is SDKResultError { return !!message.subtype && message.subtype !== 'success'; } function getBuiltInModelSignature(model: string): { family: 'haiku' | 'sonnet' | 'opus'; is1M: boolean } | null { const normalized = model.trim().toLowerCase(); if (normalized === 'haiku') { return { family: 'haiku', is1M: false }; } if (normalized === 'sonnet' || normalized === 'sonnet[1m]') { return { family: 'sonnet', is1M: normalized.endsWith('[1m]') }; } if (normalized === 'opus' || normalized === 'opus[1m]') { return { family: 'opus', is1M: normalized.endsWith('[1m]') }; } return null; } function getModelUsageSignature(model: string): { family: 'haiku' | 'sonnet' | 'opus'; is1M: boolean } | null { const normalized = model.trim().toLowerCase(); if (normalized.includes('haiku')) { return { family: 'haiku', is1M: false }; } if (normalized.includes('sonnet')) { return { family: 'sonnet', is1M: normalized.endsWith('[1m]') }; } if (normalized.includes('opus')) { return { family: 'opus', is1M: normalized.endsWith('[1m]') }; } return null; } function selectContextWindowEntry( modelUsage: Record, intendedModel?: string ): ContextWindowEntry | null { const entries: ContextWindowEntry[] = Object.entries(modelUsage) .flatMap(([model, usage]) => typeof usage?.contextWindow === 'number' && usage.contextWindow > 0 ? [{ model, contextWindow: usage.contextWindow }] : [] ); if (entries.length === 0) { return null; } if (entries.length === 1) { return entries[0]; } if (!intendedModel) { return null; } const exactMatches = entries.filter((entry) => entry.model === intendedModel); if (exactMatches.length === 1) { return exactMatches[0]; } const intendedSignature = getBuiltInModelSignature(intendedModel); if (!intendedSignature) { return null; } const signatureMatches = entries.filter((entry) => { const entrySignature = getModelUsageSignature(entry.model); return entrySignature?.family === intendedSignature.family && entrySignature.is1M === intendedSignature.is1M; }); return signatureMatches.length === 1 ? signatureMatches[0] : null; } /** * Transform SDK message to StreamChunk format. * One SDK message can yield multiple chunks (e.g., text + tool_use blocks). */ export function* transformSDKMessage( message: SDKMessage, options?: TransformOptions ): Generator { switch (message.type) { case 'system': if (message.subtype === 'init' && message.session_id) { yield { type: 'session_init', sessionId: message.session_id, agents: message.agents, permissionMode: message.permissionMode, }; } else if (message.subtype === 'compact_boundary') { yield { type: 'compact_boundary' }; } break; case 'assistant': { const parentToolUseId = message.parent_tool_use_id ?? null; // Errors on assistant messages (e.g. rate_limit, billing_error) if (message.error) { yield { type: 'error', content: message.error }; } if (message.message?.content && Array.isArray(message.message.content)) { for (const block of message.message.content) { if (block.type === 'thinking' && block.thinking) { yield { type: 'thinking', content: block.thinking, parentToolUseId }; } else if (block.type === 'text' && block.text && block.text.trim() !== '(no content)') { yield { type: 'text', content: block.text, parentToolUseId }; } else if (block.type === 'tool_use') { yield { type: 'tool_use', id: block.id || `tool-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`, name: block.name || 'unknown', input: block.input || {}, parentToolUseId, }; } } } // Extract usage from main agent assistant messages only (not subagent) // This gives accurate per-turn context usage without subagent token pollution const usage = (message.message as { usage?: MessageUsage } | undefined)?.usage; if (parentToolUseId === null && usage) { const inputTokens = usage.input_tokens ?? 0; const cacheCreationInputTokens = usage.cache_creation_input_tokens ?? 0; const cacheReadInputTokens = usage.cache_read_input_tokens ?? 0; const contextTokens = inputTokens + cacheCreationInputTokens + cacheReadInputTokens; const model = options?.intendedModel ?? 'sonnet'; const contextWindow = getContextWindowSize(model, options?.customContextLimits); const percentage = Math.min(100, Math.max(0, Math.round((contextTokens / contextWindow) * 100))); const usageInfo: UsageInfo = { model, inputTokens, cacheCreationInputTokens, cacheReadInputTokens, contextWindow, contextTokens, percentage, }; yield { type: 'usage', usage: usageInfo }; } break; } case 'user': { const parentToolUseId = message.parent_tool_use_id ?? null; // Check for blocked tool calls (from hook denials) if (isBlockedMessage(message)) { yield { type: 'blocked', content: message._blockReason, }; break; } // User messages can contain tool results if (message.tool_use_result !== undefined && message.parent_tool_use_id) { yield { type: 'tool_result', id: message.parent_tool_use_id, content: extractToolResultContent(message.tool_use_result, { fallbackIndent: 2 }), isError: false, parentToolUseId, toolUseResult: (message.tool_use_result ?? undefined) as SDKToolUseResult | undefined, }; } // Also check message.message.content for tool_result blocks if (message.message?.content && Array.isArray(message.message.content)) { for (const block of message.message.content) { if (block.type === 'tool_result') { yield { type: 'tool_result', id: block.tool_use_id || message.parent_tool_use_id || '', content: extractToolResultContent(block.content, { fallbackIndent: 2 }), isError: block.is_error || false, parentToolUseId, toolUseResult: (message.tool_use_result ?? undefined) as SDKToolUseResult | undefined, }; } } } break; } case 'stream_event': { const parentToolUseId = message.parent_tool_use_id ?? null; const event = message.event; if (event?.type === 'content_block_start' && event.content_block?.type === 'tool_use') { yield { type: 'tool_use', id: event.content_block.id || `tool-${Date.now()}`, name: event.content_block.name || 'unknown', input: event.content_block.input || {}, parentToolUseId, }; } else if (event?.type === 'content_block_start' && event.content_block?.type === 'thinking') { if (event.content_block.thinking) { yield { type: 'thinking', content: event.content_block.thinking, parentToolUseId }; } } else if (event?.type === 'content_block_start' && event.content_block?.type === 'text') { if (event.content_block.text) { yield { type: 'text', content: event.content_block.text, parentToolUseId }; } } else if (event?.type === 'content_block_delta') { if (event.delta?.type === 'thinking_delta' && event.delta.thinking) { yield { type: 'thinking', content: event.delta.thinking, parentToolUseId }; } else if (event.delta?.type === 'text_delta' && event.delta.text) { yield { type: 'text', content: event.delta.text, parentToolUseId }; } } break; } case 'result': if (isResultError(message)) { const content = message.errors.filter((e) => e.trim().length > 0).join('\n'); yield { type: 'error', content: content || `Result error: ${message.subtype}`, }; } // Usage is now extracted from assistant messages for accuracy (excludes subagent tokens) // Result message usage is aggregated across main + subagents, causing inaccurate spikes if ('modelUsage' in message && message.modelUsage) { const modelUsage = message.modelUsage as Record; const selectedEntry = selectContextWindowEntry(modelUsage, options?.intendedModel); if (selectedEntry) { yield { type: 'context_window_update', contextWindow: selectedEntry.contextWindow }; } } break; default: break; } } ================================================ FILE: src/core/sdk/typeGuards.ts ================================================ import type { StreamChunk } from '../types'; import type { SessionInitEvent, TransformEvent } from './types'; export function isSessionInitEvent(event: TransformEvent): event is SessionInitEvent { return event.type === 'session_init'; } export function isStreamChunk(event: TransformEvent): event is StreamChunk { return event.type !== 'session_init'; } ================================================ FILE: src/core/sdk/types.ts ================================================ import type { StreamChunk } from '../types'; export interface SessionInitEvent { type: 'session_init'; sessionId: string; agents?: string[]; permissionMode?: string; } export type TransformEvent = StreamChunk | SessionInitEvent; ================================================ FILE: src/core/security/ApprovalManager.ts ================================================ /** Permission utilities for tool action approval. */ import type { PermissionUpdate, PermissionUpdateDestination } from '@anthropic-ai/claude-agent-sdk'; import { TOOL_BASH, TOOL_EDIT, TOOL_GLOB, TOOL_GREP, TOOL_NOTEBOOK_EDIT, TOOL_READ, TOOL_WRITE, } from '../tools/toolNames'; export function getActionPattern(toolName: string, input: Record): string | null { switch (toolName) { case TOOL_BASH: return typeof input.command === 'string' ? input.command.trim() : ''; case TOOL_READ: case TOOL_WRITE: case TOOL_EDIT: return typeof input.file_path === 'string' && input.file_path ? input.file_path : null; case TOOL_NOTEBOOK_EDIT: if (typeof input.notebook_path === 'string' && input.notebook_path) { return input.notebook_path; } return typeof input.file_path === 'string' && input.file_path ? input.file_path : null; case TOOL_GLOB: return typeof input.pattern === 'string' && input.pattern ? input.pattern : null; case TOOL_GREP: return typeof input.pattern === 'string' && input.pattern ? input.pattern : null; default: return JSON.stringify(input); } } export function getActionDescription(toolName: string, input: Record): string { const pattern = getActionPattern(toolName, input) ?? '(unknown)'; switch (toolName) { case TOOL_BASH: return `Run command: ${pattern}`; case TOOL_READ: return `Read file: ${pattern}`; case TOOL_WRITE: return `Write to file: ${pattern}`; case TOOL_EDIT: return `Edit file: ${pattern}`; case TOOL_GLOB: return `Search files matching: ${pattern}`; case TOOL_GREP: return `Search content matching: ${pattern}`; default: return `${toolName}: ${pattern}`; } } /** * Bash: exact or explicit wildcard ("git *", "npm:*"). * File tools: path-prefix matching with segment boundaries. * Other tools: simple prefix matching. */ export function matchesRulePattern( toolName: string, actionPattern: string | null, rulePattern: string | undefined ): boolean { // No rule pattern means match all if (!rulePattern) return true; // Null action pattern means we can't determine the action - don't match if (actionPattern === null) return false; const normalizedAction = actionPattern.replace(/\\/g, '/'); const normalizedRule = rulePattern.replace(/\\/g, '/'); // Wildcard matches everything if (normalizedRule === '*') return true; // Exact match if (normalizedAction === normalizedRule) return true; // Bash: Only exact match (handled above) or explicit wildcard patterns are allowed. // This is intentional - Bash commands require explicit wildcards for security. // Supported formats: // - "git *" matches "git status", "git commit", etc. // - "npm:*" matches "npm install", "npm run", etc. (CC format) if (toolName === TOOL_BASH) { // CC format "npm:*" — colon is a separator, not part of the prefix if (normalizedRule.endsWith(':*')) { const prefix = normalizedRule.slice(0, -2); return matchesBashPrefix(normalizedAction, prefix); } // Space wildcard "git *" if (normalizedRule.endsWith('*')) { const prefix = normalizedRule.slice(0, -1); return matchesBashPrefix(normalizedAction, prefix); } // No wildcard present and exact match failed above - reject return false; } // File tools: prefix match with path-segment boundary awareness if ( toolName === TOOL_READ || toolName === TOOL_WRITE || toolName === TOOL_EDIT || toolName === TOOL_NOTEBOOK_EDIT ) { return isPathPrefixMatch(normalizedAction, normalizedRule); } // Other tools: allow simple prefix matching if (normalizedAction.startsWith(normalizedRule)) return true; return false; } function isPathPrefixMatch(actionPath: string, approvedPath: string): boolean { if (!actionPath.startsWith(approvedPath)) { return false; } if (approvedPath.endsWith('/')) { return true; } if (actionPath.length === approvedPath.length) { return true; } return actionPath.charAt(approvedPath.length) === '/'; } function matchesBashPrefix(action: string, prefix: string): boolean { if (action === prefix) { return true; } if (prefix.endsWith(' ')) { return action.startsWith(prefix); } return action.startsWith(`${prefix} `); } /** * Convert a user allow decision + SDK suggestions into PermissionUpdate[]. * * Only handles allow decisions — deny results use the SDK's bare deny path * (PermissionResult deny variant has no updatedPermissions field). * * Overrides destination on addRules/replaceRules suggestions to match the user's choice. * Other suggestion entries keep their original destinations (they may carry * specific semantics about where the update should be applied). * "always" destinations go to projectSettings; "allow" stays session. * Falls back to constructing an addRules entry from the action pattern * when no addRules/replaceRules suggestion is present. */ export function buildPermissionUpdates( toolName: string, input: Record, decision: 'allow' | 'allow-always', suggestions?: PermissionUpdate[] ): PermissionUpdate[] { const destination: PermissionUpdateDestination = decision === 'allow-always' ? 'projectSettings' : 'session'; const processed: PermissionUpdate[] = []; let hasRuleUpdate = false; if (suggestions) { for (const s of suggestions) { if (s.type === 'addRules' || s.type === 'replaceRules') { hasRuleUpdate = true; processed.push({ ...s, behavior: 'allow', destination }); } else { processed.push(s); } } } if (!hasRuleUpdate) { const pattern = getActionPattern(toolName, input); const ruleValue: { toolName: string; ruleContent?: string } = { toolName }; if (pattern && !pattern.startsWith('{')) { ruleValue.ruleContent = pattern; } processed.unshift({ type: 'addRules', behavior: 'allow', rules: [ruleValue], destination, }); } return processed; } ================================================ FILE: src/core/security/BashPathValidator.ts ================================================ /** * Bash Path Validator * * Pure functions for parsing bash commands and validating path access. * Extracted from ClaudianService for better testability and separation of concerns. */ import * as path from 'path'; import type { PathAccessType } from '../../utils/path'; export type PathViolation = | { type: 'outside_vault'; path: string } | { type: 'export_path_read'; path: string }; /** Context for path validation - allows dependency injection of access rules */ export interface PathCheckContext { getPathAccessType: (filePath: string) => PathAccessType; } /** * Split a bash command into tokens. * This is a best-effort tokenizer (quotes/backticks are handled; full bash parsing is out of scope). */ export function tokenizeBashCommand(command: string): string[] { const tokens: string[] = []; // Only handle single and double quotes as string delimiters. // Backticks are command substitution, not quoting -- handled by subshell extraction. const tokenRegex = /(['"])(.*?)\1|[^\s]+/g; let match: RegExpExecArray | null; while ((match = tokenRegex.exec(command)) !== null) { const token = match[2] ?? match[0]; const cleaned = token.trim(); if (!cleaned) continue; tokens.push(cleaned); } return tokens; } /** * Split tokens into segments by common bash operators. * Each segment is treated as an independent command for output-target heuristics. */ export function splitBashTokensIntoSegments(tokens: string[]): string[][] { const separators = new Set(['&&', '||', ';', '|']); const segments: string[][] = []; let current: string[] = []; for (const token of tokens) { if (separators.has(token)) { if (current.length > 0) { segments.push(current); current = []; } continue; } current.push(token); } if (current.length > 0) { segments.push(current); } return segments; } export function getBashSegmentCommandName(segment: string[]): { cmdName: string; cmdIndex: number } { const wrappers = new Set(['command', 'env', 'sudo']); let cmdIndex = 0; while (cmdIndex < segment.length) { const token = segment[cmdIndex]; if (wrappers.has(token)) { cmdIndex += 1; continue; } if (!token.startsWith('-') && token.includes('=')) { cmdIndex += 1; continue; } break; } const rawCmd = segment[cmdIndex] || ''; const cmdName = path.basename(rawCmd); return { cmdName, cmdIndex }; } const OUTPUT_REDIRECT_OPS = new Set(['>', '>>', '1>', '1>>', '2>', '2>>', '&>', '&>>', '>|']); const INPUT_REDIRECT_OPS = new Set(['<', '<<', '0<', '0<<']); const OUTPUT_OPTION_FLAGS = new Set(['-o', '--output', '--out', '--outfile', '--output-file']); export function isBashOutputRedirectOperator(token: string): boolean { return OUTPUT_REDIRECT_OPS.has(token); } export function isBashInputRedirectOperator(token: string): boolean { return INPUT_REDIRECT_OPS.has(token); } export function isBashOutputOptionExpectingValue(token: string): boolean { return OUTPUT_OPTION_FLAGS.has(token); } /** Clean a path token by stripping quotes and delimiters */ export function cleanPathToken(raw: string): string | null { let token = raw.trim(); if (!token) return null; token = stripQuoteChars(token); if (!token) return null; // Trim common delimiters from shells / subshells. while (token.startsWith('(') || token.startsWith('[') || token.startsWith('{')) { token = token.slice(1).trim(); } while ( token.endsWith(')') || token.endsWith(']') || token.endsWith('}') || token.endsWith(';') || token.endsWith(',') ) { token = token.slice(0, -1).trim(); } if (!token) return null; token = stripQuoteChars(token); if (!token) return null; if (token === '.' || token === '/' || token === '\\' || token === '--') return null; return token; } const QUOTE_CHARS = new Set(["'", '"', '`']); function stripQuoteChars(token: string): string { // Strip matched quotes first if ( token.length >= 2 && QUOTE_CHARS.has(token[0]) && token[0] === token[token.length - 1] ) { return token.slice(1, -1).trim(); } // Strip unmatched leading/trailing quote characters while (token.length > 0 && QUOTE_CHARS.has(token[0])) { token = token.slice(1); } while (token.length > 0 && QUOTE_CHARS.has(token[token.length - 1])) { token = token.slice(0, -1); } return token.trim(); } export function isPathLikeToken(token: string): boolean { const cleaned = token.trim(); if (!cleaned) return false; if (cleaned === '.' || cleaned === '/' || cleaned === '\\' || cleaned === '--') return false; const isWindows = process.platform === 'win32'; return ( // Home directory paths (Unix and Windows style) cleaned === '~' || cleaned.startsWith('~/') || (isWindows && cleaned.startsWith('~\\')) || // Relative paths cleaned.startsWith('./') || cleaned.startsWith('../') || cleaned === '..' || (isWindows && (cleaned.startsWith('.\\') || cleaned.startsWith('..\\'))) || // Absolute paths (Unix) cleaned.startsWith('/') || // Absolute paths (Windows drive letters) (isWindows && /^[A-Za-z]:[\\/]/.test(cleaned)) || // Absolute paths (Windows UNC) (isWindows && (cleaned.startsWith('\\\\') || cleaned.startsWith('//'))) || // Contains path separators cleaned.includes('/') || (isWindows && cleaned.includes('\\')) ); } /** * Check if a path has valid access permissions. * Returns a violation if the path is outside vault and not an allowed export/context path. */ export function checkBashPathAccess( candidate: string, access: 'read' | 'write', context: PathCheckContext ): PathViolation | null { const cleaned = cleanPathToken(candidate); if (!cleaned) return null; const accessType = context.getPathAccessType(cleaned); if (accessType === 'vault' || accessType === 'readwrite') { return null; } if (accessType === 'context') { return null; // Context paths have full read/write access } if (accessType === 'export') { return access === 'write' ? null : { type: 'export_path_read', path: cleaned }; } return { type: 'outside_vault', path: cleaned }; } /** * Find path violations in a single bash command segment. * Analyzes redirects, output options, and positional arguments. */ export function findBashPathViolationInSegment( segment: string[], context: PathCheckContext ): PathViolation | null { if (segment.length === 0) return null; const { cmdName, cmdIndex } = getBashSegmentCommandName(segment); // Some commands have a clear destination argument that should be treated as a write target. const destinationCommands = new Set(['cp', 'mv', 'rsync']); let destinationTokenIndex: number | null = null; if (destinationCommands.has(cmdName)) { const pathArgIndices: number[] = []; let seenDoubleDash = false; for (let i = cmdIndex + 1; i < segment.length; i += 1) { const token = segment[i]; if (!seenDoubleDash && token === '--') { seenDoubleDash = true; continue; } // Skip options (best-effort). if (!seenDoubleDash && token.startsWith('-')) { continue; } if (isPathLikeToken(token)) { pathArgIndices.push(i); } } if (pathArgIndices.length > 0) { destinationTokenIndex = pathArgIndices[pathArgIndices.length - 1]; } } let expectWriteNext = false; for (let i = 0; i < segment.length; i += 1) { const token = segment[i]; // Standalone redirection operators. if (isBashOutputRedirectOperator(token)) { expectWriteNext = true; continue; } if (isBashInputRedirectOperator(token)) { expectWriteNext = false; continue; } // Standalone output options. if (isBashOutputOptionExpectingValue(token)) { expectWriteNext = true; continue; } // Embedded redirection operators, e.g. ">/tmp/out", "2>>~/Desktop/log". const embeddedOutputRedirect = token.match(/^(?:&>>|&>|\d*>>|\d*>\||\d*>|>>|>\||>)(.+)$/); if (embeddedOutputRedirect) { const violation = checkBashPathAccess(embeddedOutputRedirect[1], 'write', context); if (violation) return violation; continue; } const embeddedInputRedirect = token.match(/^(?:\d*<<|\d*<|<<|<)(.+)$/); if (embeddedInputRedirect) { const violation = checkBashPathAccess(embeddedInputRedirect[1], 'read', context); if (violation) return violation; continue; } // Embedded output options, e.g. "--output=/tmp/out", "-o/tmp/out", "-o~/Desktop/out". const embeddedLongOutput = token.match(/^--(?:output|out|outfile|output-file)=(.+)$/); if (embeddedLongOutput) { const violation = checkBashPathAccess(embeddedLongOutput[1], 'write', context); if (violation) return violation; continue; } const embeddedShortOutput = token.match(/^-o(.+)$/); if (embeddedShortOutput) { const violation = checkBashPathAccess(embeddedShortOutput[1], 'write', context); if (violation) return violation; continue; } // Generic KEY=VALUE where VALUE looks like a path. // We treat this as a read access since it is ambiguous and can be used to smuggle paths. const eqIndex = token.indexOf('='); if (eqIndex > 0) { const key = token.slice(0, eqIndex); const value = token.slice(eqIndex + 1); if (key.startsWith('-') && isPathLikeToken(value)) { const violation = checkBashPathAccess(value, 'read', context); if (violation) return violation; } } if (!isPathLikeToken(token)) { expectWriteNext = false; continue; } const access: 'read' | 'write' = i === destinationTokenIndex || expectWriteNext ? 'write' : 'read'; const violation = checkBashPathAccess(token, access, context); if (violation) return violation; expectWriteNext = false; } return null; } /** Extract inner commands from command substitution patterns ($(...) and backticks) */ function extractSubshellCommands(command: string): string[] { const results: string[] = []; // Extract $(...) content, handling nested parens let i = 0; while (i < command.length) { if (command[i] === '$' && command[i + 1] === '(') { let depth = 1; const start = i + 2; let j = start; while (j < command.length && depth > 0) { if (command[j] === '(') depth++; else if (command[j] === ')') depth--; j++; } if (depth === 0) { results.push(command.slice(start, j - 1)); } i = j; } else { i++; } } // Extract backtick content (already handled by tokenizer, but we also check // raw command for cases where backticks span the whole token) const backtickRegex = /`([^`]+)`/g; let match: RegExpExecArray | null; while ((match = backtickRegex.exec(command)) !== null) { results.push(match[1]); } return results; } /** * Find the first path violation in a bash command. * Main entry point for bash command validation. * * @param command - The bash command to analyze * @param context - Path checking context with vault/export path validators * @returns The first violation found, or null if command is safe */ export function findBashCommandPathViolation( command: string, context: PathCheckContext ): PathViolation | null { if (!command) return null; // Recursively check subshell commands first const subshellCommands = extractSubshellCommands(command); for (const subCmd of subshellCommands) { const violation = findBashCommandPathViolation(subCmd, context); if (violation) return violation; } const tokens = tokenizeBashCommand(command); const segments = splitBashTokensIntoSegments(tokens); for (const segment of segments) { const violation = findBashPathViolationInSegment(segment, context); if (violation) { return violation; } } return null; } ================================================ FILE: src/core/security/BlocklistChecker.ts ================================================ /** * Blocklist Checker * * Checks bash commands against user-defined blocklist patterns. * Patterns are treated as case-insensitive regex with fallback to substring match. */ const MAX_PATTERN_LENGTH = 500; export function isCommandBlocked( command: string, patterns: string[], enableBlocklist: boolean ): boolean { if (!enableBlocklist) { return false; } return patterns.some((pattern) => { if (pattern.length > MAX_PATTERN_LENGTH) { return command.toLowerCase().includes(pattern.toLowerCase()); } try { return new RegExp(pattern, 'i').test(command); } catch { // Invalid regex - fall back to substring match return command.toLowerCase().includes(pattern.toLowerCase()); } }); } ================================================ FILE: src/core/security/index.ts ================================================ export { buildPermissionUpdates, getActionDescription, getActionPattern, matchesRulePattern, } from './ApprovalManager'; export { checkBashPathAccess, cleanPathToken, findBashCommandPathViolation, findBashPathViolationInSegment, getBashSegmentCommandName, isBashInputRedirectOperator, isBashOutputOptionExpectingValue, isBashOutputRedirectOperator, isPathLikeToken, type PathCheckContext, type PathViolation, splitBashTokensIntoSegments, tokenizeBashCommand, } from './BashPathValidator'; export { isCommandBlocked, } from './BlocklistChecker'; ================================================ FILE: src/core/storage/AgentVaultStorage.ts ================================================ import { serializeAgent } from '../../utils/agent'; import { buildAgentFromFrontmatter, parseAgentFile } from '../agents/AgentStorage'; import type { AgentDefinition } from '../types'; import type { VaultFileAdapter } from './VaultFileAdapter'; export const AGENTS_PATH = '.claude/agents'; export class AgentVaultStorage { constructor(private adapter: VaultFileAdapter) {} async loadAll(): Promise { const agents: AgentDefinition[] = []; try { const files = await this.adapter.listFiles(AGENTS_PATH); for (const filePath of files) { if (!filePath.endsWith('.md')) continue; try { const content = await this.adapter.read(filePath); const parsed = parseAgentFile(content); if (!parsed) continue; const { frontmatter, body } = parsed; agents.push(buildAgentFromFrontmatter(frontmatter, body, { id: frontmatter.name, source: 'vault', filePath, })); } catch { /* Non-critical: skip malformed agent files */ } } } catch { /* Non-critical: directory may not exist yet */ } return agents; } async load(agent: AgentDefinition): Promise { const filePath = this.resolvePath(agent); try { const content = await this.adapter.read(filePath); const parsed = parseAgentFile(content); if (!parsed) return null; const { frontmatter, body } = parsed; return buildAgentFromFrontmatter(frontmatter, body, { id: frontmatter.name, source: agent.source, filePath, }); } catch (error) { if (this.isFileNotFoundError(error)) { return null; } throw error; } } async save(agent: AgentDefinition): Promise { await this.adapter.write(this.resolvePath(agent), serializeAgent(agent)); } async delete(agent: AgentDefinition): Promise { await this.adapter.delete(this.resolvePath(agent)); } private resolvePath(agent: AgentDefinition): string { if (!agent.filePath) { return `${AGENTS_PATH}/${agent.name}.md`; } const normalized = agent.filePath.replace(/\\/g, '/'); const idx = normalized.lastIndexOf(`${AGENTS_PATH}/`); if (idx !== -1) { return normalized.slice(idx); } return `${AGENTS_PATH}/${agent.name}.md`; } private isFileNotFoundError(error: unknown): boolean { if (!error) return false; if (typeof error === 'string') { return /enoent|not found|no such file/i.test(error); } if (typeof error === 'object') { const maybeCode = (error as { code?: unknown }).code; if (typeof maybeCode === 'string' && /enoent|not.?found/i.test(maybeCode)) { return true; } const maybeMessage = (error as { message?: unknown }).message; if (typeof maybeMessage === 'string' && /enoent|not found|no such file/i.test(maybeMessage)) { return true; } } return false; } } ================================================ FILE: src/core/storage/CCSettingsStorage.ts ================================================ /** * CCSettingsStorage - Handles CC-compatible settings.json read/write. * * Manages the .claude/settings.json file in Claude Code compatible format. * This file is shared with Claude Code CLI for interoperability. * * Only CC-compatible fields are stored here: * - permissions (allow/deny/ask) * - model (optional override) * - env (optional environment variables) * * Claudian-specific settings go in claudian-settings.json. */ import type { CCPermissions, CCSettings, LegacyPermission, PermissionRule, } from '../types'; import { DEFAULT_CC_PERMISSIONS, DEFAULT_CC_SETTINGS, legacyPermissionsToCCPermissions, } from '../types'; import { CLAUDIAN_ONLY_FIELDS } from './migrationConstants'; import type { VaultFileAdapter } from './VaultFileAdapter'; /** Path to CC settings file relative to vault root. */ export const CC_SETTINGS_PATH = '.claude/settings.json'; /** Schema URL for CC settings. */ const CC_SETTINGS_SCHEMA = 'https://json.schemastore.org/claude-code-settings.json'; function hasClaudianOnlyFields(data: Record): boolean { return Object.keys(data).some(key => CLAUDIAN_ONLY_FIELDS.has(key)); } /** * Check if a settings object uses the legacy Claudian permissions format. * Legacy format: permissions is an array of objects with toolName/pattern. */ export function isLegacyPermissionsFormat(data: unknown): data is { permissions: LegacyPermission[] } { if (!data || typeof data !== 'object') return false; const obj = data as Record; if (!Array.isArray(obj.permissions)) return false; if (obj.permissions.length === 0) return false; // Check if first item has legacy structure const first = obj.permissions[0]; return ( typeof first === 'object' && first !== null && 'toolName' in first && 'pattern' in first ); } function normalizeRuleList(value: unknown): PermissionRule[] { if (!Array.isArray(value)) return []; return value.filter((r): r is string => typeof r === 'string') as PermissionRule[]; } function normalizePermissions(permissions: unknown): CCPermissions { if (!permissions || typeof permissions !== 'object') { return { ...DEFAULT_CC_PERMISSIONS }; } const p = permissions as Record; return { allow: normalizeRuleList(p.allow), deny: normalizeRuleList(p.deny), ask: normalizeRuleList(p.ask), defaultMode: typeof p.defaultMode === 'string' ? p.defaultMode as CCPermissions['defaultMode'] : undefined, additionalDirectories: Array.isArray(p.additionalDirectories) ? p.additionalDirectories.filter((d): d is string => typeof d === 'string') : undefined, }; } /** * Storage for CC-compatible settings. * * Note: Permission update methods (addAllowRule, addDenyRule, etc.) use a * read-modify-write pattern. Concurrent calls may race and lose updates. * In practice this is fine since user interactions are sequential. */ export class CCSettingsStorage { constructor(private adapter: VaultFileAdapter) { } /** * Load CC settings from .claude/settings.json. * Returns default settings if file doesn't exist. * Throws if file exists but cannot be read or parsed. */ async load(): Promise { if (!(await this.adapter.exists(CC_SETTINGS_PATH))) { return { ...DEFAULT_CC_SETTINGS }; } const content = await this.adapter.read(CC_SETTINGS_PATH); const stored = JSON.parse(content) as Record; // Check for legacy format and migrate if needed if (isLegacyPermissionsFormat(stored)) { const legacyPerms = stored.permissions as LegacyPermission[]; const ccPerms = legacyPermissionsToCCPermissions(legacyPerms); // Return migrated permissions but keep other CC fields return { $schema: CC_SETTINGS_SCHEMA, ...stored, permissions: ccPerms, }; } return { $schema: CC_SETTINGS_SCHEMA, ...stored, permissions: normalizePermissions(stored.permissions), }; } /** * Save CC settings to .claude/settings.json. * Preserves unknown fields for CC compatibility. * * @param stripClaudianFields - If true, remove Claudian-only fields (only during migration) */ async save(settings: CCSettings, stripClaudianFields: boolean = false): Promise { // Load existing to preserve CC-specific fields we don't manage let existing: Record = {}; if (await this.adapter.exists(CC_SETTINGS_PATH)) { try { const content = await this.adapter.read(CC_SETTINGS_PATH); const parsed = JSON.parse(content) as Record; // Only strip Claudian-only fields during explicit migration if (stripClaudianFields && (isLegacyPermissionsFormat(parsed) || hasClaudianOnlyFields(parsed))) { existing = {}; for (const [key, value] of Object.entries(parsed)) { if (!CLAUDIAN_ONLY_FIELDS.has(key)) { existing[key] = value; } } // Also strip legacy permissions array format if (Array.isArray(existing.permissions)) { delete existing.permissions; } } else { existing = parsed; } } catch { // Parse error - start fresh with default settings } } // Merge: existing CC fields + our updates const merged: CCSettings = { ...existing, $schema: CC_SETTINGS_SCHEMA, permissions: settings.permissions ?? { ...DEFAULT_CC_PERMISSIONS }, }; if (settings.enabledPlugins !== undefined) { merged.enabledPlugins = settings.enabledPlugins; } const content = JSON.stringify(merged, null, 2); await this.adapter.write(CC_SETTINGS_PATH, content); } async exists(): Promise { return this.adapter.exists(CC_SETTINGS_PATH); } async getPermissions(): Promise { const settings = await this.load(); return settings.permissions ?? { ...DEFAULT_CC_PERMISSIONS }; } async updatePermissions(permissions: CCPermissions): Promise { const settings = await this.load(); settings.permissions = permissions; await this.save(settings); } async addAllowRule(rule: PermissionRule): Promise { const permissions = await this.getPermissions(); if (!permissions.allow?.includes(rule)) { permissions.allow = [...(permissions.allow ?? []), rule]; await this.updatePermissions(permissions); } } async addDenyRule(rule: PermissionRule): Promise { const permissions = await this.getPermissions(); if (!permissions.deny?.includes(rule)) { permissions.deny = [...(permissions.deny ?? []), rule]; await this.updatePermissions(permissions); } } async addAskRule(rule: PermissionRule): Promise { const permissions = await this.getPermissions(); if (!permissions.ask?.includes(rule)) { permissions.ask = [...(permissions.ask ?? []), rule]; await this.updatePermissions(permissions); } } /** * Remove a rule from all lists. */ async removeRule(rule: PermissionRule): Promise { const permissions = await this.getPermissions(); permissions.allow = permissions.allow?.filter(r => r !== rule); permissions.deny = permissions.deny?.filter(r => r !== rule); permissions.ask = permissions.ask?.filter(r => r !== rule); await this.updatePermissions(permissions); } /** * Get enabled plugins map from CC settings. * Returns empty object if not set. */ async getEnabledPlugins(): Promise> { const settings = await this.load(); return settings.enabledPlugins ?? {}; } /** * Set plugin enabled state. * Writes to .claude/settings.json so CLI respects the state. * * @param pluginId - Full plugin ID (e.g., "plugin-name@source") * @param enabled - true to enable, false to disable */ async setPluginEnabled(pluginId: string, enabled: boolean): Promise { const settings = await this.load(); const enabledPlugins = settings.enabledPlugins ?? {}; enabledPlugins[pluginId] = enabled; settings.enabledPlugins = enabledPlugins; await this.save(settings); } /** * Get list of plugin IDs that are explicitly enabled. * Used for PluginManager initialization. */ async getExplicitlyEnabledPluginIds(): Promise { const enabledPlugins = await this.getEnabledPlugins(); return Object.entries(enabledPlugins) .filter(([, enabled]) => enabled) .map(([id]) => id); } /** * Check if a plugin is explicitly disabled. * Returns true only if the plugin is set to false. * Returns false if not set (inherits from global) or set to true. */ async isPluginDisabled(pluginId: string): Promise { const enabledPlugins = await this.getEnabledPlugins(); return enabledPlugins[pluginId] === false; } } ================================================ FILE: src/core/storage/ClaudianSettingsStorage.ts ================================================ /** * ClaudianSettingsStorage - Handles claudian-settings.json read/write. * * Manages the .claude/claudian-settings.json file for Claudian-specific settings. * These settings are NOT shared with Claude Code CLI. * * Includes: * - User preferences (userName) * - Security (blocklist, permission mode) * - Model & thinking settings * - Content settings (tags, media, prompts) * - Environment (string format, snippets) * - UI settings (keyboard navigation) * - CLI paths * - State (merged from data.json) */ import type { ClaudeModel, ClaudianSettings, PlatformBlockedCommands } from '../types'; import { DEFAULT_SETTINGS, getDefaultBlockedCommands } from '../types'; import type { VaultFileAdapter } from './VaultFileAdapter'; /** Path to Claudian settings file relative to vault root. */ export const CLAUDIAN_SETTINGS_PATH = '.claude/claudian-settings.json'; /** Fields that are loaded separately (slash commands from .claude/commands/). */ type SeparatelyLoadedFields = 'slashCommands'; /** Settings stored in .claude/claudian-settings.json. */ export type StoredClaudianSettings = Omit; function normalizeCommandList(value: unknown, fallback: string[]): string[] { if (!Array.isArray(value)) { return [...fallback]; } return value .filter((item): item is string => typeof item === 'string') .map((item) => item.trim()) .filter((item) => item.length > 0); } export function normalizeBlockedCommands(value: unknown): PlatformBlockedCommands { const defaults = getDefaultBlockedCommands(); // Migrate old string[] format to new platform-keyed structure if (Array.isArray(value)) { return { unix: normalizeCommandList(value, defaults.unix), windows: [...defaults.windows], }; } if (!value || typeof value !== 'object') { return defaults; } const candidate = value as Record; return { unix: normalizeCommandList(candidate.unix, defaults.unix), windows: normalizeCommandList(candidate.windows, defaults.windows), }; } function normalizeHostnameCliPaths(value: unknown): Record { if (!value || typeof value !== 'object') { return {}; } const result: Record = {}; for (const [key, val] of Object.entries(value)) { if (typeof val === 'string' && val.trim()) { result[key] = val.trim(); } } return result; } export class ClaudianSettingsStorage { constructor(private adapter: VaultFileAdapter) { } /** * Load Claudian settings from .claude/claudian-settings.json. * Returns default settings if file doesn't exist. * Throws if file exists but cannot be read or parsed. */ async load(): Promise { if (!(await this.adapter.exists(CLAUDIAN_SETTINGS_PATH))) { return this.getDefaults(); } const content = await this.adapter.read(CLAUDIAN_SETTINGS_PATH); const stored = JSON.parse(content) as Record; const { activeConversationId: _activeConversationId, show1MModel: _show1MModel, ...storedWithoutLegacy } = stored; // Remove legacy show1MModel from persisted file (replaced by enableOpus1M/enableSonnet1M) if ('show1MModel' in stored) { await this.adapter.write(CLAUDIAN_SETTINGS_PATH, JSON.stringify(storedWithoutLegacy, null, 2)); } const blockedCommands = normalizeBlockedCommands(stored.blockedCommands); const hostnameCliPaths = normalizeHostnameCliPaths(stored.claudeCliPathsByHost); const legacyCliPath = typeof stored.claudeCliPath === 'string' ? stored.claudeCliPath : ''; return { ...this.getDefaults(), ...storedWithoutLegacy, blockedCommands, claudeCliPath: legacyCliPath, claudeCliPathsByHost: hostnameCliPaths, } as StoredClaudianSettings; } async save(settings: StoredClaudianSettings): Promise { const content = JSON.stringify(settings, null, 2); await this.adapter.write(CLAUDIAN_SETTINGS_PATH, content); } async exists(): Promise { return this.adapter.exists(CLAUDIAN_SETTINGS_PATH); } async update(updates: Partial): Promise { const current = await this.load(); await this.save({ ...current, ...updates }); } /** * Read legacy activeConversationId from claudian-settings.json, if present. * Used only for one-time migration to tabManagerState. */ async getLegacyActiveConversationId(): Promise { if (!(await this.adapter.exists(CLAUDIAN_SETTINGS_PATH))) { return null; } const content = await this.adapter.read(CLAUDIAN_SETTINGS_PATH); const stored = JSON.parse(content) as Record; const value = stored.activeConversationId; if (typeof value === 'string') { return value; } return null; } /** * Remove legacy activeConversationId from claudian-settings.json. */ async clearLegacyActiveConversationId(): Promise { if (!(await this.adapter.exists(CLAUDIAN_SETTINGS_PATH))) { return; } const content = await this.adapter.read(CLAUDIAN_SETTINGS_PATH); const stored = JSON.parse(content) as Record; if (!('activeConversationId' in stored)) { return; } delete stored.activeConversationId; const nextContent = JSON.stringify(stored, null, 2); await this.adapter.write(CLAUDIAN_SETTINGS_PATH, nextContent); } async setLastModel(model: ClaudeModel, isCustom: boolean): Promise { if (isCustom) { await this.update({ lastCustomModel: model }); } else { await this.update({ lastClaudeModel: model }); } } async setLastEnvHash(hash: string): Promise { await this.update({ lastEnvHash: hash }); } /** * Get default settings (excluding separately loaded fields). */ private getDefaults(): StoredClaudianSettings { const { slashCommands: _, ...defaults } = DEFAULT_SETTINGS; return defaults; } } ================================================ FILE: src/core/storage/McpStorage.ts ================================================ /** * McpStorage - Handles .claude/mcp.json read/write * * MCP server configurations are stored in Claude Code-compatible format * with optional Claudian-specific metadata in _claudian field. * * File format: * { * "mcpServers": { * "server-name": { "command": "...", "args": [...] } * }, * "_claudian": { * "servers": { * "server-name": { "enabled": true, "contextSaving": true, "disabledTools": ["tool"], "description": "..." } * } * } * } */ import type { ClaudianMcpConfigFile, ClaudianMcpServer, McpServerConfig, ParsedMcpConfig, } from '../types'; import { DEFAULT_MCP_SERVER, isValidMcpServerConfig } from '../types'; import type { VaultFileAdapter } from './VaultFileAdapter'; /** Path to MCP config file relative to vault root. */ export const MCP_CONFIG_PATH = '.claude/mcp.json'; export class McpStorage { constructor(private adapter: VaultFileAdapter) {} async load(): Promise { try { if (!(await this.adapter.exists(MCP_CONFIG_PATH))) { return []; } const content = await this.adapter.read(MCP_CONFIG_PATH); const file = JSON.parse(content) as ClaudianMcpConfigFile; if (!file.mcpServers || typeof file.mcpServers !== 'object') { return []; } const claudianMeta = file._claudian?.servers ?? {}; const servers: ClaudianMcpServer[] = []; for (const [name, config] of Object.entries(file.mcpServers)) { if (!isValidMcpServerConfig(config)) { continue; } const meta = claudianMeta[name] ?? {}; const disabledTools = Array.isArray(meta.disabledTools) ? meta.disabledTools.filter((tool) => typeof tool === 'string') : undefined; const normalizedDisabledTools = disabledTools && disabledTools.length > 0 ? disabledTools : undefined; servers.push({ name, config, enabled: meta.enabled ?? DEFAULT_MCP_SERVER.enabled, contextSaving: meta.contextSaving ?? DEFAULT_MCP_SERVER.contextSaving, disabledTools: normalizedDisabledTools, description: meta.description, }); } return servers; } catch { return []; } } async save(servers: ClaudianMcpServer[]): Promise { const mcpServers: Record = {}; const claudianServers: Record< string, { enabled?: boolean; contextSaving?: boolean; disabledTools?: string[]; description?: string } > = {}; for (const server of servers) { mcpServers[server.name] = server.config; // Only store Claudian metadata if different from defaults const meta: { enabled?: boolean; contextSaving?: boolean; disabledTools?: string[]; description?: string; } = {}; if (server.enabled !== DEFAULT_MCP_SERVER.enabled) { meta.enabled = server.enabled; } if (server.contextSaving !== DEFAULT_MCP_SERVER.contextSaving) { meta.contextSaving = server.contextSaving; } const normalizedDisabledTools = server.disabledTools ?.map((tool) => tool.trim()) .filter((tool) => tool.length > 0); if (normalizedDisabledTools && normalizedDisabledTools.length > 0) { meta.disabledTools = normalizedDisabledTools; } if (server.description) { meta.description = server.description; } if (Object.keys(meta).length > 0) { claudianServers[server.name] = meta; } } let existing: Record | null = null; if (await this.adapter.exists(MCP_CONFIG_PATH)) { try { const raw = await this.adapter.read(MCP_CONFIG_PATH); const parsed = JSON.parse(raw); if (parsed && typeof parsed === 'object') { existing = parsed as Record; } } catch { existing = null; } } const file: Record = existing ? { ...existing } : {}; file.mcpServers = mcpServers; const existingClaudian = existing && typeof existing._claudian === 'object' ? (existing._claudian as Record) : null; if (Object.keys(claudianServers).length > 0) { file._claudian = { ...(existingClaudian ?? {}), servers: claudianServers }; } else if (existingClaudian) { const { servers: _servers, ...rest } = existingClaudian; if (Object.keys(rest).length > 0) { file._claudian = rest; } else { delete file._claudian; } } else { delete file._claudian; } const content = JSON.stringify(file, null, 2); await this.adapter.write(MCP_CONFIG_PATH, content); } async exists(): Promise { return this.adapter.exists(MCP_CONFIG_PATH); } /** * Parse pasted JSON (supports multiple formats). * * Formats supported: * 1. Full Claude Code format: { "mcpServers": { "name": {...} } } * 2. Single server with name: { "name": { "command": "..." } } * 3. Single server without name: { "command": "..." } */ static parseClipboardConfig(json: string): ParsedMcpConfig { try { const parsed = JSON.parse(json); if (!parsed || typeof parsed !== 'object') { throw new Error('Invalid JSON object'); } // Format 1: Full Claude Code format // { "mcpServers": { "server-name": { "command": "...", ... } } } if (parsed.mcpServers && typeof parsed.mcpServers === 'object') { const servers: Array<{ name: string; config: McpServerConfig }> = []; for (const [name, config] of Object.entries(parsed.mcpServers)) { if (isValidMcpServerConfig(config)) { servers.push({ name, config: config as McpServerConfig }); } } if (servers.length === 0) { throw new Error('No valid server configs found in mcpServers'); } return { servers, needsName: false }; } // Format 2: Single server config without name // { "command": "...", "args": [...] } or { "type": "sse", "url": "..." } if (isValidMcpServerConfig(parsed)) { return { servers: [{ name: '', config: parsed as McpServerConfig }], needsName: true, }; } // Format 3: Single named server // { "server-name": { "command": "...", ... } } const entries = Object.entries(parsed); if (entries.length === 1) { const [name, config] = entries[0]; if (isValidMcpServerConfig(config)) { return { servers: [{ name, config: config as McpServerConfig }], needsName: false, }; } } // Format 4: Multiple named servers (without mcpServers wrapper) // { "server1": {...}, "server2": {...} } const servers: Array<{ name: string; config: McpServerConfig }> = []; for (const [name, config] of entries) { if (isValidMcpServerConfig(config)) { servers.push({ name, config: config as McpServerConfig }); } } if (servers.length > 0) { return { servers, needsName: false }; } throw new Error('Invalid MCP configuration format'); } catch (error) { if (error instanceof SyntaxError) { throw new Error('Invalid JSON'); } throw error; } } /** * Try to parse clipboard content as MCP config. * Returns null if not valid MCP config. */ static tryParseClipboardConfig(text: string): ParsedMcpConfig | null { // Quick check - must look like JSON const trimmed = text.trim(); if (!trimmed.startsWith('{')) { return null; } try { return McpStorage.parseClipboardConfig(trimmed); } catch { return null; } } } ================================================ FILE: src/core/storage/SessionStorage.ts ================================================ /** * SessionStorage - Handles chat session files in vault/.claude/sessions/ * * Each conversation is stored as a JSONL (JSON Lines) file. * First line contains metadata, subsequent lines contain messages. * * JSONL format: * ``` * {"type":"meta","id":"conv-123","title":"Fix bug","createdAt":1703500000,"sessionId":"sdk-xyz"} * {"type":"message","id":"msg-1","role":"user","content":"...","timestamp":1703500001} * {"type":"message","id":"msg-2","role":"assistant","content":"...","timestamp":1703500002} * ``` */ import { isSubagentToolName } from '../tools/toolNames'; import type { ChatMessage, Conversation, ConversationMeta, SessionMetadata, SubagentInfo, UsageInfo, } from '../types'; import type { VaultFileAdapter } from './VaultFileAdapter'; /** Path to sessions folder relative to vault root. */ export const SESSIONS_PATH = '.claude/sessions'; /** Metadata record stored as first line of JSONL. */ interface SessionMetaRecord { type: 'meta'; id: string; title: string; createdAt: number; updatedAt: number; lastResponseAt?: number; sessionId: string | null; currentNote?: string; usage?: UsageInfo; titleGenerationStatus?: 'pending' | 'success' | 'failed'; } /** Message record stored as subsequent lines. */ interface SessionMessageRecord { type: 'message'; message: ChatMessage; } /** Union type for JSONL records. */ type SessionRecord = SessionMetaRecord | SessionMessageRecord; export class SessionStorage { constructor(private adapter: VaultFileAdapter) { } async loadConversation(id: string): Promise { const filePath = this.getFilePath(id); try { if (!(await this.adapter.exists(filePath))) { return null; } const content = await this.adapter.read(filePath); return this.parseJSONL(content); } catch { return null; } } async saveConversation(conversation: Conversation): Promise { const filePath = this.getFilePath(conversation.id); const content = this.serializeToJSONL(conversation); await this.adapter.write(filePath, content); } async deleteConversation(id: string): Promise { const filePath = this.getFilePath(id); await this.adapter.delete(filePath); } /** List all conversation metadata (without loading full messages). */ async listConversations(): Promise { const metas: ConversationMeta[] = []; try { const files = await this.adapter.listFiles(SESSIONS_PATH); for (const filePath of files) { if (!filePath.endsWith('.jsonl')) continue; try { const meta = await this.loadMetaOnly(filePath); if (meta) { metas.push(meta); } } catch { // Skip files that fail to load } } // Sort by updatedAt descending (most recent first) metas.sort((a, b) => b.updatedAt - a.updatedAt); } catch { // Return empty list if directory listing fails } return metas; } async loadAllConversations(): Promise<{ conversations: Conversation[]; failedCount: number }> { const conversations: Conversation[] = []; let failedCount = 0; try { const files = await this.adapter.listFiles(SESSIONS_PATH); for (const filePath of files) { if (!filePath.endsWith('.jsonl')) continue; try { const content = await this.adapter.read(filePath); const conversation = this.parseJSONL(content); if (conversation) { conversations.push(conversation); } else { failedCount++; } } catch { failedCount++; } } conversations.sort((a, b) => b.updatedAt - a.updatedAt); } catch { // Return empty list if directory listing fails } return { conversations, failedCount }; } async hasSessions(): Promise { const files = await this.adapter.listFiles(SESSIONS_PATH); return files.some(f => f.endsWith('.jsonl')); } getFilePath(id: string): string { return `${SESSIONS_PATH}/${id}.jsonl`; } private async loadMetaOnly(filePath: string): Promise { const content = await this.adapter.read(filePath); // Handle both Unix (LF) and Windows (CRLF) line endings const firstLine = content.split(/\r?\n/)[0]; if (!firstLine) return null; try { const record = JSON.parse(firstLine) as SessionRecord; if (record.type !== 'meta') return null; // Count messages by counting remaining lines const lines = content.split(/\r?\n/).filter(l => l.trim()); const messageCount = lines.length - 1; // Get preview from first user message let preview = 'New conversation'; for (let i = 1; i < lines.length; i++) { try { const msgRecord = JSON.parse(lines[i]) as SessionRecord; if (msgRecord.type === 'message' && msgRecord.message.role === 'user') { const content = msgRecord.message.content; preview = content.substring(0, 50) + (content.length > 50 ? '...' : ''); break; } } catch { continue; } } return { id: record.id, title: record.title, createdAt: record.createdAt, updatedAt: record.updatedAt, lastResponseAt: record.lastResponseAt, messageCount, preview, titleGenerationStatus: record.titleGenerationStatus, }; } catch { return null; } } private parseJSONL(content: string): Conversation | null { // Handle both Unix (LF) and Windows (CRLF) line endings const lines = content.split(/\r?\n/).filter(l => l.trim()); if (lines.length === 0) return null; let meta: SessionMetaRecord | null = null; const messages: ChatMessage[] = []; for (const line of lines) { try { const record = JSON.parse(line) as SessionRecord; if (record.type === 'meta') { meta = record; } else if (record.type === 'message') { messages.push(record.message); } } catch { // Skip invalid JSONL lines } } if (!meta) return null; return { id: meta.id, title: meta.title, createdAt: meta.createdAt, updatedAt: meta.updatedAt, lastResponseAt: meta.lastResponseAt, sessionId: meta.sessionId, messages, currentNote: meta.currentNote, usage: meta.usage, titleGenerationStatus: meta.titleGenerationStatus, }; } private serializeToJSONL(conversation: Conversation): string { const lines: string[] = []; // First line: metadata const meta: SessionMetaRecord = { type: 'meta', id: conversation.id, title: conversation.title, createdAt: conversation.createdAt, updatedAt: conversation.updatedAt, lastResponseAt: conversation.lastResponseAt, sessionId: conversation.sessionId, currentNote: conversation.currentNote, usage: conversation.usage, titleGenerationStatus: conversation.titleGenerationStatus, }; lines.push(JSON.stringify(meta)); // Subsequent lines: messages for (const message of conversation.messages) { const record: SessionMessageRecord = { type: 'message', message, }; lines.push(JSON.stringify(record)); } return lines.join('\n'); } /** * Detects if a session uses SDK-native storage. * A session is "native" if no legacy JSONL file exists. * * Legacy sessions have id.jsonl (and optionally id.meta.json). * Native sessions have only id.meta.json or no files yet (SDK stores messages). */ async isNativeSession(id: string): Promise { const legacyPath = `${SESSIONS_PATH}/${id}.jsonl`; const legacyExists = await this.adapter.exists(legacyPath); // Native if no legacy JSONL exists (new conversation or meta-only) return !legacyExists; } getMetadataPath(id: string): string { return `${SESSIONS_PATH}/${id}.meta.json`; } async saveMetadata(metadata: SessionMetadata): Promise { const filePath = this.getMetadataPath(metadata.id); const content = JSON.stringify(metadata, null, 2); await this.adapter.write(filePath, content); } async loadMetadata(id: string): Promise { const filePath = this.getMetadataPath(id); try { if (!(await this.adapter.exists(filePath))) { return null; } const content = await this.adapter.read(filePath); return JSON.parse(content) as SessionMetadata; } catch { return null; } } async deleteMetadata(id: string): Promise { const filePath = this.getMetadataPath(id); await this.adapter.delete(filePath); } /** List all native session metadata (.meta.json files without .jsonl counterparts). */ async listNativeMetadata(): Promise { const metas: SessionMetadata[] = []; try { const files = await this.adapter.listFiles(SESSIONS_PATH); const metaFiles = files.filter(f => f.endsWith('.meta.json')); for (const filePath of metaFiles) { // Extract ID from path: .claude/sessions/{id}.meta.json const fileName = filePath.split('/').pop() || ''; const id = fileName.replace('.meta.json', ''); // Check if this is truly native (no legacy .jsonl exists) const legacyPath = `${SESSIONS_PATH}/${id}.jsonl`; const legacyExists = await this.adapter.exists(legacyPath); if (legacyExists) { // Skip - this has legacy storage, meta.json is supplementary continue; } try { const content = await this.adapter.read(filePath); const meta = JSON.parse(content) as SessionMetadata; metas.push(meta); } catch { // Skip files that fail to load } } } catch { // Return empty list if directory listing fails } return metas; } /** * List all conversations, merging legacy JSONL and native metadata sources. * Legacy conversations take precedence if both exist. */ async listAllConversations(): Promise { const metas: ConversationMeta[] = []; // 1. Load legacy conversations (existing .jsonl files) const legacyMetas = await this.listConversations(); metas.push(...legacyMetas); // 2. Load native metadata (.meta.json files) const nativeMetas = await this.listNativeMetadata(); // 3. Merge, avoiding duplicates (legacy takes precedence) const legacyIds = new Set(legacyMetas.map(m => m.id)); for (const meta of nativeMetas) { if (!legacyIds.has(meta.id)) { metas.push({ id: meta.id, title: meta.title, createdAt: meta.createdAt, updatedAt: meta.updatedAt, lastResponseAt: meta.lastResponseAt, messageCount: 0, // Native sessions don't track message count in metadata preview: 'SDK session', // SDK stores messages, we don't parse them for preview titleGenerationStatus: meta.titleGenerationStatus, isNative: true, }); } } // 4. Sort by lastResponseAt descending (fallback to createdAt) return metas.sort((a, b) => (b.lastResponseAt ?? b.createdAt) - (a.lastResponseAt ?? a.createdAt) ); } toSessionMetadata(conversation: Conversation): SessionMetadata { const subagentData = this.extractSubagentData(conversation.messages); return { id: conversation.id, title: conversation.title, titleGenerationStatus: conversation.titleGenerationStatus, createdAt: conversation.createdAt, updatedAt: conversation.updatedAt, lastResponseAt: conversation.lastResponseAt, sessionId: conversation.sessionId, sdkSessionId: conversation.sdkSessionId, previousSdkSessionIds: conversation.previousSdkSessionIds, currentNote: conversation.currentNote, externalContextPaths: conversation.externalContextPaths, enabledMcpServers: conversation.enabledMcpServers, usage: conversation.usage, legacyCutoffAt: conversation.legacyCutoffAt, subagentData: Object.keys(subagentData).length > 0 ? subagentData : undefined, resumeSessionAt: conversation.resumeSessionAt, forkSource: conversation.forkSource, }; } /** * Extracts subagentData from messages for persistence. * Collects subagent info from Agent tool calls, including legacy Task transcripts. */ private extractSubagentData(messages: ChatMessage[]): Record { const result: Record = {}; for (const msg of messages) { if (msg.role !== 'assistant') continue; if (msg.toolCalls) { for (const toolCall of msg.toolCalls) { if (!isSubagentToolName(toolCall.name) || !toolCall.subagent) continue; result[toolCall.subagent.id] = toolCall.subagent; } } } return result; } } ================================================ FILE: src/core/storage/SkillStorage.ts ================================================ import { parsedToSlashCommand, parseSlashCommandContent, serializeCommand } from '../../utils/slashCommand'; import type { SlashCommand } from '../types'; import type { VaultFileAdapter } from './VaultFileAdapter'; export const SKILLS_PATH = '.claude/skills'; export class SkillStorage { constructor(private adapter: VaultFileAdapter) {} async loadAll(): Promise { const skills: SlashCommand[] = []; try { const folders = await this.adapter.listFolders(SKILLS_PATH); for (const folder of folders) { const skillName = folder.split('/').pop()!; const skillPath = `${SKILLS_PATH}/${skillName}/SKILL.md`; try { if (!(await this.adapter.exists(skillPath))) continue; const content = await this.adapter.read(skillPath); const parsed = parseSlashCommandContent(content); skills.push(parsedToSlashCommand(parsed, { id: `skill-${skillName}`, name: skillName, source: 'user', })); } catch { // Non-critical: skip malformed skill files } } } catch { return []; } return skills; } async save(skill: SlashCommand): Promise { const name = skill.name; const dirPath = `${SKILLS_PATH}/${name}`; const filePath = `${dirPath}/SKILL.md`; await this.adapter.ensureFolder(dirPath); await this.adapter.write(filePath, serializeCommand(skill)); } async delete(skillId: string): Promise { const name = skillId.replace(/^skill-/, ''); const dirPath = `${SKILLS_PATH}/${name}`; const filePath = `${dirPath}/SKILL.md`; await this.adapter.delete(filePath); await this.adapter.deleteFolder(dirPath); } } ================================================ FILE: src/core/storage/SlashCommandStorage.ts ================================================ import { parsedToSlashCommand, parseSlashCommandContent, serializeCommand } from '../../utils/slashCommand'; import type { SlashCommand } from '../types'; import type { VaultFileAdapter } from './VaultFileAdapter'; export const COMMANDS_PATH = '.claude/commands'; export class SlashCommandStorage { constructor(private adapter: VaultFileAdapter) {} async loadAll(): Promise { const commands: SlashCommand[] = []; try { const files = await this.adapter.listFilesRecursive(COMMANDS_PATH); for (const filePath of files) { if (!filePath.endsWith('.md')) continue; try { const command = await this.loadFromFile(filePath); if (command) { commands.push(command); } } catch { // Non-critical: skip malformed command files } } } catch { // Non-critical: directory may not exist yet } return commands; } private async loadFromFile(filePath: string): Promise { const content = await this.adapter.read(filePath); return this.parseFile(content, filePath); } async save(command: SlashCommand): Promise { const filePath = this.getFilePath(command); await this.adapter.write(filePath, serializeCommand(command)); } async delete(commandId: string): Promise { const files = await this.adapter.listFilesRecursive(COMMANDS_PATH); for (const filePath of files) { if (!filePath.endsWith('.md')) continue; const id = this.filePathToId(filePath); if (id === commandId) { await this.adapter.delete(filePath); return; } } } getFilePath(command: SlashCommand): string { const safeName = command.name.replace(/[^a-zA-Z0-9_/-]/g, '-'); return `${COMMANDS_PATH}/${safeName}.md`; } private parseFile(content: string, filePath: string): SlashCommand { const parsed = parseSlashCommandContent(content); return parsedToSlashCommand(parsed, { id: this.filePathToId(filePath), name: this.filePathToName(filePath), }); } private filePathToId(filePath: string): string { // Encoding: escape `-` as `-_`, then replace `/` with `--` // This is unambiguous and reversible: // a/b.md -> cmd-a--b // a-b.md -> cmd-a-_b // a--b.md -> cmd-a-_-_b // a/b-c.md -> cmd-a--b-_c const relativePath = filePath .replace(`${COMMANDS_PATH}/`, '') .replace(/\.md$/, ''); const escaped = relativePath .replace(/-/g, '-_') // Escape dashes first .replace(/\//g, '--'); // Then encode slashes return `cmd-${escaped}`; } private filePathToName(filePath: string): string { return filePath .replace(`${COMMANDS_PATH}/`, '') .replace(/\.md$/, ''); } } ================================================ FILE: src/core/storage/StorageService.ts ================================================ /** * StorageService - Main coordinator for distributed storage system. * * Manages: * - CC settings in .claude/settings.json (CC-compatible, shareable) * - Claudian settings in .claude/claudian-settings.json (Claudian-specific) * - Slash commands in .claude/commands/*.md * - Chat sessions in .claude/sessions/*.jsonl * - MCP configs in .claude/mcp.json * * Handles migration from legacy formats: * - Old settings.json with Claudian fields → split into CC + Claudian files * - Old permissions array → CC permissions object * - data.json state → claudian-settings.json */ import type { App, Plugin } from 'obsidian'; import { Notice } from 'obsidian'; import type { CCPermissions, CCSettings, ClaudeModel, Conversation, LegacyPermission, SlashCommand, } from '../types'; import { createPermissionRule, DEFAULT_CC_PERMISSIONS, DEFAULT_SETTINGS, legacyPermissionsToCCPermissions, } from '../types'; import { AGENTS_PATH, AgentVaultStorage } from './AgentVaultStorage'; import { CC_SETTINGS_PATH, CCSettingsStorage, isLegacyPermissionsFormat } from './CCSettingsStorage'; import { ClaudianSettingsStorage, normalizeBlockedCommands, type StoredClaudianSettings, } from './ClaudianSettingsStorage'; import { McpStorage } from './McpStorage'; import { CLAUDIAN_ONLY_FIELDS, convertEnvObjectToString, mergeEnvironmentVariables, } from './migrationConstants'; import { SESSIONS_PATH, SessionStorage } from './SessionStorage'; import { SKILLS_PATH, SkillStorage } from './SkillStorage'; import { COMMANDS_PATH, SlashCommandStorage } from './SlashCommandStorage'; import { VaultFileAdapter } from './VaultFileAdapter'; /** Base path for all Claudian storage. */ export const CLAUDE_PATH = '.claude'; /** Legacy settings path (now CC settings). */ export const SETTINGS_PATH = CC_SETTINGS_PATH; /** * Combined settings for the application. * Merges CC settings (permissions) with Claudian settings. */ export interface CombinedSettings { /** CC-compatible settings (permissions, etc.) */ cc: CCSettings; /** Claudian-specific settings */ claudian: StoredClaudianSettings; } /** Legacy data format (pre-split migration). */ interface LegacySettingsJson { // Old Claudian fields that were in settings.json userName?: string; enableBlocklist?: boolean; allowExternalAccess?: boolean; blockedCommands?: unknown; model?: string; thinkingBudget?: string; permissionMode?: string; lastNonPlanPermissionMode?: string; permissions?: LegacyPermission[]; excludedTags?: string[]; mediaFolder?: string; environmentVariables?: string; envSnippets?: unknown[]; systemPrompt?: string; allowedExportPaths?: string[]; keyboardNavigation?: unknown; claudeCliPath?: string; claudeCliPaths?: unknown; loadUserClaudeSettings?: boolean; enableAutoTitleGeneration?: boolean; titleGenerationModel?: string; // CC fields $schema?: string; env?: Record; } /** Legacy data.json format. */ interface LegacyDataJson { activeConversationId?: string | null; lastEnvHash?: string; lastClaudeModel?: ClaudeModel; lastCustomModel?: ClaudeModel; conversations?: Conversation[]; slashCommands?: SlashCommand[]; migrationVersion?: number; // May also contain old settings if not yet migrated [key: string]: unknown; } // CLAUDIAN_ONLY_FIELDS is imported from ./migrationConstants export class StorageService { readonly ccSettings: CCSettingsStorage; readonly claudianSettings: ClaudianSettingsStorage; readonly commands: SlashCommandStorage; readonly skills: SkillStorage; readonly sessions: SessionStorage; readonly mcp: McpStorage; readonly agents: AgentVaultStorage; private adapter: VaultFileAdapter; private plugin: Plugin; private app: App; constructor(plugin: Plugin) { this.plugin = plugin; this.app = plugin.app; this.adapter = new VaultFileAdapter(this.app); this.ccSettings = new CCSettingsStorage(this.adapter); this.claudianSettings = new ClaudianSettingsStorage(this.adapter); this.commands = new SlashCommandStorage(this.adapter); this.skills = new SkillStorage(this.adapter); this.sessions = new SessionStorage(this.adapter); this.mcp = new McpStorage(this.adapter); this.agents = new AgentVaultStorage(this.adapter); } async initialize(): Promise { await this.ensureDirectories(); await this.runMigrations(); const cc = await this.ccSettings.load(); const claudian = await this.claudianSettings.load(); return { cc, claudian }; } private async runMigrations(): Promise { const ccExists = await this.ccSettings.exists(); const claudianExists = await this.claudianSettings.exists(); const dataJson = await this.loadDataJson(); // Check if old settings.json has Claudian fields that need migration if (ccExists && !claudianExists) { await this.migrateFromOldSettingsJson(); } if (dataJson) { const hasState = this.hasStateToMigrate(dataJson); const hasLegacyContent = this.hasLegacyContentToMigrate(dataJson); // Migrate data.json state to claudian-settings.json if (hasState) { await this.migrateFromDataJson(dataJson); } // Migrate slash commands and conversations from data.json let legacyContentHadErrors = false; if (hasLegacyContent) { const result = await this.migrateLegacyDataJsonContent(dataJson); legacyContentHadErrors = result.hadErrors; } // Clear legacy data.json only after successful migrations if ((hasState || hasLegacyContent) && !legacyContentHadErrors) { await this.clearLegacyDataJson(); } } } private hasStateToMigrate(data: LegacyDataJson): boolean { return ( data.lastEnvHash !== undefined || data.lastClaudeModel !== undefined || data.lastCustomModel !== undefined ); } private hasLegacyContentToMigrate(data: LegacyDataJson): boolean { return ( (data.slashCommands?.length ?? 0) > 0 || (data.conversations?.length ?? 0) > 0 ); } /** * Migrate from old settings.json (with Claudian fields) to split format. * * Handles: * - Legacy Claudian fields (userName, model, etc.) → claudian-settings.json * - Legacy permissions array → CC permissions object * - CC env object → Claudian environmentVariables string * - Preserves existing CC permissions if already in CC format */ private async migrateFromOldSettingsJson(): Promise { const content = await this.adapter.read(CC_SETTINGS_PATH); const oldSettings = JSON.parse(content) as LegacySettingsJson; const hasClaudianFields = Array.from(CLAUDIAN_ONLY_FIELDS).some( field => (oldSettings as Record)[field] !== undefined ); if (!hasClaudianFields) { return; } // Handle environment variables: merge Claudian string format with CC object format let environmentVariables = oldSettings.environmentVariables ?? ''; if (oldSettings.env && typeof oldSettings.env === 'object') { const envFromCC = convertEnvObjectToString(oldSettings.env); if (envFromCC) { environmentVariables = mergeEnvironmentVariables(environmentVariables, envFromCC); } } const claudianFields: Partial = { userName: oldSettings.userName ?? DEFAULT_SETTINGS.userName, enableBlocklist: oldSettings.enableBlocklist ?? DEFAULT_SETTINGS.enableBlocklist, allowExternalAccess: oldSettings.allowExternalAccess ?? DEFAULT_SETTINGS.allowExternalAccess, blockedCommands: normalizeBlockedCommands(oldSettings.blockedCommands), model: (oldSettings.model as ClaudeModel) ?? DEFAULT_SETTINGS.model, thinkingBudget: (oldSettings.thinkingBudget as StoredClaudianSettings['thinkingBudget']) ?? DEFAULT_SETTINGS.thinkingBudget, permissionMode: (oldSettings.permissionMode as StoredClaudianSettings['permissionMode']) ?? DEFAULT_SETTINGS.permissionMode, excludedTags: oldSettings.excludedTags ?? DEFAULT_SETTINGS.excludedTags, mediaFolder: oldSettings.mediaFolder ?? DEFAULT_SETTINGS.mediaFolder, environmentVariables, // Merged from both sources envSnippets: oldSettings.envSnippets as StoredClaudianSettings['envSnippets'] ?? DEFAULT_SETTINGS.envSnippets, systemPrompt: oldSettings.systemPrompt ?? DEFAULT_SETTINGS.systemPrompt, allowedExportPaths: oldSettings.allowedExportPaths ?? DEFAULT_SETTINGS.allowedExportPaths, persistentExternalContextPaths: DEFAULT_SETTINGS.persistentExternalContextPaths, keyboardNavigation: oldSettings.keyboardNavigation as StoredClaudianSettings['keyboardNavigation'] ?? DEFAULT_SETTINGS.keyboardNavigation, claudeCliPath: oldSettings.claudeCliPath ?? DEFAULT_SETTINGS.claudeCliPath, claudeCliPathsByHost: DEFAULT_SETTINGS.claudeCliPathsByHost, // Migration to hostname-based handled in main.ts loadUserClaudeSettings: oldSettings.loadUserClaudeSettings ?? DEFAULT_SETTINGS.loadUserClaudeSettings, enableAutoTitleGeneration: oldSettings.enableAutoTitleGeneration ?? DEFAULT_SETTINGS.enableAutoTitleGeneration, titleGenerationModel: oldSettings.titleGenerationModel ?? DEFAULT_SETTINGS.titleGenerationModel, lastClaudeModel: DEFAULT_SETTINGS.lastClaudeModel, lastCustomModel: DEFAULT_SETTINGS.lastCustomModel, lastEnvHash: DEFAULT_SETTINGS.lastEnvHash, }; // Save Claudian settings FIRST (before stripping from settings.json) await this.claudianSettings.save(claudianFields as StoredClaudianSettings); // Verify Claudian settings were saved const savedClaudian = await this.claudianSettings.load(); if (!savedClaudian || savedClaudian.userName === undefined) { throw new Error('Failed to verify claudian-settings.json was saved correctly'); } // Handle permissions: convert legacy format OR preserve existing CC format let ccPermissions: CCPermissions; if (isLegacyPermissionsFormat(oldSettings)) { ccPermissions = legacyPermissionsToCCPermissions(oldSettings.permissions); } else if (oldSettings.permissions && typeof oldSettings.permissions === 'object' && !Array.isArray(oldSettings.permissions)) { // Already in CC format - preserve it including defaultMode and additionalDirectories const existingPerms = oldSettings.permissions as unknown as CCPermissions; ccPermissions = { allow: existingPerms.allow ?? [], deny: existingPerms.deny ?? [], ask: existingPerms.ask ?? [], defaultMode: existingPerms.defaultMode, additionalDirectories: existingPerms.additionalDirectories, }; } else { ccPermissions = { ...DEFAULT_CC_PERMISSIONS }; } // Rewrite settings.json with only CC fields const ccSettings: CCSettings = { $schema: 'https://json.schemastore.org/claude-code-settings.json', permissions: ccPermissions, }; // Pass true to strip Claudian-only fields during migration await this.ccSettings.save(ccSettings, true); } private async migrateFromDataJson(dataJson: LegacyDataJson): Promise { const claudian = await this.claudianSettings.load(); // Only migrate if not already set (claudian-settings.json takes precedence) if (dataJson.lastEnvHash !== undefined && !claudian.lastEnvHash) { claudian.lastEnvHash = dataJson.lastEnvHash; } if (dataJson.lastClaudeModel !== undefined && !claudian.lastClaudeModel) { claudian.lastClaudeModel = dataJson.lastClaudeModel; } if (dataJson.lastCustomModel !== undefined && !claudian.lastCustomModel) { claudian.lastCustomModel = dataJson.lastCustomModel; } await this.claudianSettings.save(claudian); } private async migrateLegacyDataJsonContent(dataJson: LegacyDataJson): Promise<{ hadErrors: boolean }> { let hadErrors = false; if (dataJson.slashCommands && dataJson.slashCommands.length > 0) { for (const command of dataJson.slashCommands) { try { const filePath = this.commands.getFilePath(command); if (await this.adapter.exists(filePath)) { continue; } await this.commands.save(command); } catch { hadErrors = true; } } } if (dataJson.conversations && dataJson.conversations.length > 0) { for (const conversation of dataJson.conversations) { try { const filePath = this.sessions.getFilePath(conversation.id); if (await this.adapter.exists(filePath)) { continue; } await this.sessions.saveConversation(conversation); } catch { hadErrors = true; } } } return { hadErrors }; } private async clearLegacyDataJson(): Promise { const dataJson = await this.loadDataJson(); if (!dataJson) { return; } const cleaned: Record = { ...dataJson }; delete cleaned.lastEnvHash; delete cleaned.lastClaudeModel; delete cleaned.lastCustomModel; delete cleaned.conversations; delete cleaned.slashCommands; delete cleaned.migrationVersion; if (Object.keys(cleaned).length === 0) { await this.plugin.saveData({}); return; } await this.plugin.saveData(cleaned); } private async loadDataJson(): Promise { try { const data = await this.plugin.loadData(); return data || null; } catch { // data.json may not exist on fresh installs return null; } } async ensureDirectories(): Promise { await this.adapter.ensureFolder(CLAUDE_PATH); await this.adapter.ensureFolder(COMMANDS_PATH); await this.adapter.ensureFolder(SKILLS_PATH); await this.adapter.ensureFolder(SESSIONS_PATH); await this.adapter.ensureFolder(AGENTS_PATH); } async loadAllSlashCommands(): Promise { const commands = await this.commands.loadAll(); const skills = await this.skills.loadAll(); return [...commands, ...skills]; } getAdapter(): VaultFileAdapter { return this.adapter; } async getPermissions(): Promise { return this.ccSettings.getPermissions(); } async updatePermissions(permissions: CCPermissions): Promise { return this.ccSettings.updatePermissions(permissions); } async addAllowRule(rule: string): Promise { return this.ccSettings.addAllowRule(createPermissionRule(rule)); } async addDenyRule(rule: string): Promise { return this.ccSettings.addDenyRule(createPermissionRule(rule)); } /** * Remove a permission rule from all lists. */ async removePermissionRule(rule: string): Promise { return this.ccSettings.removeRule(createPermissionRule(rule)); } async updateClaudianSettings(updates: Partial): Promise { return this.claudianSettings.update(updates); } async saveClaudianSettings(settings: StoredClaudianSettings): Promise { return this.claudianSettings.save(settings); } async loadClaudianSettings(): Promise { return this.claudianSettings.load(); } /** * Get legacy activeConversationId from storage (claudian-settings.json or data.json). */ async getLegacyActiveConversationId(): Promise { const fromSettings = await this.claudianSettings.getLegacyActiveConversationId(); if (fromSettings) { return fromSettings; } const dataJson = await this.loadDataJson(); if (dataJson && typeof dataJson.activeConversationId === 'string') { return dataJson.activeConversationId; } return null; } /** * Remove legacy activeConversationId from storage after migration. */ async clearLegacyActiveConversationId(): Promise { await this.claudianSettings.clearLegacyActiveConversationId(); const dataJson = await this.loadDataJson(); if (!dataJson || !('activeConversationId' in dataJson)) { return; } const cleaned: Record = { ...dataJson }; delete cleaned.activeConversationId; await this.plugin.saveData(cleaned); } /** * Get tab manager state from data.json with runtime validation. */ async getTabManagerState(): Promise { try { const data = await this.plugin.loadData(); if (data?.tabManagerState) { return this.validateTabManagerState(data.tabManagerState); } return null; } catch { return null; } } /** * Validates and sanitizes tab manager state from storage. * Returns null if the data is invalid or corrupted. */ private validateTabManagerState(data: unknown): TabManagerPersistedState | null { if (!data || typeof data !== 'object') { return null; } const state = data as Record; if (!Array.isArray(state.openTabs)) { return null; } const validatedTabs: Array<{ tabId: string; conversationId: string | null }> = []; for (const tab of state.openTabs) { if (!tab || typeof tab !== 'object') { continue; // Skip invalid entries } const tabObj = tab as Record; if (typeof tabObj.tabId !== 'string') { continue; // Skip entries without valid tabId } validatedTabs.push({ tabId: tabObj.tabId, conversationId: typeof tabObj.conversationId === 'string' ? tabObj.conversationId : null, }); } const activeTabId = typeof state.activeTabId === 'string' ? state.activeTabId : null; return { openTabs: validatedTabs, activeTabId, }; } async setTabManagerState(state: TabManagerPersistedState): Promise { try { const data = (await this.plugin.loadData()) || {}; data.tabManagerState = state; await this.plugin.saveData(data); } catch { new Notice('Failed to save tab layout'); } } } /** * Persisted state for the tab manager. * Stored in data.json (machine-specific, not shared). */ export interface TabManagerPersistedState { openTabs: Array<{ tabId: string; conversationId: string | null }>; activeTabId: string | null; } ================================================ FILE: src/core/storage/VaultFileAdapter.ts ================================================ /** * VaultFileAdapter - Wrapper around Obsidian Vault API for file operations. * * Provides a consistent interface for file operations using Obsidian's * vault adapter instead of Node's fs module. */ import type { App } from 'obsidian'; export class VaultFileAdapter { private writeQueue: Promise = Promise.resolve(); constructor(private app: App) {} async exists(path: string): Promise { return this.app.vault.adapter.exists(path); } async read(path: string): Promise { return this.app.vault.adapter.read(path); } async write(path: string, content: string): Promise { await this.ensureParentFolder(path); await this.app.vault.adapter.write(path, content); } async append(path: string, content: string): Promise { await this.ensureParentFolder(path); this.writeQueue = this.writeQueue.then(async () => { if (await this.exists(path)) { const existing = await this.read(path); await this.app.vault.adapter.write(path, existing + content); } else { await this.app.vault.adapter.write(path, content); } }).catch(() => { // prevent queue from getting stuck }); await this.writeQueue; } async delete(path: string): Promise { if (await this.exists(path)) { await this.app.vault.adapter.remove(path); } } /** Fails silently if non-empty or missing. */ async deleteFolder(path: string): Promise { try { if (await this.exists(path)) { await this.app.vault.adapter.rmdir(path, false); } } catch { // Non-critical: directory may not be empty } } async listFiles(folder: string): Promise { if (!(await this.exists(folder))) { return []; } const listing = await this.app.vault.adapter.list(folder); return listing.files; } /** List subfolders in a folder. Returns relative paths from the folder. */ async listFolders(folder: string): Promise { if (!(await this.exists(folder))) { return []; } const listing = await this.app.vault.adapter.list(folder); return listing.folders; } /** Recursively list all files in a folder and subfolders. */ async listFilesRecursive(folder: string): Promise { const allFiles: string[] = []; const processFolder = async (currentFolder: string) => { if (!(await this.exists(currentFolder))) return; const listing = await this.app.vault.adapter.list(currentFolder); allFiles.push(...listing.files); for (const subfolder of listing.folders) { await processFolder(subfolder); } }; await processFolder(folder); return allFiles; } private async ensureParentFolder(filePath: string): Promise { const folder = filePath.substring(0, filePath.lastIndexOf('/')); if (folder && !(await this.exists(folder))) { await this.ensureFolder(folder); } } /** Ensure a folder exists, creating it and parent folders if needed. */ async ensureFolder(path: string): Promise { if (await this.exists(path)) return; // Create parent folders recursively const parts = path.split('/').filter(Boolean); let current = ''; for (const part of parts) { current = current ? `${current}/${part}` : part; if (!(await this.exists(current))) { await this.app.vault.adapter.mkdir(current); } } } /** Rename/move a file. */ async rename(oldPath: string, newPath: string): Promise { await this.app.vault.adapter.rename(oldPath, newPath); } async stat(path: string): Promise<{ mtime: number; size: number } | null> { try { const stat = await this.app.vault.adapter.stat(path); if (!stat) return null; return { mtime: stat.mtime, size: stat.size }; } catch { return null; } } } ================================================ FILE: src/core/storage/index.ts ================================================ export { AGENTS_PATH, AgentVaultStorage } from './AgentVaultStorage'; export { CC_SETTINGS_PATH, CCSettingsStorage, isLegacyPermissionsFormat } from './CCSettingsStorage'; export { CLAUDIAN_SETTINGS_PATH, ClaudianSettingsStorage, type StoredClaudianSettings, } from './ClaudianSettingsStorage'; export { MCP_CONFIG_PATH, McpStorage } from './McpStorage'; export { SESSIONS_PATH, SessionStorage } from './SessionStorage'; export { SKILLS_PATH, SkillStorage } from './SkillStorage'; export { COMMANDS_PATH, SlashCommandStorage } from './SlashCommandStorage'; export { CLAUDE_PATH, type CombinedSettings, SETTINGS_PATH, StorageService, } from './StorageService'; export { VaultFileAdapter } from './VaultFileAdapter'; ================================================ FILE: src/core/storage/migrationConstants.ts ================================================ /** * Migration Constants - Shared constants for storage migration. * * Single source of truth for fields that need to be migrated * from settings.json to claudian-settings.json. */ /** * Fields that are Claudian-specific and should NOT be in CC settings.json. * These are migrated to claudian-settings.json and stripped from settings.json. * * IMPORTANT: Keep this list updated when adding new Claudian settings! */ export const CLAUDIAN_ONLY_FIELDS = new Set([ // User preferences 'userName', // Security settings 'enableBlocklist', 'allowExternalAccess', 'blockedCommands', 'permissionMode', 'lastNonPlanPermissionMode', // Model & thinking 'model', 'thinkingBudget', 'effortLevel', 'enableAutoTitleGeneration', 'titleGenerationModel', // Content settings 'excludedTags', 'mediaFolder', 'systemPrompt', 'allowedExportPaths', 'persistentExternalContextPaths', // Environment (Claudian uses string format + snippets) 'environmentVariables', 'envSnippets', // UI settings 'keyboardNavigation', // CLI paths 'claudeCliPath', 'claudeCliPaths', 'loadUserClaudeSettings', // Deprecated fields (removed completely, not migrated) 'allowedContextPaths', 'showToolUse', 'toolCallExpandedByDefault', ]); /** * Fields that are Claudian-specific and should be migrated. * Excludes deprecated fields which are just removed. */ export const MIGRATABLE_CLAUDIAN_FIELDS = new Set([ 'userName', 'enableBlocklist', 'allowExternalAccess', 'blockedCommands', 'permissionMode', 'lastNonPlanPermissionMode', 'model', 'thinkingBudget', 'effortLevel', 'enableAutoTitleGeneration', 'titleGenerationModel', 'excludedTags', 'mediaFolder', 'systemPrompt', 'allowedExportPaths', 'persistentExternalContextPaths', 'environmentVariables', 'envSnippets', 'env', // Converted to environmentVariables 'keyboardNavigation', 'claudeCliPath', 'claudeCliPaths', 'loadUserClaudeSettings', ]); /** * Deprecated fields that are removed completely (not migrated). */ export const DEPRECATED_FIELDS = new Set([ 'allowedContextPaths', 'showToolUse', 'toolCallExpandedByDefault', ]); /** * Convert CC env object format to Claudian environmentVariables string format. * * @example * { ANTHROPIC_API_KEY: "xxx", MY_VAR: "value" } * → "ANTHROPIC_API_KEY=xxx\nMY_VAR=value" */ export function convertEnvObjectToString(env: Record | undefined): string { if (!env || typeof env !== 'object') { return ''; } return Object.entries(env) .filter(([key, value]) => typeof key === 'string' && typeof value === 'string') .map(([key, value]) => `${key}=${value}`) .join('\n'); } /** * Merge two environmentVariables strings, removing duplicates. * Later values override earlier ones for the same key. */ export function mergeEnvironmentVariables(existing: string, additional: string): string { const envMap = new Map(); for (const line of existing.split('\n')) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith('#')) continue; const eqIndex = trimmed.indexOf('='); if (eqIndex > 0) { const key = trimmed.slice(0, eqIndex); const value = trimmed.slice(eqIndex + 1); envMap.set(key, value); } } // Parse additional (overrides existing) for (const line of additional.split('\n')) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith('#')) continue; const eqIndex = trimmed.indexOf('='); if (eqIndex > 0) { const key = trimmed.slice(0, eqIndex); const value = trimmed.slice(eqIndex + 1); envMap.set(key, value); } } return Array.from(envMap.entries()) .map(([key, value]) => `${key}=${value}`) .join('\n'); } ================================================ FILE: src/core/tools/index.ts ================================================ export { extractLastTodosFromMessages, parseTodoInput, type TodoItem, } from './todo'; export { getToolIcon, MCP_ICON_MARKER } from './toolIcons'; export { extractResolvedAnswers, extractResolvedAnswersFromResultText, getPathFromToolInput, } from './toolInput'; export { BASH_TOOLS, type BashToolName, EDIT_TOOLS, type EditToolName, FILE_TOOLS, type FileToolName, isBashTool, isEditTool, isFileTool, isMcpTool, isReadOnlyTool, isSubagentToolName, isWriteEditTool, MCP_TOOLS, type McpToolName, READ_ONLY_TOOLS, type ReadOnlyToolName, skipsBlockedDetection, SUBAGENT_TOOL_NAMES, type SubagentToolName, TOOL_AGENT_OUTPUT, TOOL_ASK_USER_QUESTION, TOOL_BASH, TOOL_BASH_OUTPUT, TOOL_EDIT, TOOL_ENTER_PLAN_MODE, TOOL_EXIT_PLAN_MODE, TOOL_GLOB, TOOL_GREP, TOOL_KILL_SHELL, TOOL_LIST_MCP_RESOURCES, TOOL_LS, TOOL_MCP, TOOL_NOTEBOOK_EDIT, TOOL_READ, TOOL_READ_MCP_RESOURCE, TOOL_SKILL, TOOL_SUBAGENT, TOOL_SUBAGENT_LEGACY, TOOL_TASK, TOOL_TODO_WRITE, TOOL_WEB_FETCH, TOOL_WEB_SEARCH, TOOL_WRITE, TOOLS_SKIP_BLOCKED_DETECTION, WRITE_EDIT_TOOLS, type WriteEditToolName, } from './toolNames'; ================================================ FILE: src/core/tools/todo.ts ================================================ /** * Todo tool helpers. * * Parses TodoWrite tool input into typed todo items. */ import { TOOL_TODO_WRITE } from './toolNames'; export interface TodoItem { /** Imperative description (e.g., "Run tests") */ content: string; status: 'pending' | 'in_progress' | 'completed'; /** Present continuous form (e.g., "Running tests") */ activeForm: string; } function isValidTodoItem(item: unknown): item is TodoItem { if (typeof item !== 'object' || item === null) return false; const record = item as Record; return ( typeof record.content === 'string' && record.content.length > 0 && typeof record.activeForm === 'string' && record.activeForm.length > 0 && typeof record.status === 'string' && ['pending', 'in_progress', 'completed'].includes(record.status) ); } export function parseTodoInput(input: Record): TodoItem[] | null { if (!input.todos || !Array.isArray(input.todos)) { return null; } const validTodos: TodoItem[] = []; for (const item of input.todos) { if (isValidTodoItem(item)) { validTodos.push(item); } } return validTodos.length > 0 ? validTodos : null; } /** * Extract the last TodoWrite todos from a list of messages. * Used to restore the todo panel when loading a saved conversation. */ export function extractLastTodosFromMessages( messages: Array<{ role: string; toolCalls?: Array<{ name: string; input: Record }> }> ): TodoItem[] | null { for (let i = messages.length - 1; i >= 0; i--) { const msg = messages[i]; if (msg.role === 'assistant' && msg.toolCalls) { for (let j = msg.toolCalls.length - 1; j >= 0; j--) { const toolCall = msg.toolCalls[j]; if (toolCall.name === TOOL_TODO_WRITE) { const todos = parseTodoInput(toolCall.input); return todos; } } } } return null; } ================================================ FILE: src/core/tools/toolIcons.ts ================================================ import { TOOL_AGENT_OUTPUT, TOOL_ASK_USER_QUESTION, TOOL_BASH, TOOL_BASH_OUTPUT, TOOL_EDIT, TOOL_ENTER_PLAN_MODE, TOOL_EXIT_PLAN_MODE, TOOL_GLOB, TOOL_GREP, TOOL_KILL_SHELL, TOOL_LIST_MCP_RESOURCES, TOOL_LS, TOOL_MCP, TOOL_NOTEBOOK_EDIT, TOOL_READ, TOOL_READ_MCP_RESOURCE, TOOL_SKILL, TOOL_SUBAGENT_LEGACY, TOOL_TASK, TOOL_TODO_WRITE, TOOL_TOOL_SEARCH, TOOL_WEB_FETCH, TOOL_WEB_SEARCH, TOOL_WRITE, } from './toolNames'; const TOOL_ICONS: Record = { [TOOL_READ]: 'file-text', [TOOL_WRITE]: 'file-plus', [TOOL_EDIT]: 'file-pen', [TOOL_NOTEBOOK_EDIT]: 'file-pen', [TOOL_BASH]: 'terminal', [TOOL_BASH_OUTPUT]: 'terminal', [TOOL_KILL_SHELL]: 'terminal', [TOOL_GLOB]: 'folder-search', [TOOL_GREP]: 'search', [TOOL_LS]: 'list', [TOOL_TODO_WRITE]: 'list-checks', [TOOL_TASK]: 'bot', [TOOL_SUBAGENT_LEGACY]: 'bot', [TOOL_LIST_MCP_RESOURCES]: 'list', [TOOL_READ_MCP_RESOURCE]: 'file-text', [TOOL_MCP]: 'wrench', [TOOL_WEB_SEARCH]: 'globe', [TOOL_WEB_FETCH]: 'download', [TOOL_AGENT_OUTPUT]: 'bot', [TOOL_ASK_USER_QUESTION]: 'help-circle', [TOOL_SKILL]: 'zap', [TOOL_TOOL_SEARCH]: 'search-check', [TOOL_ENTER_PLAN_MODE]: 'map', [TOOL_EXIT_PLAN_MODE]: 'check-circle', }; /** Special marker for MCP tools - signals to use custom SVG. */ export const MCP_ICON_MARKER = '__mcp_icon__'; export function getToolIcon(toolName: string): string { if (toolName.startsWith('mcp__')) { return MCP_ICON_MARKER; } return TOOL_ICONS[toolName] || 'wrench'; } ================================================ FILE: src/core/tools/toolInput.ts ================================================ /** * Tool input helpers. * * Keeps parsing of common tool inputs consistent across services. */ import type { AskUserAnswers } from '../types/tools'; import { TOOL_EDIT, TOOL_GLOB, TOOL_GREP, TOOL_LS, TOOL_NOTEBOOK_EDIT, TOOL_READ, TOOL_WRITE, } from './toolNames'; export function extractResolvedAnswers(toolUseResult: unknown): AskUserAnswers | undefined { if (typeof toolUseResult !== 'object' || toolUseResult === null) return undefined; const r = toolUseResult as Record; return normalizeAnswersObject(r.answers); } function normalizeAnswerValue(value: unknown): string | undefined { if (typeof value === 'string') return value; if (Array.isArray(value)) { const normalized = value .map((item) => (typeof item === 'string' ? item : String(item))) .filter(Boolean) .join(', '); return normalized || undefined; } if (typeof value === 'number' || typeof value === 'boolean') return String(value); return undefined; } function normalizeAnswersObject(value: unknown): AskUserAnswers | undefined { if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined; const answers: AskUserAnswers = {}; for (const [question, rawValue] of Object.entries(value as Record)) { const normalized = normalizeAnswerValue(rawValue); if (normalized) { answers[question] = normalized; } } return Object.keys(answers).length > 0 ? answers : undefined; } function parseAnswersFromJsonObject(resultText: string): AskUserAnswers | undefined { const start = resultText.indexOf('{'); const end = resultText.lastIndexOf('}'); if (start < 0 || end <= start) return undefined; try { const parsed = JSON.parse(resultText.slice(start, end + 1)) as unknown; return normalizeAnswersObject(parsed); } catch { return undefined; } } function parseAnswersFromQuotedPairs(resultText: string): AskUserAnswers | undefined { const answers: AskUserAnswers = {}; const pattern = /"([^"]+)"="([^"]*)"/g; for (const match of resultText.matchAll(pattern)) { const question = match[1]?.trim(); if (!question) continue; answers[question] = match[2] ?? ''; } return Object.keys(answers).length > 0 ? answers : undefined; } /** * Fallback extractor for AskUserQuestion results when structured `toolUseResult.answers` * is unavailable (for example after reload from JSONL history). */ export function extractResolvedAnswersFromResultText(result: unknown): AskUserAnswers | undefined { if (typeof result !== 'string') return undefined; const trimmed = result.trim(); if (!trimmed) return undefined; return parseAnswersFromJsonObject(trimmed) ?? parseAnswersFromQuotedPairs(trimmed); } export function getPathFromToolInput( toolName: string, toolInput: Record ): string | null { switch (toolName) { case TOOL_READ: case TOOL_WRITE: case TOOL_EDIT: case TOOL_NOTEBOOK_EDIT: return (toolInput.file_path as string) || (toolInput.notebook_path as string) || null; case TOOL_GLOB: return (toolInput.path as string) || (toolInput.pattern as string) || null; case TOOL_GREP: return (toolInput.path as string) || null; case TOOL_LS: return (toolInput.path as string) || null; default: return null; } } ================================================ FILE: src/core/tools/toolNames.ts ================================================ export const TOOL_AGENT_OUTPUT = 'TaskOutput' as const; export const TOOL_ASK_USER_QUESTION = 'AskUserQuestion' as const; export const TOOL_BASH = 'Bash' as const; export const TOOL_BASH_OUTPUT = 'BashOutput' as const; export const TOOL_EDIT = 'Edit' as const; export const TOOL_GLOB = 'Glob' as const; export const TOOL_GREP = 'Grep' as const; export const TOOL_KILL_SHELL = 'KillShell' as const; export const TOOL_LS = 'LS' as const; export const TOOL_LIST_MCP_RESOURCES = 'ListMcpResources' as const; export const TOOL_MCP = 'Mcp' as const; export const TOOL_NOTEBOOK_EDIT = 'NotebookEdit' as const; export const TOOL_READ = 'Read' as const; export const TOOL_READ_MCP_RESOURCE = 'ReadMcpResource' as const; export const TOOL_SKILL = 'Skill' as const; export const TOOL_SUBAGENT = 'Agent' as const; export const TOOL_SUBAGENT_LEGACY = 'Task' as const; // Kept as an alias while the internal codebase is still named around "Task". export const TOOL_TASK = TOOL_SUBAGENT; export const TOOL_TODO_WRITE = 'TodoWrite' as const; export const TOOL_TOOL_SEARCH = 'ToolSearch' as const; export const TOOL_WEB_FETCH = 'WebFetch' as const; export const TOOL_WEB_SEARCH = 'WebSearch' as const; export const TOOL_WRITE = 'Write' as const; export const TOOL_ENTER_PLAN_MODE = 'EnterPlanMode' as const; export const TOOL_EXIT_PLAN_MODE = 'ExitPlanMode' as const; // These tools resolve via dedicated callbacks (not content-based), so their // tool_result should never be marked "blocked" based on result text. export const TOOLS_SKIP_BLOCKED_DETECTION = [ TOOL_ENTER_PLAN_MODE, TOOL_EXIT_PLAN_MODE, TOOL_ASK_USER_QUESTION, ] as const; export const SUBAGENT_TOOL_NAMES = [ TOOL_SUBAGENT, TOOL_SUBAGENT_LEGACY, ] as const; export type SubagentToolName = (typeof SUBAGENT_TOOL_NAMES)[number]; export function skipsBlockedDetection(name: string): boolean { return (TOOLS_SKIP_BLOCKED_DETECTION as readonly string[]).includes(name); } export function isSubagentToolName(name: string): name is SubagentToolName { return (SUBAGENT_TOOL_NAMES as readonly string[]).includes(name); } export const EDIT_TOOLS = [TOOL_WRITE, TOOL_EDIT, TOOL_NOTEBOOK_EDIT] as const; export type EditToolName = (typeof EDIT_TOOLS)[number]; export const WRITE_EDIT_TOOLS = [TOOL_WRITE, TOOL_EDIT] as const; export type WriteEditToolName = (typeof WRITE_EDIT_TOOLS)[number]; export const BASH_TOOLS = [TOOL_BASH, TOOL_BASH_OUTPUT, TOOL_KILL_SHELL] as const; export type BashToolName = (typeof BASH_TOOLS)[number]; export const FILE_TOOLS = [ TOOL_READ, TOOL_WRITE, TOOL_EDIT, TOOL_GLOB, TOOL_GREP, TOOL_LS, TOOL_NOTEBOOK_EDIT, TOOL_BASH, ] as const; export type FileToolName = (typeof FILE_TOOLS)[number]; export const MCP_TOOLS = [ TOOL_LIST_MCP_RESOURCES, TOOL_READ_MCP_RESOURCE, TOOL_MCP, ] as const; export type McpToolName = (typeof MCP_TOOLS)[number]; export const READ_ONLY_TOOLS = [ TOOL_READ, TOOL_GREP, TOOL_GLOB, TOOL_LS, TOOL_WEB_SEARCH, TOOL_WEB_FETCH, ] as const; export type ReadOnlyToolName = (typeof READ_ONLY_TOOLS)[number]; export function isEditTool(toolName: string): toolName is EditToolName { return (EDIT_TOOLS as readonly string[]).includes(toolName); } export function isWriteEditTool(toolName: string): toolName is WriteEditToolName { return (WRITE_EDIT_TOOLS as readonly string[]).includes(toolName); } export function isFileTool(toolName: string): toolName is FileToolName { return (FILE_TOOLS as readonly string[]).includes(toolName); } export function isBashTool(toolName: string): toolName is BashToolName { return (BASH_TOOLS as readonly string[]).includes(toolName); } export function isMcpTool(toolName: string): toolName is McpToolName { return (MCP_TOOLS as readonly string[]).includes(toolName); } export function isReadOnlyTool(toolName: string): toolName is ReadOnlyToolName { return (READ_ONLY_TOOLS as readonly string[]).includes(toolName); } ================================================ FILE: src/core/types/agent.ts ================================================ export const AGENT_PERMISSION_MODES = ['default', 'acceptEdits', 'dontAsk', 'bypassPermissions', 'plan', 'delegate'] as const; export type AgentPermissionMode = typeof AGENT_PERMISSION_MODES[number]; /** * Agent definition loaded from markdown files with YAML frontmatter. * Matches Claude Code's agent format for compatibility. */ export interface AgentDefinition { /** Unique identifier. Namespaced for plugins: "plugin-name:agent-name" */ id: string; /** Display name (from YAML `name` field) */ name: string; description: string; /** System prompt for the agent (markdown body after frontmatter) */ prompt: string; /** Allowed tools. If undefined, inherits all tools from parent */ tools?: string[]; /** Disallowed tools. Removed from inherited or specified tools list */ disallowedTools?: string[]; /** Model override. 'inherit' (default) uses parent's model */ model?: 'sonnet' | 'opus' | 'haiku' | 'inherit'; source: 'plugin' | 'vault' | 'global' | 'builtin'; /** Plugin name (only for plugin-sourced agents) */ pluginName?: string; /** Absolute path to the source .md file (undefined for built-in agents) */ filePath?: string; /** Skills available to this agent (pass-through to SDK) */ skills?: string[]; permissionMode?: AgentPermissionMode; /** Parsed from frontmatter; round-tripped on save so the SDK reads hooks from the agent file */ hooks?: Record; /** Frontmatter keys not recognized by Claudian, preserved on round-trip */ extraFrontmatter?: Record; } export interface AgentFrontmatter { name: string; description: string; /** Tools list: comma-separated string or array from YAML */ tools?: string | string[]; /** Disallowed tools: comma-separated string or array from YAML */ disallowedTools?: string | string[]; /** Model: validated at parse time, invalid values fall back to 'inherit' */ model?: string; skills?: string[]; permissionMode?: string; hooks?: Record; extraFrontmatter?: Record; } ================================================ FILE: src/core/types/chat.ts ================================================ /** * Chat and conversation type definitions. */ import type { SDKToolUseResult } from './diff'; import type { SubagentInfo, SubagentMode, ToolCallInfo } from './tools'; /** Fork origin reference: identifies the source session and resume point. */ export interface ForkSource { sessionId: string; resumeAt: string; } /** View type identifier for Obsidian. */ export const VIEW_TYPE_CLAUDIAN = 'claudian-view'; /** Supported image media types for attachments. */ export type ImageMediaType = 'image/jpeg' | 'image/png' | 'image/gif' | 'image/webp'; /** Image attachment metadata. */ export interface ImageAttachment { id: string; name: string; mediaType: ImageMediaType; /** Base64 encoded image data - single source of truth. */ data: string; width?: number; height?: number; size: number; source: 'file' | 'paste' | 'drop'; } /** Content block for preserving streaming order in messages. */ export type ContentBlock = | { type: 'text'; content: string } | { type: 'tool_use'; toolId: string } | { type: 'thinking'; content: string; durationSeconds?: number } | { type: 'subagent'; subagentId: string; mode?: SubagentMode } | { type: 'compact_boundary' }; /** Chat message with content, tool calls, and attachments. */ export interface ChatMessage { id: string; role: 'user' | 'assistant'; content: string; /** Display-only content (e.g., "/tests" when content is the expanded prompt). */ displayContent?: string; timestamp: number; toolCalls?: ToolCallInfo[]; contentBlocks?: ContentBlock[]; currentNote?: string; images?: ImageAttachment[]; /** True if this message represents a user interrupt (from SDK storage). */ isInterrupt?: boolean; /** True if this message is rebuilt context sent to SDK on session reset (should be hidden). */ isRebuiltContext?: boolean; /** Duration in seconds from user send to response completion. */ durationSeconds?: number; /** Flavor word used for duration display (e.g., "Baked", "Cooked"). */ durationFlavorWord?: string; /** SDK user message UUID for rewind. */ sdkUserUuid?: string; /** SDK assistant message UUID for resumeSessionAt. */ sdkAssistantUuid?: string; } /** Persisted conversation with messages and session state. */ export interface Conversation { id: string; title: string; createdAt: number; updatedAt: number; /** Timestamp when the last agent response completed. */ lastResponseAt?: number; sessionId: string | null; /** * Current SDK session ID for native sessions. * May differ from sessionId when SDK creates a new session (session expired, API key changed). * Used for loading messages from SDK storage. Falls back to sessionId if not set. */ sdkSessionId?: string; /** * Previous SDK session IDs from session rebuilds. * When resume fails and SDK creates a new session, the old sdkSessionId is moved here. * Used to load and merge messages from all session files for display. */ previousSdkSessionIds?: string[]; messages: ChatMessage[]; currentNote?: string; /** Session-specific external context paths (directories with full access). Resets on new session. */ externalContextPaths?: string[]; /** Context window usage information. */ usage?: UsageInfo; /** Status of AI title generation. */ titleGenerationStatus?: 'pending' | 'success' | 'failed'; /** UI-enabled MCP servers for this session (context-saving servers activated via selector). */ enabledMcpServers?: string[]; /** True if this conversation uses SDK-native storage (messages in ~/.claude/projects/). */ isNative?: boolean; /** Timestamp of the last legacy JSONL message (used to merge SDK history). */ legacyCutoffAt?: number; /** Internal flag to avoid reloading SDK history repeatedly. */ sdkMessagesLoaded?: boolean; /** * Cached subagent data for Task tool operations. * Loaded from metadata for native sessions to restore tool count and status on reload. */ subagentData?: Record; /** Assistant UUID for resumeSessionAt after rewind. */ resumeSessionAt?: string; /** Fork origin: source session to resume + fork from. Cleared after first SDK session init. */ forkSource?: ForkSource; } /** Lightweight conversation metadata for the history dropdown. */ export interface ConversationMeta { id: string; title: string; createdAt: number; updatedAt: number; /** Timestamp when the last agent response completed. */ lastResponseAt?: number; messageCount: number; preview: string; /** Status of AI title generation. */ titleGenerationStatus?: 'pending' | 'success' | 'failed'; /** True if this conversation uses SDK-native storage. */ isNative?: boolean; } /** * Session metadata overlay for SDK-native storage. * Stored in vault/.claude/sessions/{id}.meta.json * SDK handles message storage; this stores UI-only state. */ export interface SessionMetadata { id: string; title: string; titleGenerationStatus?: 'pending' | 'success' | 'failed'; createdAt: number; updatedAt: number; lastResponseAt?: number; /** Session ID used for SDK resume (may be cleared when invalidated). */ sessionId?: string | null; /** * Current SDK session ID. May differ from id when SDK creates a new session. * Used to locate the correct SDK session file for message loading. */ sdkSessionId?: string; /** * Previous SDK session IDs from session rebuilds. * When resume fails and SDK creates a new session, the old sdkSessionId is moved here. * Used to load and merge messages from all session files for display. */ previousSdkSessionIds?: string[]; currentNote?: string; externalContextPaths?: string[]; enabledMcpServers?: string[]; usage?: UsageInfo; /** Timestamp of the last legacy JSONL message (used to merge SDK history). */ legacyCutoffAt?: number; /** * Subagent data for Task tool operations. * Maps toolUseId to subagent info (tool count, status, result). * Stored here because SDK session files don't preserve this Claudian-specific data. */ subagentData?: Record; /** Assistant UUID for resumeSessionAt after rewind. */ resumeSessionAt?: string; /** Fork origin: source session to resume + fork from. Cleared after first SDK session init. */ forkSource?: ForkSource; } /** Normalized stream chunk from the Claude Agent SDK. */ export type StreamChunk = | { type: 'text'; content: string; parentToolUseId?: string | null } | { type: 'thinking'; content: string; parentToolUseId?: string | null } | { type: 'tool_use'; id: string; name: string; input: Record; parentToolUseId?: string | null } | { type: 'tool_result'; id: string; content: string; isError?: boolean; parentToolUseId?: string | null; toolUseResult?: SDKToolUseResult } | { type: 'error'; content: string } | { type: 'blocked'; content: string } | { type: 'done' } | { type: 'usage'; usage: UsageInfo; sessionId?: string | null } | { type: 'compact_boundary' } | { type: 'sdk_user_uuid'; uuid: string } | { type: 'sdk_user_sent'; uuid: string } | { type: 'sdk_assistant_uuid'; uuid: string } | { type: 'context_window_update'; contextWindow: number }; /** Context window usage information. */ export interface UsageInfo { model?: string; inputTokens: number; cacheCreationInputTokens: number; cacheReadInputTokens: number; contextWindow: number; contextTokens: number; percentage: number; } ================================================ FILE: src/core/types/diff.ts ================================================ /** * Diff-related type definitions. */ export interface DiffLine { type: 'equal' | 'insert' | 'delete'; text: string; oldLineNum?: number; newLineNum?: number; } export interface DiffStats { added: number; removed: number; } /** A single hunk from the SDK's structuredPatch format. */ export interface StructuredPatchHunk { oldStart: number; oldLines: number; newStart: number; newLines: number; lines: string[]; } /** Shape of the SDK's toolUseResult object for Write/Edit tools. */ export interface SDKToolUseResult { structuredPatch?: StructuredPatchHunk[]; filePath?: string; [key: string]: unknown; } ================================================ FILE: src/core/types/index.ts ================================================ // Chat types export { type ChatMessage, type ContentBlock, type Conversation, type ConversationMeta, type ForkSource, type ImageAttachment, type ImageMediaType, type SessionMetadata, type StreamChunk, type UsageInfo, VIEW_TYPE_CLAUDIAN, } from './chat'; // Model types export { type ClaudeModel, CONTEXT_WINDOW_1M, CONTEXT_WINDOW_STANDARD, DEFAULT_CLAUDE_MODELS, DEFAULT_EFFORT_LEVEL, DEFAULT_THINKING_BUDGET, EFFORT_LEVELS, type EffortLevel, filterVisibleModelOptions, getContextWindowSize, isAdaptiveThinkingModel, normalizeVisibleModelVariant, THINKING_BUDGETS, type ThinkingBudget, } from './models'; // SDK types export { type SDKMessage } from './sdk'; // Settings types export { type ApprovalDecision, type CCPermissions, type CCSettings, type ClaudianSettings, type CliPlatformKey, createPermissionRule, DEFAULT_CC_PERMISSIONS, DEFAULT_CC_SETTINGS, DEFAULT_SETTINGS, type EnvSnippet, getBashToolBlockedCommands, getCliPlatformKey, // Kept for migration getCurrentPlatformBlockedCommands, getCurrentPlatformKey, getDefaultBlockedCommands, type HostnameCliPaths, type InstructionRefineResult, type KeyboardNavigationSettings, type LegacyPermission, legacyPermissionsToCCPermissions, legacyPermissionToCCRule, parseCCPermissionRule, type PermissionMode, type PermissionRule, type PlatformBlockedCommands, type PlatformCliPaths, // Kept for migration type SlashCommand, type TabBarPosition, } from './settings'; // Re-export getHostnameKey from utils (moved from settings for architecture compliance) export { getHostnameKey } from '../../utils/env'; // Diff types export { type DiffLine, type DiffStats, type SDKToolUseResult, type StructuredPatchHunk, } from './diff'; // Tool types export { type AskUserAnswers, type AskUserQuestionItem, type AskUserQuestionOption, type AsyncSubagentStatus, type ExitPlanModeCallback, type ExitPlanModeDecision, type SubagentInfo, type SubagentMode, type ToolCallInfo, type ToolDiffData, } from './tools'; // MCP types export { type ClaudianMcpConfigFile, type ClaudianMcpServer, DEFAULT_MCP_SERVER, getMcpServerType, isValidMcpServerConfig, type McpConfigFile, type McpHttpServerConfig, type McpServerConfig, type McpServerType, type McpSSEServerConfig, type McpStdioServerConfig, type ParsedMcpConfig, } from './mcp'; // Plugin types export { type ClaudianPlugin, type InstalledPluginEntry, type InstalledPluginsFile, type PluginScope, } from './plugins'; // Agent types export { AGENT_PERMISSION_MODES, type AgentDefinition, type AgentFrontmatter, type AgentPermissionMode, } from './agent'; ================================================ FILE: src/core/types/mcp.ts ================================================ /** * Claudian - MCP (Model Context Protocol) type definitions * * Types for configuring and managing MCP servers that extend Claude's capabilities. */ /** Stdio server configuration (local command-line programs). */ export interface McpStdioServerConfig { type?: 'stdio'; command: string; args?: string[]; env?: Record; } /** Server-Sent Events remote server configuration. */ export interface McpSSEServerConfig { type: 'sse'; url: string; headers?: Record; } /** HTTP remote server configuration. */ export interface McpHttpServerConfig { type: 'http'; url: string; headers?: Record; } /** Union type for all MCP server configurations. */ export type McpServerConfig = | McpStdioServerConfig | McpSSEServerConfig | McpHttpServerConfig; /** Server type identifier. */ export type McpServerType = 'stdio' | 'sse' | 'http'; /** Extended server configuration with Claudian-specific options. */ export interface ClaudianMcpServer { /** Unique server name (key in mcpServers record). */ name: string; config: McpServerConfig; enabled: boolean; /** Context-saving mode: hide tools unless @-mentioned. */ contextSaving: boolean; /** Tool names disabled for this server. */ disabledTools?: string[]; description?: string; } /** MCP configuration file format (Claude Code compatible). */ export interface McpConfigFile { mcpServers: Record; } /** Extended config file with Claudian metadata. */ export interface ClaudianMcpConfigFile extends McpConfigFile { _claudian?: { /** Per-server Claudian-specific settings. */ servers: Record< string, { enabled?: boolean; contextSaving?: boolean; disabledTools?: string[]; description?: string; } >; }; } /** Result of parsing clipboard config. */ export interface ParsedMcpConfig { servers: Array<{ name: string; config: McpServerConfig }>; needsName: boolean; } export function getMcpServerType(config: McpServerConfig): McpServerType { if (config.type === 'sse') return 'sse'; if (config.type === 'http') return 'http'; if ('url' in config) return 'http'; // URL without explicit type defaults to http return 'stdio'; } export function isValidMcpServerConfig(obj: unknown): obj is McpServerConfig { if (!obj || typeof obj !== 'object') return false; const config = obj as Record; // Check for stdio (command required) if (config.command && typeof config.command === 'string') return true; // Check for sse/http (url required, type is optional - defaults to http) if (config.url && typeof config.url === 'string') return true; return false; } export const DEFAULT_MCP_SERVER: Omit = { enabled: true, contextSaving: true, }; ================================================ FILE: src/core/types/models.ts ================================================ /** * Model type definitions and constants. */ /** Model identifier (string to support custom models via environment variables). */ export type ClaudeModel = string; export const DEFAULT_CLAUDE_MODELS: { value: ClaudeModel; label: string; description: string }[] = [ { value: 'haiku', label: 'Haiku', description: 'Fast and efficient' }, { value: 'sonnet', label: 'Sonnet', description: 'Balanced performance' }, { value: 'sonnet[1m]', label: 'Sonnet 1M', description: 'Balanced performance (1M context window)' }, { value: 'opus', label: 'Opus', description: 'Most capable' }, { value: 'opus[1m]', label: 'Opus 1M', description: 'Most capable (1M context window)' }, ]; export type ThinkingBudget = 'off' | 'low' | 'medium' | 'high' | 'xhigh'; export const THINKING_BUDGETS: { value: ThinkingBudget; label: string; tokens: number }[] = [ { value: 'off', label: 'Off', tokens: 0 }, { value: 'low', label: 'Low', tokens: 4000 }, { value: 'medium', label: 'Med', tokens: 8000 }, { value: 'high', label: 'High', tokens: 16000 }, { value: 'xhigh', label: 'Ultra', tokens: 32000 }, ]; /** Effort levels for adaptive thinking models. */ export type EffortLevel = 'low' | 'medium' | 'high' | 'max'; export const EFFORT_LEVELS: { value: EffortLevel; label: string }[] = [ { value: 'low', label: 'Low' }, { value: 'medium', label: 'Med' }, { value: 'high', label: 'High' }, { value: 'max', label: 'Max' }, ]; /** Default effort level per model tier. */ export const DEFAULT_EFFORT_LEVEL: Record = { 'haiku': 'high', 'sonnet': 'high', 'sonnet[1m]': 'high', 'opus': 'high', 'opus[1m]': 'high', }; /** Default thinking budget per model tier. */ export const DEFAULT_THINKING_BUDGET: Record = { 'haiku': 'off', 'sonnet': 'low', 'sonnet[1m]': 'low', 'opus': 'medium', 'opus[1m]': 'medium', }; const DEFAULT_MODEL_VALUES = new Set(DEFAULT_CLAUDE_MODELS.map(m => m.value)); /** Whether the model is a known Claude model that supports adaptive thinking. */ export function isAdaptiveThinkingModel(model: string): boolean { if (DEFAULT_MODEL_VALUES.has(model)) return true; return /claude-(haiku|sonnet|opus)-/.test(model); } export const CONTEXT_WINDOW_STANDARD = 200_000; export const CONTEXT_WINDOW_1M = 1_000_000; export function filterVisibleModelOptions( models: T[], enableOpus1M: boolean, enableSonnet1M: boolean ): T[] { return models.filter((model) => { if (model.value === 'opus' || model.value === 'opus[1m]') { return enableOpus1M ? model.value === 'opus[1m]' : model.value === 'opus'; } if (model.value === 'sonnet' || model.value === 'sonnet[1m]') { return enableSonnet1M ? model.value === 'sonnet[1m]' : model.value === 'sonnet'; } return true; }); } export function normalizeVisibleModelVariant( model: string, enableOpus1M: boolean, enableSonnet1M: boolean ): string { if (model === 'opus' || model === 'opus[1m]') { return enableOpus1M ? 'opus[1m]' : 'opus'; } if (model === 'sonnet' || model === 'sonnet[1m]') { return enableSonnet1M ? 'sonnet[1m]' : 'sonnet'; } return model; } export function getContextWindowSize( model: string, customLimits?: Record ): number { if (customLimits && model in customLimits) { const limit = customLimits[model]; if (typeof limit === 'number' && limit > 0 && !isNaN(limit) && isFinite(limit)) { return limit; } } if (model.endsWith('[1m]')) { return CONTEXT_WINDOW_1M; } return CONTEXT_WINDOW_STANDARD; } ================================================ FILE: src/core/types/plugins.ts ================================================ export type PluginScope = 'user' | 'project'; export interface ClaudianPlugin { /** e.g., "plugin-name@source" */ id: string; name: string; enabled: boolean; scope: PluginScope; installPath: string; } export interface InstalledPluginEntry { scope: 'user' | 'project'; installPath: string; version: string; installedAt: string; lastUpdated: string; gitCommitSha?: string; projectPath?: string; } export interface InstalledPluginsFile { version: number; plugins: Record; } ================================================ FILE: src/core/types/sdk.ts ================================================ import type { SDKUserMessage } from '@anthropic-ai/claude-agent-sdk'; export type { SDKMessage } from '@anthropic-ai/claude-agent-sdk'; /** Runtime-only extension for blocked user messages (hook denials). */ export type BlockedUserMessage = SDKUserMessage & { _blocked: true; _blockReason: string; }; export function isBlockedMessage(message: { type: string }): message is BlockedUserMessage { return ( message.type === 'user' && '_blocked' in message && (message as Record)._blocked === true && '_blockReason' in message ); } ================================================ FILE: src/core/types/settings.ts ================================================ /** * Settings type definitions. */ import type { Locale } from '../../i18n/types'; import type { ClaudeModel, EffortLevel, ThinkingBudget } from './models'; const UNIX_BLOCKED_COMMANDS = [ 'rm -rf', 'chmod 777', 'chmod -R 777', ]; /** Platform-specific blocked commands (Windows - both CMD and PowerShell). */ const WINDOWS_BLOCKED_COMMANDS = [ // CMD commands 'del /s /q', 'rd /s /q', 'rmdir /s /q', 'format', 'diskpart', // PowerShell Remove-Item variants (full and abbreviated flags) 'Remove-Item -Recurse -Force', 'Remove-Item -Force -Recurse', 'Remove-Item -r -fo', 'Remove-Item -fo -r', 'Remove-Item -Recurse', 'Remove-Item -r', // PowerShell aliases for Remove-Item 'ri -Recurse', 'ri -r', 'ri -Force', 'ri -fo', 'rm -r -fo', 'rm -Recurse', 'rm -Force', 'del -Recurse', 'del -Force', 'erase -Recurse', 'erase -Force', // PowerShell directory removal aliases 'rd -Recurse', 'rmdir -Recurse', // Dangerous disk/volume commands 'Format-Volume', 'Clear-Disk', 'Initialize-Disk', 'Remove-Partition', ]; export interface PlatformBlockedCommands { unix: string[]; windows: string[]; } export function getDefaultBlockedCommands(): PlatformBlockedCommands { return { unix: [...UNIX_BLOCKED_COMMANDS], windows: [...WINDOWS_BLOCKED_COMMANDS], }; } export function getCurrentPlatformKey(): keyof PlatformBlockedCommands { return process.platform === 'win32' ? 'windows' : 'unix'; } export function getCurrentPlatformBlockedCommands(commands: PlatformBlockedCommands): string[] { return commands[getCurrentPlatformKey()]; } /** * Get blocked commands for the Bash tool. * * On Windows, the Bash tool runs in a Git Bash/MSYS2 environment but can still * invoke Windows commands (e.g., via `cmd /c` or `powershell`), so both Unix * and Windows blocklist patterns are merged. */ export function getBashToolBlockedCommands(commands: PlatformBlockedCommands): string[] { if (process.platform === 'win32') { return Array.from(new Set([...commands.unix, ...commands.windows])); } return getCurrentPlatformBlockedCommands(commands); } /** * Platform-specific Claude CLI paths. * @deprecated Use HostnameCliPaths instead. Kept for migration from older versions. */ export interface PlatformCliPaths { macos: string; linux: string; windows: string; } /** Platform key for CLI paths. Used for migration only. */ export type CliPlatformKey = keyof PlatformCliPaths; /** * Map process.platform to CLI platform key. * @deprecated Used for migration only. */ export function getCliPlatformKey(): CliPlatformKey { switch (process.platform) { case 'darwin': return 'macos'; case 'win32': return 'windows'; default: return 'linux'; } } /** * Hostname-keyed CLI paths for per-device configuration. * Each device stores its path using its hostname as key. * This allows settings to sync across devices without conflicts. */ export type HostnameCliPaths = Record; /** Permission mode for tool execution. */ export type PermissionMode = 'yolo' | 'plan' | 'normal'; /** User decision from the approval modal. */ export type ApprovalDecision = 'allow' | 'allow-always' | 'deny' | 'cancel'; /** * Legacy permission format (pre-CC compatibility). * @deprecated Use CCPermissions instead */ export interface LegacyPermission { toolName: string; pattern: string; approvedAt: number; scope: 'session' | 'always'; } /** * CC-compatible permission rule string. * Format: "Tool(pattern)" or "Tool" for all * Examples: "Bash(git *)", "Read(*.md)", "WebFetch(domain:github.com)" */ export type PermissionRule = string & { readonly __brand: 'PermissionRule' }; /** * Create a PermissionRule from a string. * @internal Use legacyPermissionToCCRule instead. */ export function createPermissionRule(rule: string): PermissionRule { return rule as PermissionRule; } /** * CC-compatible permissions object. * Stored in .claude/settings.json for interoperability with Claude Code CLI. */ export interface CCPermissions { /** Rules that auto-approve tool actions */ allow?: PermissionRule[]; /** Rules that auto-deny tool actions (highest persistent priority) */ deny?: PermissionRule[]; /** Rules that always prompt for confirmation */ ask?: PermissionRule[]; /** Default permission mode */ defaultMode?: 'acceptEdits' | 'bypassPermissions' | 'default' | 'plan'; /** Additional directories to include in permission scope */ additionalDirectories?: string[]; } /** * CC-compatible settings stored in .claude/settings.json. * These settings are shared with Claude Code CLI. */ export interface CCSettings { /** JSON Schema reference */ $schema?: string; /** Tool permissions (CC format) */ permissions?: CCPermissions; /** Model override */ model?: string; /** Environment variables (object format) */ env?: Record; /** MCP server settings */ enableAllProjectMcpServers?: boolean; enabledMcpjsonServers?: string[]; disabledMcpjsonServers?: string[]; /** Plugin enabled state (CC format: { "plugin-id": true/false }) */ enabledPlugins?: Record; /** Allow additional properties for CC compatibility */ [key: string]: unknown; } /** Saved environment variable configuration. */ export interface EnvSnippet { id: string; name: string; description: string; envVars: string; contextLimits?: Record; // Optional: context limits for custom models } /** Source of a slash command. */ export type SlashCommandSource = 'builtin' | 'user' | 'plugin' | 'sdk'; /** Slash command configuration with Claude Code compatibility. */ export interface SlashCommand { id: string; name: string; // Command name used after / (e.g., "review-code") description?: string; // Optional description shown in dropdown argumentHint?: string; // Placeholder text for arguments (e.g., "[file] [focus]") allowedTools?: string[]; // Restrict tools when command is used model?: ClaudeModel; // Override model for this command content: string; // Prompt template with placeholders source?: SlashCommandSource; // Origin of the command (builtin, user, plugin, sdk) // Skill fields (from .claude/skills/ definitions) disableModelInvocation?: boolean; // Disable model invocation for this skill userInvocable?: boolean; // Whether user can invoke this skill directly context?: 'fork'; // Subagent execution mode agent?: string; // Subagent type when context='fork' hooks?: Record; // Pass-through to SDK } /** Keyboard navigation settings for vim-style scrolling. */ export interface KeyboardNavigationSettings { scrollUpKey: string; // Key to scroll up when focused on messages (default: 'w') scrollDownKey: string; // Key to scroll down when focused on messages (default: 's') focusInputKey: string; // Key to focus input (default: 'i', like vim insert mode) } /** Tab bar position setting. */ export type TabBarPosition = 'input' | 'header'; /** * Claudian-specific settings stored in .claude/claudian-settings.json. * These settings are NOT shared with Claude Code CLI. */ export interface ClaudianSettings { // User preferences userName: string; // Security (Claudian-specific, CC uses permissions.deny instead) enableBlocklist: boolean; allowExternalAccess: boolean; blockedCommands: PlatformBlockedCommands; permissionMode: PermissionMode; // Model & thinking (Claudian uses enum, CC uses full model ID string) model: ClaudeModel; thinkingBudget: ThinkingBudget; // Legacy token budget for custom models effortLevel: EffortLevel; // Effort level for adaptive thinking models enableAutoTitleGeneration: boolean; titleGenerationModel: string; // Model for auto title generation (empty = auto) enableChrome: boolean; // Enable Chrome extension support (passes --chrome flag) enableBangBash: boolean; // Enable ! bash mode for direct command execution enableOpus1M: boolean; // Show Opus 1M model variant (opus[1m]) enableSonnet1M: boolean; // Show Sonnet 1M model variant (sonnet[1m]) // Content settings excludedTags: string[]; mediaFolder: string; systemPrompt: string; allowedExportPaths: string[]; persistentExternalContextPaths: string[]; // Paths that persist across all sessions // Environment (string format, CC uses object format in settings.json) environmentVariables: string; envSnippets: EnvSnippet[]; /** * Custom context window limits for models configured via environment variables. * Keys are model IDs (from ANTHROPIC_MODEL, ANTHROPIC_DEFAULT_*_MODEL env vars). * Values are token counts in range [1000, 10000000]. * Empty object means all models use default context limits (200k). */ customContextLimits: Record; // UI settings keyboardNavigation: KeyboardNavigationSettings; // Internationalization locale: Locale; // UI language setting // CLI paths claudeCliPath: string; // Legacy: single CLI path (for backwards compatibility) claudeCliPathsByHost: HostnameCliPaths; // Per-device paths keyed by hostname (preferred) loadUserClaudeSettings: boolean; // Load ~/.claude/settings.json (may override permissions) // State (merged from data.json) lastClaudeModel?: ClaudeModel; lastCustomModel?: ClaudeModel; lastEnvHash?: string; // Slash commands (loaded separately from .claude/commands/) slashCommands: SlashCommand[]; // UI preferences maxTabs: number; // Maximum number of chat tabs (3-10, default 3) tabBarPosition: TabBarPosition; // Where to show tab bar ('input' or 'header') enableAutoScroll: boolean; // Enable auto-scroll during streaming (default: true) openInMainTab: boolean; // Open chat panel in main editor area instead of sidebar // Slash commands hiddenSlashCommands: string[]; // Command names to hide from dropdown (user preference) } /** Default Claudian-specific settings. */ export const DEFAULT_SETTINGS: ClaudianSettings = { // User preferences userName: '', // Security enableBlocklist: true, allowExternalAccess: false, blockedCommands: getDefaultBlockedCommands(), permissionMode: 'yolo', // Model & thinking model: 'haiku', thinkingBudget: 'off', effortLevel: 'high', enableAutoTitleGeneration: true, titleGenerationModel: '', // Empty = auto (ANTHROPIC_DEFAULT_HAIKU_MODEL or claude-haiku-4-5) enableChrome: false, // Disabled by default enableBangBash: false, // Disabled by default enableOpus1M: false, // Disabled by default enableSonnet1M: false, // Disabled by default // Content settings excludedTags: [], mediaFolder: '', systemPrompt: '', allowedExportPaths: ['~/Desktop', '~/Downloads'], persistentExternalContextPaths: [], // Environment environmentVariables: '', envSnippets: [], customContextLimits: {}, // UI settings keyboardNavigation: { scrollUpKey: 'w', scrollDownKey: 's', focusInputKey: 'i', }, // Internationalization locale: 'en', // Default to English // CLI paths claudeCliPath: '', // Legacy field (empty = not migrated) claudeCliPathsByHost: {}, // Per-device paths keyed by hostname loadUserClaudeSettings: true, // Default on for compatibility lastClaudeModel: 'haiku', lastCustomModel: '', lastEnvHash: '', // Slash commands (loaded separately) slashCommands: [], // UI preferences maxTabs: 3, // Default to 3 tabs (safe resource usage) tabBarPosition: 'input', // Default to input mode (current behavior) enableAutoScroll: true, // Default to auto-scroll enabled openInMainTab: false, // Default to sidebar (current behavior) // Slash commands hiddenSlashCommands: [], // No commands hidden by default }; /** Default CC-compatible settings. */ export const DEFAULT_CC_SETTINGS: CCSettings = { $schema: 'https://json.schemastore.org/claude-code-settings.json', permissions: { allow: [], deny: [], ask: [], }, }; /** Default CC permissions. */ export const DEFAULT_CC_PERMISSIONS: CCPermissions = { allow: [], deny: [], ask: [], }; /** Result from instruction refinement agent query. */ export interface InstructionRefineResult { success: boolean; refinedInstruction?: string; // The refined instruction text clarification?: string; // Agent's clarifying question (if any) error?: string; // Error message (if failed) } /** * Convert a legacy permission to CC permission rule format. * Examples: * { toolName: "Bash", pattern: "git *" } → "Bash(git *)" * { toolName: "Read", pattern: "/path/to/file" } → "Read(/path/to/file)" * { toolName: "WebSearch", pattern: "*" } → "WebSearch" */ export function legacyPermissionToCCRule(legacy: LegacyPermission): PermissionRule { const pattern = legacy.pattern.trim(); // If pattern is empty, wildcard, or JSON object (old format), just use tool name if (!pattern || pattern === '*' || pattern.startsWith('{')) { return createPermissionRule(legacy.toolName); } return createPermissionRule(`${legacy.toolName}(${pattern})`); } /** * Convert legacy permissions array to CC permissions object. * Only 'always' scope permissions are converted (session = ephemeral). */ export function legacyPermissionsToCCPermissions( legacyPermissions: LegacyPermission[] ): CCPermissions { const allow: PermissionRule[] = []; for (const perm of legacyPermissions) { if (perm.scope === 'always') { allow.push(legacyPermissionToCCRule(perm)); } } return { allow: [...new Set(allow)], // Deduplicate deny: [], ask: [], }; } /** * Parse a CC permission rule into tool name and pattern. * Examples: * "Bash(git *)" → { tool: "Bash", pattern: "git *" } * "Read" → { tool: "Read", pattern: undefined } * "WebFetch(domain:github.com)" → { tool: "WebFetch", pattern: "domain:github.com" } */ export function parseCCPermissionRule(rule: PermissionRule): { tool: string; pattern?: string; } { const match = rule.match(/^(\w+)(?:\((.+)\))?$/); if (!match) { return { tool: rule }; } const [, tool, pattern] = match; return { tool, pattern }; } ================================================ FILE: src/core/types/tools.ts ================================================ /** * Tool-related type definitions. */ import type { DiffLine, DiffStats } from './diff'; /** Diff data for Write/Edit tool operations (pre-computed from SDK structuredPatch). */ export interface ToolDiffData { filePath: string; diffLines: DiffLine[]; stats: DiffStats; } /** Parsed option for AskUserQuestion tool. */ export interface AskUserQuestionOption { label: string; description: string; } /** Parsed question for AskUserQuestion tool. */ export interface AskUserQuestionItem { question: string; header: string; options: AskUserQuestionOption[]; multiSelect: boolean; } /** User-provided answers keyed by question text. */ export type AskUserAnswers = Record; /** Tool call tracking with status and result. */ export interface ToolCallInfo { id: string; name: string; input: Record; status: 'running' | 'completed' | 'error' | 'blocked'; result?: string; isExpanded?: boolean; diffData?: ToolDiffData; resolvedAnswers?: AskUserAnswers; subagent?: SubagentInfo; } export type ExitPlanModeDecision = | { type: 'approve' } | { type: 'approve-new-session'; planContent: string } | { type: 'feedback'; text: string }; export type ExitPlanModeCallback = ( input: Record, signal?: AbortSignal, ) => Promise; /** Subagent execution mode: sync (nested tools) or async (background). */ export type SubagentMode = 'sync' | 'async'; /** Async subagent lifecycle states. */ export type AsyncSubagentStatus = | 'pending' | 'running' | 'completed' | 'error' | 'orphaned'; /** Subagent (Agent tool, legacy Task) tracking for sync and async modes. */ export interface SubagentInfo { id: string; description: string; prompt?: string; mode?: SubagentMode; isExpanded: boolean; result?: string; status: 'running' | 'completed' | 'error'; toolCalls: ToolCallInfo[]; asyncStatus?: AsyncSubagentStatus; agentId?: string; outputToolId?: string; startedAt?: number; completedAt?: number; } ================================================ FILE: src/features/chat/CLAUDE.md ================================================ # Chat Feature Main sidebar chat interface. `ClaudianView` is a thin shell; logic lives in controllers and services. ## Architecture ``` ClaudianView (lifecycle + assembly) ├── ChatState (centralized state) ├── Controllers │ ├── ConversationController # History, session switching │ ├── StreamController # Streaming, auto-scroll, abort │ ├── InputController # Text input, file context, images │ ├── SelectionController # Editor selection awareness │ └── NavigationController # Keyboard navigation (vim-style) ├── Services │ ├── TitleGenerationService # Auto-generate conversation titles │ ├── SubagentManager # Unified sync/async subagent lifecycle │ ├── InstructionRefineService # "#" instruction mode │ └── BangBashService # Direct bash execution ("!" mode) ├── Rendering │ ├── MessageRenderer # Main rendering orchestrator │ ├── ToolCallRenderer # Tool use blocks │ ├── ThinkingBlockRenderer # Extended thinking │ ├── WriteEditRenderer # File write/edit with diff │ ├── DiffRenderer # Inline diff display │ ├── TodoListRenderer # Todo panel │ ├── SubagentRenderer # Subagent status panel │ ├── InlineExitPlanMode # Plan mode approval card │ ├── InlineAskUserQuestion # AskUserQuestion inline card │ └── collapsible # Collapsible block utility ├── Tabs │ ├── TabManager # Multi-tab orchestration │ ├── TabBar # Tab UI component │ └── Tab # Individual tab state + fork request handling └── UI Components ├── InputToolbar # Model selector, thinking, permissions, context meter ├── FileContext # @-mention chips and dropdown ├── ImageContext # Image attachments ├── StatusPanel # Todo/command output panels container ├── InstructionModeManager # "#" mode UI └── BangBashModeManager # "!" bash mode UI ``` ## State Flow ``` User Input → InputController → ClaudianService.query() ↓ StreamController (handle messages) ↓ MessageRenderer (update DOM) ↓ ChatState (persist) ``` ## Controllers | Controller | Responsibility | |------------|----------------| | `ConversationController` | Load/save sessions, history panel, session switching, fork session setup | | `StreamController` | Process SDK messages, auto-scroll, streaming UI state | | `InputController` | Input textarea, file/image attachments, slash commands | | `SelectionController` | Poll editor selection (250ms), CM6 decoration | | `NavigationController` | Vim-style keyboard navigation (j/k scroll, i focus) | ## Rendering Pipeline | Renderer | Handles | |----------|---------| | `MessageRenderer` | Orchestrates all rendering, manages message containers, fork button on user messages | | `ToolCallRenderer` | Tool use blocks with status, input display | | `ThinkingBlockRenderer` | Extended thinking with collapse/expand | | `WriteEditRenderer` | File operations with before/after diff | | `DiffRenderer` | Hunked inline diffs (del/ins highlighting) | | `InlineExitPlanMode` | Plan mode approval card (approve/feedback/new session) | | `InlineAskUserQuestion` | AskUserQuestion inline card | | `TodoListRenderer` | Todo items with status icons | | `SubagentRenderer` | Background agent progress | ## Key Patterns ### Lazy Tab Initialization ```typescript // ClaudianService created on first query, not on tab create tab.ensureService(); // Creates service if needed ``` ### Message Rendering ```typescript // StreamController receives SDK messages for await (const message of response) { this.messageRenderer.render(message); // Updates DOM this.chatState.appendMessage(message); // Persists } ``` ### Auto-Scroll - Enabled by default during streaming - User scroll-up disables; scroll-to-bottom re-enables - Resets to setting value on new query ## Gotchas - `ClaudianView.onClose()` must abort all tabs and dispose services - Tab switching preserves scroll position per-tab - `ChatState` is per-tab; `TabManager` coordinates across tabs (including fork orchestration) - Title generation runs concurrently per-conversation (separate AbortControllers) - `FileContext` has nested state in `ui/file-context/state/` - `/compact` has a special code path: `InputController` skips context XML appending so the SDK recognizes the built-in command; `StreamController` handles the `compact_boundary` chunk as a standalone separator; `sdkSession.ts` prevents merge with adjacent assistant messages; ESC during compact produces an SDK stderr (`Compaction canceled`) that `sdkSession.ts` maps to `isInterrupt` for persistent rendering - Plan mode: `EnterPlanMode` is auto-approved by the SDK (detected in stream to sync UI); `ExitPlanMode` uses a dedicated callback in `canUseTool` that bypasses normal approval flow. Shift+Tab toggles plan mode and saves/restores the previous permission mode. "Approve (new session)" stops the current session and auto-sends plan content as the first message in a fresh session. - Bang-bash mode: `!` in empty input triggers direct bash execution (bypasses Claude). `BangBashModeManager` manages input mode; `BangBashService` runs commands via `child_process.exec` (30s timeout, 1MB buffer). Output displays in `StatusPanel` command panel. ESC exits mode; Enter submits. - Fork conversation: `Tab.handleForkRequest()` validates eligibility (not streaming, both user and preceding assistant messages have SDK UUIDs), deep clones messages up to the fork point, then delegates to `TabManager`. `/fork` command triggers `Tab.handleForkAll()`, which forks the entire conversation (all messages, resuming at the last assistant UUID). Both handlers share `resolveForkSource()` for session ID resolution and conversation metadata lookup. `TabManager` shows `ForkTargetModal` (new tab vs current tab), creates the fork conversation with `forkSource: { sessionId, resumeAt }` metadata, sets `sdkMessagesLoaded` to prevent duplicate message loading, and propagates title/currentNote. `ConversationController.switchTo()` detects fork metadata and sets `pendingForkSession`/`pendingResumeAt` on `ClaudianService` so the SDK resumes at the correct point. Fork titles are deduplicated across existing tabs. ================================================ FILE: src/features/chat/ClaudianView.ts ================================================ import type { EventRef, WorkspaceLeaf } from 'obsidian'; import { ItemView, Notice, Scope, setIcon } from 'obsidian'; import { getContextWindowSize, VIEW_TYPE_CLAUDIAN } from '../../core/types'; import type ClaudianPlugin from '../../main'; import { LOGO_SVG } from './constants'; import { TabBar, TabManager, updatePlanModeUI } from './tabs'; import type { TabData, TabId } from './tabs/types'; export class ClaudianView extends ItemView { private plugin: ClaudianPlugin; // Tab management private tabManager: TabManager | null = null; private tabBar: TabBar | null = null; private tabBarContainerEl: HTMLElement | null = null; private tabContentEl: HTMLElement | null = null; private navRowContent: HTMLElement | null = null; // DOM Elements private viewContainerEl: HTMLElement | null = null; private headerEl: HTMLElement | null = null; private titleSlotEl: HTMLElement | null = null; private logoEl: HTMLElement | null = null; private titleTextEl: HTMLElement | null = null; private headerActionsEl: HTMLElement | null = null; private headerActionsContent: HTMLElement | null = null; // Header elements private historyDropdown: HTMLElement | null = null; // Event refs for cleanup private eventRefs: EventRef[] = []; // Debouncing for tab bar updates private pendingTabBarUpdate: number | null = null; // Debouncing for tab state persistence private pendingPersist: ReturnType | null = null; constructor(leaf: WorkspaceLeaf, plugin: ClaudianPlugin) { super(leaf); this.plugin = plugin; // Hover Editor compatibility: Define load as an instance method that can't be // overwritten by prototype patching. Hover Editor patches ClaudianView.prototype.load // after our class is defined, but instance methods take precedence over prototype methods. const originalLoad = Object.getPrototypeOf(this).load.bind(this); Object.defineProperty(this, 'load', { value: async () => { // Ensure containerEl exists before any patched load code tries to use it if (!this.containerEl) { // eslint-disable-next-line @typescript-eslint/no-explicit-any (this as any).containerEl = createDiv({ cls: 'view-content' }); } // Wrap in try-catch to prevent Hover Editor errors from breaking our view try { return await originalLoad(); } catch { // Hover Editor may throw if its DOM setup fails - continue anyway } }, writable: false, configurable: false, }); } getViewType(): string { return VIEW_TYPE_CLAUDIAN; } getDisplayText(): string { return 'Claudian'; } getIcon(): string { return 'bot'; } /** Refreshes model-dependent UI across all tabs (used after settings/env changes). */ refreshModelSelector(): void { const model = this.plugin.settings.model; const contextWindow = getContextWindowSize(model, this.plugin.settings.customContextLimits); for (const tab of this.tabManager?.getAllTabs() ?? []) { if (tab.state.usage) { const percentage = Math.min(100, Math.max(0, Math.round((tab.state.usage.contextTokens / contextWindow) * 100))); tab.state.usage = { ...tab.state.usage, model, contextWindow, percentage }; } tab.ui.modelSelector?.updateDisplay(); tab.ui.modelSelector?.renderOptions(); } } /** Updates hidden slash commands on all tabs (used after settings change). */ updateHiddenSlashCommands(): void { const hiddenCommands = new Set( (this.plugin.settings.hiddenSlashCommands || []).map(c => c.toLowerCase()) ); for (const tab of this.tabManager?.getAllTabs() ?? []) { tab.ui.slashCommandDropdown?.setHiddenCommands(hiddenCommands); } } async onOpen() { // Guard: Hover Editor and similar plugins may call onOpen before DOM is ready. // containerEl must exist before we can access contentEl or create elements. if (!this.containerEl) { return; } // Use contentEl (standard Obsidian API) as primary target. // Hover Editor and other plugins may modify the DOM structure, // so we need fallbacks to handle non-standard scenarios. let container: HTMLElement | null = this.contentEl ?? (this.containerEl.children[1] as HTMLElement | null); if (!container) { // Last resort: create our own container inside containerEl container = this.containerEl.createDiv(); } this.viewContainerEl = container; this.viewContainerEl.empty(); this.viewContainerEl.addClass('claudian-container'); // Build header (logo only, tab bar and actions moved to nav row) const header = this.viewContainerEl.createDiv({ cls: 'claudian-header' }); this.buildHeader(header); // Build nav row content (tab badges + header actions) this.navRowContent = this.buildNavRowContent(); // Tab content container (TabManager will populate this) this.tabContentEl = this.viewContainerEl.createDiv({ cls: 'claudian-tab-content-container' }); // Initialize TabManager this.tabManager = new TabManager( this.plugin, this.plugin.mcpManager, this.tabContentEl, this, { onTabCreated: () => { this.updateTabBar(); this.updateNavRowLocation(); this.persistTabState(); }, onTabSwitched: () => { this.updateTabBar(); this.updateHistoryDropdown(); this.updateNavRowLocation(); this.persistTabState(); }, onTabClosed: () => { this.updateTabBar(); this.persistTabState(); }, onTabStreamingChanged: () => this.updateTabBar(), onTabTitleChanged: () => this.updateTabBar(), onTabAttentionChanged: () => this.updateTabBar(), onTabConversationChanged: () => { this.persistTabState(); }, } ); // Wire up view-level event handlers this.wireEventHandlers(); // Restore tabs from persisted state or create default tab await this.restoreOrCreateTabs(); // Apply initial layout based on tabBarPosition setting this.updateLayoutForPosition(); } async onClose() { // Cancel any pending tab bar update if (this.pendingTabBarUpdate !== null) { cancelAnimationFrame(this.pendingTabBarUpdate); this.pendingTabBarUpdate = null; } // Cleanup event refs for (const ref of this.eventRefs) { this.plugin.app.vault.offref(ref); } this.eventRefs = []; // Persist tab state before cleanup (immediate, not debounced) await this.persistTabStateImmediate(); // Destroy tab manager and all tabs await this.tabManager?.destroy(); this.tabManager = null; // Cleanup tab bar this.tabBar?.destroy(); this.tabBar = null; } // ============================================ // UI Building // ============================================ private buildHeader(header: HTMLElement) { this.headerEl = header; // Title slot container (logo + title or tabs) this.titleSlotEl = header.createDiv({ cls: 'claudian-title-slot' }); // Logo (hidden when 2+ tabs) this.logoEl = this.titleSlotEl.createSpan({ cls: 'claudian-logo' }); const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.setAttribute('viewBox', LOGO_SVG.viewBox); svg.setAttribute('width', LOGO_SVG.width); svg.setAttribute('height', LOGO_SVG.height); svg.setAttribute('fill', 'none'); const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); path.setAttribute('d', LOGO_SVG.path); path.setAttribute('fill', LOGO_SVG.fill); svg.appendChild(path); this.logoEl.appendChild(svg); // Title text (hidden in header mode when 2+ tabs) this.titleTextEl = this.titleSlotEl.createEl('h4', { text: 'Claudian', cls: 'claudian-title-text' }); // Header actions container (for header mode - initially hidden) this.headerActionsEl = header.createDiv({ cls: 'claudian-header-actions claudian-header-actions-slot' }); this.headerActionsEl.style.display = 'none'; } /** * Builds the nav row content (tab badges + header actions). * This is called once and the content is moved between locations. */ private buildNavRowContent(): HTMLElement { // Create a fragment to hold nav row content const fragment = document.createDocumentFragment(); // Tab badges (left side in nav row, or in title slot for header mode) this.tabBarContainerEl = document.createElement('div'); this.tabBarContainerEl.className = 'claudian-tab-bar-container'; this.tabBar = new TabBar(this.tabBarContainerEl, { onTabClick: (tabId) => this.handleTabClick(tabId), onTabClose: (tabId) => this.handleTabClose(tabId), onNewTab: () => this.handleNewTab(), }); fragment.appendChild(this.tabBarContainerEl); // Header actions (right side) this.headerActionsContent = document.createElement('div'); this.headerActionsContent.className = 'claudian-header-actions'; // New tab button (plus icon) const newTabBtn = this.headerActionsContent.createDiv({ cls: 'claudian-header-btn claudian-new-tab-btn' }); setIcon(newTabBtn, 'square-plus'); newTabBtn.setAttribute('aria-label', 'New tab'); newTabBtn.addEventListener('click', async () => { await this.handleNewTab(); }); // New conversation button (square-pen icon - new conversation in current tab) const newBtn = this.headerActionsContent.createDiv({ cls: 'claudian-header-btn' }); setIcon(newBtn, 'square-pen'); newBtn.setAttribute('aria-label', 'New conversation'); newBtn.addEventListener('click', async () => { await this.tabManager?.createNewConversation(); this.updateHistoryDropdown(); }); // History dropdown const historyContainer = this.headerActionsContent.createDiv({ cls: 'claudian-history-container' }); const historyBtn = historyContainer.createDiv({ cls: 'claudian-header-btn' }); setIcon(historyBtn, 'history'); historyBtn.setAttribute('aria-label', 'Chat history'); this.historyDropdown = historyContainer.createDiv({ cls: 'claudian-history-menu' }); historyBtn.addEventListener('click', (e) => { e.stopPropagation(); this.toggleHistoryDropdown(); }); fragment.appendChild(this.headerActionsContent); // Create a wrapper div to hold the fragment (for input mode nav row) const wrapper = document.createElement('div'); wrapper.style.display = 'contents'; wrapper.appendChild(fragment); return wrapper; } /** * Moves nav row content based on tabBarPosition setting. * - 'input' mode: Both tab badges and actions go to active tab's navRowEl * - 'header' mode: Tab badges go to title slot (after logo), actions go to header right side */ private updateNavRowLocation(): void { if (!this.tabBarContainerEl || !this.headerActionsContent) return; const isHeaderMode = this.plugin.settings.tabBarPosition === 'header'; if (isHeaderMode) { // Header mode: Tab badges go to title slot, actions go to header right side if (this.titleSlotEl) { this.titleSlotEl.appendChild(this.tabBarContainerEl); } if (this.headerActionsEl) { this.headerActionsEl.appendChild(this.headerActionsContent); this.headerActionsEl.style.display = 'flex'; } } else { // Input mode: Both go to active tab's navRowEl via the wrapper const activeTab = this.tabManager?.getActiveTab(); if (activeTab && this.navRowContent) { // Re-assemble the nav row content wrapper this.navRowContent.appendChild(this.tabBarContainerEl); this.navRowContent.appendChild(this.headerActionsContent); activeTab.dom.navRowEl.appendChild(this.navRowContent); } // Hide header actions slot when in input mode if (this.headerActionsEl) { this.headerActionsEl.style.display = 'none'; } } } /** * Updates layout when tabBarPosition setting changes. * Called from settings when user changes the tab bar position. */ updateLayoutForPosition(): void { if (!this.viewContainerEl) return; const isHeaderMode = this.plugin.settings.tabBarPosition === 'header'; // Update container class for CSS styling this.viewContainerEl.toggleClass('claudian-container--header-mode', isHeaderMode); // Move nav content to appropriate location this.updateNavRowLocation(); // Update tab bar and title visibility this.updateTabBarVisibility(); } // ============================================ // Tab Management // ============================================ private handleTabClick(tabId: TabId): void { this.tabManager?.switchToTab(tabId); } private async handleTabClose(tabId: TabId): Promise { const tab = this.tabManager?.getTab(tabId); // If streaming, treat close like user interrupt (force close cancels the stream) const force = tab?.state.isStreaming ?? false; await this.tabManager?.closeTab(tabId, force); this.updateTabBarVisibility(); } private async handleNewTab(): Promise { const tab = await this.tabManager?.createTab(); if (!tab) { const maxTabs = this.plugin.settings.maxTabs ?? 3; new Notice(`Maximum ${maxTabs} tabs allowed`); return; } this.updateTabBarVisibility(); } private updateTabBar(): void { if (!this.tabManager || !this.tabBar) return; // Debounce tab bar updates using requestAnimationFrame if (this.pendingTabBarUpdate !== null) { cancelAnimationFrame(this.pendingTabBarUpdate); } this.pendingTabBarUpdate = requestAnimationFrame(() => { this.pendingTabBarUpdate = null; if (!this.tabManager || !this.tabBar) return; const items = this.tabManager.getTabBarItems(); this.tabBar.update(items); this.updateTabBarVisibility(); }); } private updateTabBarVisibility(): void { if (!this.tabBarContainerEl || !this.tabManager) return; const tabCount = this.tabManager.getTabCount(); const showTabBar = tabCount >= 2; const isHeaderMode = this.plugin.settings.tabBarPosition === 'header'; // Hide tab badges when only 1 tab, show when 2+ this.tabBarContainerEl.style.display = showTabBar ? 'flex' : 'none'; // In header mode, badges replace logo/title in the same location // In input mode, keep logo/title visible (badges are in nav row) const hideBranding = showTabBar && isHeaderMode; if (this.logoEl) { this.logoEl.style.display = hideBranding ? 'none' : ''; } if (this.titleTextEl) { this.titleTextEl.style.display = hideBranding ? 'none' : ''; } } // ============================================ // History Dropdown // ============================================ private toggleHistoryDropdown(): void { if (!this.historyDropdown) return; const isVisible = this.historyDropdown.hasClass('visible'); if (isVisible) { this.historyDropdown.removeClass('visible'); } else { this.updateHistoryDropdown(); this.historyDropdown.addClass('visible'); } } private updateHistoryDropdown(): void { if (!this.historyDropdown) return; this.historyDropdown.empty(); const activeTab = this.tabManager?.getActiveTab(); const conversationController = activeTab?.controllers.conversationController; if (conversationController) { conversationController.renderHistoryDropdown(this.historyDropdown, { onSelectConversation: async (conversationId) => { // Check if conversation is already open in this view's tabs const existingTab = this.findTabWithConversation(conversationId); if (existingTab) { // Switch to existing tab instead of opening in current tab await this.tabManager?.switchToTab(existingTab.id); this.historyDropdown?.removeClass('visible'); return; } // Check if conversation is open in another view (split workspace scenario) const crossViewResult = this.plugin.findConversationAcrossViews(conversationId); if (crossViewResult && crossViewResult.view !== this) { // Focus the other view's leaf and switch to the tab this.plugin.app.workspace.revealLeaf(crossViewResult.view.leaf); await crossViewResult.view.getTabManager()?.switchToTab(crossViewResult.tabId); this.historyDropdown?.removeClass('visible'); return; } // Open in current tab await this.tabManager?.openConversation(conversationId); this.historyDropdown?.removeClass('visible'); }, }); } } private findTabWithConversation(conversationId: string): TabData | null { const tabs = this.tabManager?.getAllTabs() ?? []; return tabs.find(tab => tab.conversationId === conversationId) ?? null; } // ============================================ // Event Wiring // ============================================ private wireEventHandlers(): void { // Document-level click to close dropdowns this.registerDomEvent(document, 'click', () => { this.historyDropdown?.removeClass('visible'); }); // View-level Shift+Tab to toggle plan mode (works from any focused element) this.registerDomEvent(this.containerEl, 'keydown', (e: KeyboardEvent) => { if (e.key === 'Tab' && e.shiftKey && !e.isComposing) { e.preventDefault(); const activeTab = this.tabManager?.getActiveTab(); if (!activeTab) return; const current = this.plugin.settings.permissionMode; if (current === 'plan') { const restoreMode = activeTab.state.prePlanPermissionMode ?? 'normal'; activeTab.state.prePlanPermissionMode = null; updatePlanModeUI(activeTab, this.plugin, restoreMode); } else { activeTab.state.prePlanPermissionMode = current; updatePlanModeUI(activeTab, this.plugin, 'plan'); } } }); // Register Escape on the view's Obsidian Scope to prevent Obsidian from // navigating away when Claudian is open as a main-area tab. // Returning false consumes the event (preventDefault + stops scope propagation). this.scope = new Scope(this.app.scope); this.scope.register([], 'Escape', () => { const activeTab = this.tabManager?.getActiveTab(); if (activeTab?.state.isStreaming) { activeTab.controllers.inputController?.cancelStreaming(); } return false; }); // Vault events - forward to active tab's file context manager const markCacheDirty = (includesFolders: boolean): void => { const mgr = this.tabManager?.getActiveTab()?.ui.fileContextManager; if (!mgr) return; mgr.markFileCacheDirty(); if (includesFolders) mgr.markFolderCacheDirty(); }; this.eventRefs.push( this.plugin.app.vault.on('create', () => markCacheDirty(true)), this.plugin.app.vault.on('delete', () => markCacheDirty(true)), this.plugin.app.vault.on('rename', () => markCacheDirty(true)), this.plugin.app.vault.on('modify', () => markCacheDirty(false)) ); // File open event this.registerEvent( this.plugin.app.workspace.on('file-open', (file) => { if (file) { this.tabManager?.getActiveTab()?.ui.fileContextManager?.handleFileOpen(file); } }) ); // Click outside to close mention dropdown this.registerDomEvent(document, 'click', (e) => { const activeTab = this.tabManager?.getActiveTab(); if (activeTab) { const fcm = activeTab.ui.fileContextManager; if (fcm && !fcm.containsElement(e.target as Node) && e.target !== activeTab.dom.inputEl) { fcm.hideMentionDropdown(); } } }); } // ============================================ // Persistence // ============================================ private async restoreOrCreateTabs(): Promise { if (!this.tabManager) return; // Try to restore from persisted state const persistedState = await this.plugin.storage.getTabManagerState(); if (persistedState && persistedState.openTabs.length > 0) { await this.tabManager.restoreState(persistedState); await this.plugin.storage.clearLegacyActiveConversationId(); return; } // No persisted state - migrate legacy activeConversationId if present const legacyActiveId = await this.plugin.storage.getLegacyActiveConversationId(); if (legacyActiveId) { const conversation = await this.plugin.getConversationById(legacyActiveId); if (conversation) { await this.tabManager.createTab(conversation.id); } else { await this.tabManager.createTab(); } await this.plugin.storage.clearLegacyActiveConversationId(); return; } // Fallback: create a new empty tab await this.tabManager.createTab(); await this.plugin.storage.clearLegacyActiveConversationId(); } private persistTabState(): void { // Debounce persistence to avoid rapid writes (300ms delay) if (this.pendingPersist !== null) { clearTimeout(this.pendingPersist); } this.pendingPersist = setTimeout(() => { this.pendingPersist = null; if (!this.tabManager) return; const state = this.tabManager.getPersistedState(); this.plugin.storage.setTabManagerState(state).catch(() => { // Silently ignore persistence errors }); }, 300); } /** Force immediate persistence (for onClose/onunload). */ private async persistTabStateImmediate(): Promise { // Cancel any pending debounced persist if (this.pendingPersist !== null) { clearTimeout(this.pendingPersist); this.pendingPersist = null; } if (!this.tabManager) return; const state = this.tabManager.getPersistedState(); await this.plugin.storage.setTabManagerState(state); } // ============================================ // Public API // ============================================ /** Gets the currently active tab. */ getActiveTab(): TabData | null { return this.tabManager?.getActiveTab() ?? null; } /** Gets the tab manager. */ getTabManager(): TabManager | null { return this.tabManager; } } ================================================ FILE: src/features/chat/constants.ts ================================================ export const LOGO_SVG = { viewBox: '0 -.01 39.5 39.53', width: '18', height: '18', path: 'm7.75 26.27 7.77-4.36.13-.38-.13-.21h-.38l-1.3-.08-4.44-.12-3.85-.16-3.73-.2-.94-.2-.88-1.16.09-.58.79-.53 1.13.1 2.5.17 3.75.26 2.72.16 4.03.42h.64l.09-.26-.22-.16-.17-.16-3.88-2.63-4.2-2.78-2.2-1.6-1.19-.81-.6-.76-.26-1.66 1.08-1.19 1.45.1.37.1 1.47 1.13 3.14 2.43 4.1 3.02.6.5.24-.17.03-.12-.27-.45-2.23-4.03-2.38-4.1-1.06-1.7-.28-1.02c-.1-.42-.17-.77-.17-1.2l1.23-1.67.68-.22 1.64.22.69.6 1.02 2.33 1.65 3.67 2.56 4.99.75 1.48.4 1.37.15.42h.26v-.24l.21-2.81.39-3.45.38-4.44.13-1.25.62-1.5 1.23-.81.96.46.79 1.13-.11.73-.47 3.05-.92 4.78-.6 3.2h.35l.4-.4 1.62-2.15 2.72-3.4 1.2-1.35 1.4-1.49.9-.71h1.7l1.25 1.86-.56 1.92-1.75 2.22-1.45 1.88-2.08 2.8-1.3 2.24.12.18.31-.03 4.7-1 2.54-.46 3.03-.52 1.37.64.15.65-.54 1.33-3.24.8-3.8.76-5.66 1.34-.07.05.08.1 2.55.24 1.09.06h2.67l4.97.37 1.3.86.78 1.05-.13.8-2 1.02-2.7-.64-6.3-1.5-2.16-.54h-.3v.18l1.8 1.76 3.3 2.98 4.13 3.84.21.95-.53.75-.56-.08-3.63-2.73-1.4-1.23-3.17-2.67h-.21v.28l.73 1.07 3.86 5.8.2 1.78-.28.58-1 .35-1.1-.2-2.26-3.17-2.33-3.57-1.88-3.2-.23.13-1.11 11.95-.52.61-1.2.46-1-.76-.53-1.23.53-2.43.64-3.17.52-2.52.47-3.13.28-1.04-.02-.07-.23.03-2.36 3.24-3.59 4.85-2.84 3.04-.68.27-1.18-.61.11-1.09.66-.97 3.93-5 2.37-3.1 1.53-1.79-.01-.26h-.09l-10.44 6.78-1.86.24-.8-.75.1-1.23.38-.4 3.14-2.16z', fill: '#d97757', } as const; /** Random flavor words shown when response completes (e.g., "Baked for 1:23"). */ export const COMPLETION_FLAVOR_WORDS = [ 'Baked', 'Cooked', 'Crunched', 'Brewed', 'Crafted', 'Forged', 'Conjured', 'Whipped up', 'Stirred', 'Simmered', 'Toasted', 'Sautéed', 'Finagled', 'Marinated', 'Distilled', 'Fermented', 'Percolated', 'Steeped', 'Roasted', 'Cured', 'Smoked', 'Cogitated', ] as const; /** Random flavor texts shown while Claude is thinking. */ export const FLAVOR_TEXTS = [ // Classic 'Thinking...', 'Pondering...', 'Processing...', 'Analyzing...', 'Considering...', 'Working on it...', 'Vibing...', 'One moment...', 'On it...', // Thoughtful 'Ruminating...', 'Contemplating...', 'Reflecting...', 'Mulling it over...', 'Let me think...', 'Hmm...', 'Cogitating...', 'Deliberating...', 'Weighing options...', 'Gathering thoughts...', // Playful 'Brewing ideas...', 'Connecting dots...', 'Assembling thoughts...', 'Spinning up neurons...', 'Loading brilliance...', 'Consulting the oracle...', 'Summoning knowledge...', 'Crunching thoughts...', 'Dusting off neurons...', 'Wrangling ideas...', 'Herding thoughts...', 'Juggling concepts...', 'Untangling this...', 'Piecing it together...', // Cozy 'Sipping coffee...', 'Warming up...', 'Getting cozy with this...', 'Settling in...', 'Making tea...', 'Grabbing a snack...', // Technical 'Parsing...', 'Compiling thoughts...', 'Running inference...', 'Querying the void...', 'Defragmenting brain...', 'Allocating memory...', 'Optimizing...', 'Indexing...', 'Syncing neurons...', // Zen 'Breathing...', 'Finding clarity...', 'Channeling focus...', 'Centering...', 'Aligning chakras...', 'Meditating on this...', // Whimsical 'Asking the stars...', 'Reading tea leaves...', 'Shaking the magic 8-ball...', 'Consulting ancient scrolls...', 'Decoding the matrix...', 'Communing with the ether...', 'Peering into the abyss...', 'Channeling the cosmos...', // Action 'Diving in...', 'Rolling up sleeves...', 'Getting to work...', 'Tackling this...', 'On the case...', 'Investigating...', 'Exploring...', 'Digging deeper...', // Casual 'Bear with me...', 'Hang tight...', 'Just a sec...', 'Working my magic...', 'Almost there...', 'Give me a moment...', ]; ================================================ FILE: src/features/chat/controllers/BrowserSelectionController.ts ================================================ import type { App, ItemView } from 'obsidian'; import type { BrowserSelectionContext } from '../../../utils/browser'; import { updateContextRowHasContent } from './contextRowVisibility'; const BROWSER_SELECTION_POLL_INTERVAL = 250; type BrowserLikeWebview = HTMLElement & { executeJavaScript?: (code: string, userGesture?: boolean) => Promise; }; export class BrowserSelectionController { private app: App; private indicatorEl: HTMLElement; private inputEl: HTMLElement; private contextRowEl: HTMLElement; private onVisibilityChange: (() => void) | null; private storedSelection: BrowserSelectionContext | null = null; private pollInterval: ReturnType | null = null; private pollInFlight = false; constructor( app: App, indicatorEl: HTMLElement, inputEl: HTMLElement, contextRowEl: HTMLElement, onVisibilityChange?: () => void ) { this.app = app; this.indicatorEl = indicatorEl; this.inputEl = inputEl; this.contextRowEl = contextRowEl; this.onVisibilityChange = onVisibilityChange ?? null; } start(): void { if (this.pollInterval) return; this.pollInterval = setInterval(() => { void this.poll(); }, BROWSER_SELECTION_POLL_INTERVAL); } stop(): void { if (this.pollInterval) { clearInterval(this.pollInterval); this.pollInterval = null; } this.clear(); } private async poll(): Promise { if (this.pollInFlight) return; this.pollInFlight = true; try { const browserView = this.getActiveBrowserView(); if (!browserView) { this.clearWhenInputIsNotFocused(); return; } const selectedText = await this.extractSelectedText(browserView.containerEl); if (selectedText) { const nextContext = this.buildContext(browserView.view, browserView.viewType, browserView.containerEl, selectedText); if (!this.isSameSelection(nextContext, this.storedSelection)) { this.storedSelection = nextContext; this.updateIndicator(); } } else { this.clearWhenInputIsNotFocused(); } } catch { // Ignore transient polling errors to keep selection tracking resilient. } finally { this.pollInFlight = false; } } private getActiveBrowserView(): { view: ItemView; viewType: string; containerEl: HTMLElement } | null { const activeLeaf = (this.app.workspace as any).activeLeaf ?? this.app.workspace.getMostRecentLeaf?.(); const activeView = activeLeaf?.view as ItemView | undefined; const containerEl = (activeView as unknown as { containerEl?: HTMLElement }).containerEl; if (!activeView || !containerEl) return null; const viewType = activeView.getViewType?.() ?? ''; if (!this.isBrowserLikeView(viewType, containerEl)) return null; return { view: activeView, viewType, containerEl }; } private isBrowserLikeView(viewType: string, containerEl: HTMLElement): boolean { const normalized = viewType.toLowerCase(); if ( normalized.includes('surfing') || normalized.includes('browser') || normalized.includes('webview') ) { return true; } return Boolean(containerEl.querySelector('iframe, webview')); } private async extractSelectedText(containerEl: HTMLElement): Promise { const ownerDoc = containerEl.ownerDocument; const docSelection = this.extractSelectionFromDocument(ownerDoc, containerEl); if (docSelection) return docSelection; const frameSelection = this.extractSelectionFromIframes(containerEl); if (frameSelection) return frameSelection; return await this.extractSelectionFromWebviews(containerEl); } private extractSelectionFromDocument(doc: Document, scopeEl: HTMLElement): string | null { const selection = doc.getSelection(); const selectedText = selection?.toString().trim(); if (selectedText) { const anchorNode = selection?.anchorNode; const focusNode = selection?.focusNode; if ((anchorNode && scopeEl.contains(anchorNode)) || (focusNode && scopeEl.contains(focusNode))) { return selectedText; } } return this.extractSelectionFromActiveInput(doc, scopeEl); } private extractSelectionFromActiveInput(doc: Document, scopeEl: HTMLElement): string | null { const activeEl = doc.activeElement; if (!activeEl || !scopeEl.contains(activeEl)) return null; if (activeEl instanceof HTMLTextAreaElement || activeEl instanceof HTMLInputElement) { const { value, selectionStart, selectionEnd } = activeEl; if (typeof selectionStart !== 'number' || typeof selectionEnd !== 'number' || selectionStart === selectionEnd) return null; return value.slice(selectionStart, selectionEnd).trim() || null; } return null; } private extractSelectionFromIframes(containerEl: HTMLElement): string | null { const iframes = Array.from(containerEl.querySelectorAll('iframe')); for (const iframe of iframes) { try { const frameDoc = iframe.contentDocument ?? iframe.contentWindow?.document; if (!frameDoc || !frameDoc.body) continue; const frameSelection = this.extractSelectionFromDocument(frameDoc, frameDoc.body); if (frameSelection) return frameSelection; } catch { // Ignore inaccessible iframe contexts (cross-origin restrictions). } } return null; } private async extractSelectionFromWebviews(containerEl: HTMLElement): Promise { const webviews = Array.from(containerEl.querySelectorAll('webview')) as BrowserLikeWebview[]; for (const webview of webviews) { if (typeof webview.executeJavaScript !== 'function') continue; try { const result = await webview.executeJavaScript( 'window.getSelection ? window.getSelection().toString() : ""', true ); if (typeof result === 'string' && result.trim()) { return result.trim(); } } catch { // Ignore inaccessible webview contexts. } } return null; } private buildContext( view: ItemView, viewType: string, containerEl: HTMLElement, selectedText: string ): BrowserSelectionContext { const title = this.extractViewTitle(view); const url = this.extractViewUrl(view, containerEl); const source = url ? `browser:${url}` : `browser:${viewType || 'unknown'}`; return { source, selectedText, title, url, }; } private extractViewTitle(view: ItemView): string | undefined { const displayText = view.getDisplayText?.(); if (displayText?.trim()) return displayText.trim(); const title = (view as unknown as { title?: unknown }).title; return typeof title === 'string' && title.trim() ? title.trim() : undefined; } private extractViewUrl(view: ItemView, containerEl: HTMLElement): string | undefined { const rawView = view as unknown as Record; const directCandidates = [ rawView.url, rawView.currentUrl, rawView.currentURL, rawView.src, ]; for (const candidate of directCandidates) { if (typeof candidate === 'string' && candidate.trim()) { return candidate.trim(); } } const embeddableEl = containerEl.querySelector('iframe[src], webview[src]') as HTMLElement | null; const embeddedSrc = embeddableEl?.getAttribute('src'); if (embeddedSrc?.trim()) { return embeddedSrc.trim(); } return undefined; } private isSameSelection( left: BrowserSelectionContext | null, right: BrowserSelectionContext | null ): boolean { if (!left || !right) return false; return left.source === right.source && left.selectedText === right.selectedText && left.title === right.title && left.url === right.url; } private clearWhenInputIsNotFocused(): void { if (document.activeElement === this.inputEl) return; if (this.storedSelection) { this.storedSelection = null; this.updateIndicator(); } } private updateIndicator(): void { if (!this.indicatorEl) return; if (this.storedSelection) { const lineCount = this.storedSelection.selectedText.split(/\r?\n/).length; const lineLabel = lineCount === 1 ? 'line' : 'lines'; this.indicatorEl.textContent = `${lineCount} ${lineLabel} selected`; this.indicatorEl.setAttribute('title', this.buildIndicatorTitle()); this.indicatorEl.style.display = 'block'; } else { this.indicatorEl.style.display = 'none'; this.indicatorEl.textContent = ''; this.indicatorEl.removeAttribute('title'); } this.updateContextRowVisibility(); } private buildIndicatorTitle(): string { if (!this.storedSelection) return ''; const charCount = this.storedSelection.selectedText.length; const charLabel = charCount === 1 ? 'char' : 'chars'; const lines = [`${charCount} ${charLabel} selected`, `source=${this.storedSelection.source}`]; if (this.storedSelection.title) { lines.push(`title=${this.storedSelection.title}`); } if (this.storedSelection.url) { lines.push(this.storedSelection.url); } return lines.join('\n'); } updateContextRowVisibility(): void { if (!this.contextRowEl) return; updateContextRowHasContent(this.contextRowEl); this.onVisibilityChange?.(); } getContext(): BrowserSelectionContext | null { return this.storedSelection; } hasSelection(): boolean { return this.storedSelection !== null; } clear(): void { this.storedSelection = null; this.updateIndicator(); } } ================================================ FILE: src/features/chat/controllers/CanvasSelectionController.ts ================================================ import type { App, ItemView } from 'obsidian'; import type { CanvasSelectionContext } from '../../../utils/canvas'; import { updateContextRowHasContent } from './contextRowVisibility'; const CANVAS_POLL_INTERVAL = 250; export class CanvasSelectionController { private app: App; private indicatorEl: HTMLElement; private inputEl: HTMLElement; private contextRowEl: HTMLElement; private onVisibilityChange: (() => void) | null; private storedSelection: CanvasSelectionContext | null = null; private pollInterval: ReturnType | null = null; constructor( app: App, indicatorEl: HTMLElement, inputEl: HTMLElement, contextRowEl: HTMLElement, onVisibilityChange?: () => void ) { this.app = app; this.indicatorEl = indicatorEl; this.inputEl = inputEl; this.contextRowEl = contextRowEl; this.onVisibilityChange = onVisibilityChange ?? null; } start(): void { if (this.pollInterval) return; this.pollInterval = setInterval(() => this.poll(), CANVAS_POLL_INTERVAL); } stop(): void { if (this.pollInterval) { clearInterval(this.pollInterval); this.pollInterval = null; } this.clear(); } private poll(): void { const canvasView = this.getCanvasView(); if (!canvasView) return; const canvas = (canvasView as any).canvas; if (!canvas?.selection) return; const selection: Set<{ id: string }> = canvas.selection; const canvasPath = (canvasView as any).file?.path; if (!canvasPath) return; const nodeIds = [...selection].map(node => node.id).filter(Boolean); if (nodeIds.length > 0) { const sameSelection = this.storedSelection && this.storedSelection.canvasPath === canvasPath && this.storedSelection.nodeIds.length === nodeIds.length && this.storedSelection.nodeIds.every(id => nodeIds.includes(id)); if (!sameSelection) { this.storedSelection = { canvasPath, nodeIds }; this.updateIndicator(); } } else if (document.activeElement !== this.inputEl) { if (this.storedSelection) { this.storedSelection = null; this.updateIndicator(); } } } private getCanvasView(): ItemView | null { const activeLeaf = (this.app.workspace as any).activeLeaf ?? this.app.workspace.getMostRecentLeaf?.(); const activeView = activeLeaf?.view as ItemView | undefined; if (activeView?.getViewType?.() === 'canvas' && (activeView as any).file) { return activeView; } const leaves = this.app.workspace.getLeavesOfType('canvas'); if (leaves.length === 0) return null; const leaf = leaves.find(l => (l.view as any).file); return leaf ? (leaf.view as ItemView) : null; } private updateIndicator(): void { if (!this.indicatorEl) return; if (this.storedSelection) { const { nodeIds } = this.storedSelection; this.indicatorEl.textContent = nodeIds.length === 1 ? `node "${nodeIds[0]}" selected` : `${nodeIds.length} nodes selected`; this.indicatorEl.style.display = 'block'; } else { this.indicatorEl.style.display = 'none'; } this.updateContextRowVisibility(); } updateContextRowVisibility(): void { if (!this.contextRowEl) return; updateContextRowHasContent(this.contextRowEl); this.onVisibilityChange?.(); } getContext(): CanvasSelectionContext | null { if (!this.storedSelection) return null; return { canvasPath: this.storedSelection.canvasPath, nodeIds: [...this.storedSelection.nodeIds], }; } hasSelection(): boolean { return this.storedSelection !== null; } clear(): void { this.storedSelection = null; this.updateIndicator(); } } ================================================ FILE: src/features/chat/controllers/ConversationController.ts ================================================ import { Notice, setIcon } from 'obsidian'; import type { ClaudianService } from '../../../core/agent'; import type { Conversation } from '../../../core/types'; import { t } from '../../../i18n'; import type ClaudianPlugin from '../../../main'; import { confirm } from '../../../shared/modals/ConfirmModal'; import { cleanupThinkingBlock } from '../rendering'; import type { MessageRenderer } from '../rendering/MessageRenderer'; import { findRewindContext } from '../rewind'; import type { SubagentManager } from '../services/SubagentManager'; import type { TitleGenerationService } from '../services/TitleGenerationService'; import type { ChatState } from '../state/ChatState'; import type { ExternalContextSelector, FileContextManager, ImageContextManager, McpServerSelector, StatusPanel } from '../ui'; export interface ConversationCallbacks { onNewConversation?: () => void; onConversationLoaded?: () => void; onConversationSwitched?: () => void; } export interface ConversationControllerDeps { plugin: ClaudianPlugin; state: ChatState; renderer: MessageRenderer; subagentManager: SubagentManager; getHistoryDropdown: () => HTMLElement | null; getWelcomeEl: () => HTMLElement | null; setWelcomeEl: (el: HTMLElement | null) => void; getMessagesEl: () => HTMLElement; getInputEl: () => HTMLTextAreaElement; getFileContextManager: () => FileContextManager | null; getImageContextManager: () => ImageContextManager | null; getMcpServerSelector: () => McpServerSelector | null; getExternalContextSelector: () => ExternalContextSelector | null; clearQueuedMessage: () => void; getTitleGenerationService: () => TitleGenerationService | null; getStatusPanel: () => StatusPanel | null; getAgentService?: () => ClaudianService | null; } type SaveOptions = { resumeSessionAt?: string; }; export class ConversationController { private deps: ConversationControllerDeps; private callbacks: ConversationCallbacks; constructor(deps: ConversationControllerDeps, callbacks: ConversationCallbacks = {}) { this.deps = deps; this.callbacks = callbacks; } private getAgentService(): ClaudianService | null { return this.deps.getAgentService?.() ?? null; } // ============================================ // Conversation Lifecycle // ============================================ /** * Resets to entry point state (New Chat). * * Entry point is a blank UI state - no conversation is created until the * first message is sent. This prevents empty conversations cluttering history. */ async createNew(options: { force?: boolean } = {}): Promise { const { plugin, state, subagentManager } = this.deps; const force = !!options.force; if (state.isStreaming && !force) return; if (state.isCreatingConversation) return; if (state.isSwitchingConversation) return; // Set flag to block message sending during reset state.isCreatingConversation = true; try { if (force && state.isStreaming) { state.cancelRequested = true; state.bumpStreamGeneration(); this.getAgentService()?.cancel(); } // Save current conversation if it has messages if (state.currentConversationId && state.messages.length > 0) { await this.save(); } subagentManager.orphanAllActive(); subagentManager.clear(); // Clear streaming state and related DOM references cleanupThinkingBlock(state.currentThinkingState); state.currentContentEl = null; state.currentTextEl = null; state.currentTextContent = ''; state.currentThinkingState = null; state.toolCallElements.clear(); state.writeEditStates.clear(); state.isStreaming = false; // Reset to entry point state - no conversation created yet state.currentConversationId = null; state.clearMessages(); state.usage = null; state.currentTodos = null; state.pendingNewSessionPlan = null; state.planFilePath = null; state.prePlanPermissionMode = null; state.autoScrollEnabled = plugin.settings.enableAutoScroll ?? true; // Reset agent service session (no session ID for entry point) // Pass persistent paths to prevent stale external contexts this.getAgentService()?.setSessionId( null, plugin.settings.persistentExternalContextPaths || [] ); const messagesEl = this.deps.getMessagesEl(); messagesEl.empty(); // Recreate welcome element first (before StatusPanel for consistent ordering) const welcomeEl = messagesEl.createDiv({ cls: 'claudian-welcome' }); welcomeEl.createDiv({ cls: 'claudian-welcome-greeting', text: this.getGreeting() }); this.deps.setWelcomeEl(welcomeEl); // Remount StatusPanel to restore state for new conversation this.deps.getStatusPanel()?.remount(); this.deps.getInputEl().value = ''; const fileCtx = this.deps.getFileContextManager(); fileCtx?.resetForNewConversation(); fileCtx?.autoAttachActiveFile(); this.deps.getImageContextManager()?.clearImages(); this.deps.getMcpServerSelector()?.clearEnabled(); // Pass current settings to ensure we have the most up-to-date persistent paths this.deps.getExternalContextSelector()?.clearExternalContexts( plugin.settings.persistentExternalContextPaths || [] ); this.deps.clearQueuedMessage(); this.callbacks.onNewConversation?.(); } finally { state.isCreatingConversation = false; } } /** * Loads the current tab conversation, or starts at entry point if none. * * Entry point (no conversation) shows welcome screen without * creating a conversation. Conversation is created lazily on first message. */ async loadActive(): Promise { const { plugin, state, renderer } = this.deps; const conversationId = state.currentConversationId; const conversation = conversationId ? await plugin.getConversationById(conversationId) : null; // No active conversation - start at entry point if (!conversation) { state.currentConversationId = null; state.clearMessages(); state.usage = null; state.currentTodos = null; state.pendingNewSessionPlan = null; state.planFilePath = null; state.prePlanPermissionMode = null; state.autoScrollEnabled = plugin.settings.enableAutoScroll ?? true; // Pass persistent paths to prevent stale external contexts this.getAgentService()?.setSessionId( null, plugin.settings.persistentExternalContextPaths || [] ); const fileCtx = this.deps.getFileContextManager(); fileCtx?.resetForNewConversation(); fileCtx?.autoAttachActiveFile(); // Initialize external contexts with persistent paths from settings this.deps.getExternalContextSelector()?.clearExternalContexts( plugin.settings.persistentExternalContextPaths || [] ); this.deps.getMcpServerSelector()?.clearEnabled(); const welcomeEl = renderer.renderMessages( [], () => this.getGreeting() ); this.deps.setWelcomeEl(welcomeEl); this.updateWelcomeVisibility(); this.callbacks.onConversationLoaded?.(); return; } // Load existing conversation state.currentConversationId = conversation.id; state.messages = [...conversation.messages]; state.usage = conversation.usage ?? null; state.autoScrollEnabled = plugin.settings.enableAutoScroll ?? true; // Clear status panels (auto-hide: panels reappear when agent creates new todos) state.currentTodos = null; const hasMessages = state.messages.length > 0; // Determine external context paths for this session // Empty session: use persistent paths; session with messages: use saved paths const externalContextPaths = hasMessages ? conversation.externalContextPaths || [] : plugin.settings.persistentExternalContextPaths || []; this.getAgentService()?.setSessionId(conversation.sessionId ?? null, externalContextPaths); const fileCtx = this.deps.getFileContextManager(); fileCtx?.resetForLoadedConversation(hasMessages); if (conversation.currentNote) { fileCtx?.setCurrentNote(conversation.currentNote); } else if (!hasMessages) { fileCtx?.autoAttachActiveFile(); } // Restore external context paths based on session state this.restoreExternalContextPaths( conversation.externalContextPaths, !hasMessages ); // Restore enabled MCP servers (or clear for new conversation) const mcpServerSelector = this.deps.getMcpServerSelector(); if (conversation.enabledMcpServers && conversation.enabledMcpServers.length > 0) { mcpServerSelector?.setEnabledServers(conversation.enabledMcpServers); } else { mcpServerSelector?.clearEnabled(); } const welcomeEl = renderer.renderMessages( state.messages, () => this.getGreeting() ); this.deps.setWelcomeEl(welcomeEl); this.updateWelcomeVisibility(); this.callbacks.onConversationLoaded?.(); } /** Switches to a different conversation. */ async switchTo(id: string): Promise { const { plugin, state, renderer, subagentManager } = this.deps; if (id === state.currentConversationId) return; if (state.isStreaming) return; if (state.isSwitchingConversation) return; if (state.isCreatingConversation) return; state.isSwitchingConversation = true; try { await this.save(); subagentManager.orphanAllActive(); subagentManager.clear(); const conversation = await plugin.switchConversation(id); if (!conversation) { return; } state.currentConversationId = conversation.id; state.messages = [...conversation.messages]; state.usage = conversation.usage ?? null; state.autoScrollEnabled = plugin.settings.enableAutoScroll ?? true; // Clear status panels (auto-hide: panels reappear when agent creates new todos) state.currentTodos = null; const hasMessages = state.messages.length > 0; // Determine external context paths for this session // Empty session: use persistent paths; session with messages: use saved paths const externalContextPaths = hasMessages ? conversation.externalContextPaths || [] : plugin.settings.persistentExternalContextPaths || []; // Update agent service session ID with correct external contexts const agentService = this.getAgentService(); if (agentService) { const resolvedSessionId = agentService.applyForkState(conversation); agentService.setSessionId(resolvedSessionId, externalContextPaths); } this.deps.getInputEl().value = ''; this.deps.clearQueuedMessage(); const fileCtx = this.deps.getFileContextManager(); fileCtx?.resetForLoadedConversation(hasMessages); if (conversation.currentNote) { fileCtx?.setCurrentNote(conversation.currentNote); } // Restore external context paths based on session state this.restoreExternalContextPaths( conversation.externalContextPaths, !hasMessages ); // Restore enabled MCP servers (or clear if none) const mcpServerSelector = this.deps.getMcpServerSelector(); if (conversation.enabledMcpServers && conversation.enabledMcpServers.length > 0) { mcpServerSelector?.setEnabledServers(conversation.enabledMcpServers); } else { mcpServerSelector?.clearEnabled(); } const welcomeEl = renderer.renderMessages( state.messages, () => this.getGreeting() ); this.deps.setWelcomeEl(welcomeEl); this.deps.getHistoryDropdown()?.removeClass('visible'); this.updateWelcomeVisibility(); this.callbacks.onConversationSwitched?.(); } finally { state.isSwitchingConversation = false; } } async rewind(userMessageId: string): Promise { const { plugin, state, renderer } = this.deps; if (state.isStreaming) { new Notice(t('chat.rewind.unavailableStreaming')); return; } const msgs = state.messages; const userIdx = msgs.findIndex(m => m.id === userMessageId); if (userIdx === -1) { new Notice(t('chat.rewind.failed', { error: 'Message not found' })); return; } const userMsg = msgs[userIdx]; if (!userMsg.sdkUserUuid) { new Notice(t('chat.rewind.unavailableNoUuid')); return; } const rewindCtx = findRewindContext(msgs, userIdx); if (!rewindCtx.hasResponse || !rewindCtx.prevAssistantUuid) { new Notice(t('chat.rewind.unavailableNoUuid')); return; } const prevAssistantUuid = rewindCtx.prevAssistantUuid; const confirmed = await confirm( plugin.app, t('chat.rewind.confirmMessage'), t('chat.rewind.confirmButton') ); if (!confirmed) return; if (state.isStreaming) { new Notice(t('chat.rewind.unavailableStreaming')); return; } const agentService = this.getAgentService(); if (!agentService) { new Notice(t('chat.rewind.failed', { error: 'Agent service not available' })); return; } let result; try { result = await agentService.rewind(userMsg.sdkUserUuid, prevAssistantUuid); } catch (e) { new Notice(t('chat.rewind.failed', { error: e instanceof Error ? e.message : 'Unknown error' })); return; } if (!result.canRewind) { new Notice(t('chat.rewind.cannot', { error: result.error ?? 'Unknown error' })); return; } state.truncateAt(userMessageId); const inputEl = this.deps.getInputEl(); inputEl.value = userMsg.content; inputEl.focus(); const welcomeEl = renderer.renderMessages(state.messages, () => this.getGreeting()); this.deps.setWelcomeEl(welcomeEl); this.updateWelcomeVisibility(); const filesChanged = result.filesChanged?.length ?? 0; let saveError: string | null = null; try { await this.save(false, { resumeSessionAt: prevAssistantUuid }); } catch (e) { saveError = e instanceof Error ? e.message : 'Failed to save'; } if (saveError) { new Notice(t('chat.rewind.noticeSaveFailed', { count: String(filesChanged), error: saveError })); return; } new Notice(t('chat.rewind.notice', { count: String(filesChanged) })); } /** * Saves the current conversation. * * If we're at an entry point (no conversation yet) and have messages, * creates a new conversation first (lazy creation). * * For native sessions (new conversations with sessionId from SDK), * only metadata is saved - the SDK handles message persistence. */ async save(updateLastResponse = false, options?: SaveOptions): Promise { const { plugin, state } = this.deps; // Entry point with no messages - nothing to save if (!state.currentConversationId && state.messages.length === 0) { return; } const agentService = this.getAgentService(); const sessionId = agentService?.getSessionId() ?? null; const sessionInvalidated = agentService?.consumeSessionInvalidation?.() ?? false; // Entry point with messages - create conversation lazily // New conversations always use SDK-native storage. if (!state.currentConversationId && state.messages.length > 0) { const conversation = await plugin.createConversation(sessionId ?? undefined); state.currentConversationId = conversation.id; } const fileCtx = this.deps.getFileContextManager(); const currentNote = fileCtx?.getCurrentNotePath() || undefined; const externalContextSelector = this.deps.getExternalContextSelector(); const externalContextPaths = externalContextSelector?.getExternalContexts() ?? []; const mcpServerSelector = this.deps.getMcpServerSelector(); const enabledMcpServers = mcpServerSelector ? Array.from(mcpServerSelector.getEnabledServers()) : []; // Check if this is a native session and promote legacy sessions after first SDK session capture const conversation = await plugin.getConversationById(state.currentConversationId!); const wasNative = conversation?.isNative ?? false; const shouldPromote = !wasNative && !!sessionId; const isNative = wasNative || shouldPromote; const legacyMessages = conversation?.messages ?? []; const legacyCutoffAt = shouldPromote ? legacyMessages[legacyMessages.length - 1]?.timestamp : conversation?.legacyCutoffAt; // Detect session change (resume failed, SDK created new session) // Move old sdkSessionId to previousSdkSessionIds for history merging on reload // Use Set to deduplicate in case of race conditions or repeated session changes const oldSdkSessionId = conversation?.sdkSessionId; const sessionChanged = isNative && sessionId && oldSdkSessionId && sessionId !== oldSdkSessionId; const previousSdkSessionIds = sessionChanged ? [...new Set([...(conversation?.previousSdkSessionIds || []), oldSdkSessionId])] : conversation?.previousSdkSessionIds; // Don't persist the fork source session ID as the conversation's own session. // The agent service holds it for resume purposes only; the conversation gets // its own ID after SDK captureSession() returns a new session. const isForkSourceOnly = !!conversation?.forkSource && !conversation?.sdkSessionId && sessionId === conversation.forkSource.sessionId; let resolvedSessionId: string | null; if (sessionInvalidated) { resolvedSessionId = null; } else if (isForkSourceOnly) { resolvedSessionId = conversation?.sessionId ?? null; } else { resolvedSessionId = sessionId ?? conversation?.sessionId ?? null; } const updates: Partial = { messages: isNative ? state.messages : state.getPersistedMessages(), sessionId: resolvedSessionId, sdkSessionId: isNative && sessionId && !isForkSourceOnly ? sessionId : conversation?.sdkSessionId, previousSdkSessionIds, isNative: isNative || undefined, legacyCutoffAt, sdkMessagesLoaded: isNative ? true : undefined, currentNote: currentNote, externalContextPaths: externalContextPaths.length > 0 ? externalContextPaths : undefined, usage: state.usage ?? undefined, enabledMcpServers: enabledMcpServers.length > 0 ? enabledMcpServers : undefined, }; if (updateLastResponse) { updates.lastResponseAt = Date.now(); } if (options) { updates.resumeSessionAt = options.resumeSessionAt; } // Clear fork metadata after first save with a new session ID (one-time use) if (conversation?.forkSource && sessionId && sessionId !== conversation.forkSource.sessionId) { updates.forkSource = undefined; // Don't add forkSource.sessionId to previousSdkSessionIds // (the source session belongs to the original conversation) } // At this point, currentConversationId is guaranteed to be set // (either existed before or was created lazily above) await plugin.updateConversation(state.currentConversationId!, updates); } /** * Restores external context paths based on session state. * New or empty sessions get current persistent paths from settings. * Sessions with messages restore exactly what was saved. */ private restoreExternalContextPaths( savedPaths: string[] | undefined, isEmptySession: boolean ): void { const { plugin } = this.deps; const externalContextSelector = this.deps.getExternalContextSelector(); if (!externalContextSelector) { return; } if (isEmptySession) { // Empty session: use current persistent paths from settings externalContextSelector.clearExternalContexts( plugin.settings.persistentExternalContextPaths || [] ); } else { // Session with messages: restore exactly what was saved externalContextSelector.setExternalContexts(savedPaths || []); } } // ============================================ // History Dropdown // ============================================ toggleHistoryDropdown(): void { const dropdown = this.deps.getHistoryDropdown(); if (!dropdown) return; const isVisible = dropdown.hasClass('visible'); if (isVisible) { dropdown.removeClass('visible'); } else { this.updateHistoryDropdown(); dropdown.addClass('visible'); } } updateHistoryDropdown(): void { const dropdown = this.deps.getHistoryDropdown(); if (!dropdown) return; this.renderHistoryItems(dropdown, { onSelectConversation: (id) => this.switchTo(id), onRerender: () => this.updateHistoryDropdown(), }); } /** * Renders history dropdown items to a container. * Shared implementation for updateHistoryDropdown() and renderHistoryDropdown(). */ private renderHistoryItems( container: HTMLElement, options: { onSelectConversation: (id: string) => Promise; onRerender: () => void; } ): void { const { plugin, state } = this.deps; container.empty(); const dropdownHeader = container.createDiv({ cls: 'claudian-history-header' }); dropdownHeader.createSpan({ text: 'Conversations' }); const list = container.createDiv({ cls: 'claudian-history-list' }); const allConversations = plugin.getConversationList(); if (allConversations.length === 0) { list.createDiv({ cls: 'claudian-history-empty', text: 'No conversations' }); return; } // Sort by lastResponseAt (fallback to createdAt) descending const conversations = [...allConversations].sort((a, b) => { return (b.lastResponseAt ?? b.createdAt) - (a.lastResponseAt ?? a.createdAt); }); for (const conv of conversations) { const isCurrent = conv.id === state.currentConversationId; const item = list.createDiv({ cls: `claudian-history-item${isCurrent ? ' active' : ''}`, }); const iconEl = item.createDiv({ cls: 'claudian-history-item-icon' }); setIcon(iconEl, isCurrent ? 'message-square-dot' : 'message-square'); const content = item.createDiv({ cls: 'claudian-history-item-content' }); const titleEl = content.createDiv({ cls: 'claudian-history-item-title', text: conv.title }); titleEl.setAttribute('title', conv.title); content.createDiv({ cls: 'claudian-history-item-date', text: isCurrent ? 'Current session' : this.formatDate(conv.lastResponseAt ?? conv.createdAt), }); if (!isCurrent) { content.addEventListener('click', async (e) => { e.stopPropagation(); try { await options.onSelectConversation(conv.id); } catch { new Notice('Failed to load conversation'); } }); } const actions = item.createDiv({ cls: 'claudian-history-item-actions' }); // Show regenerate button if title generation failed, or loading indicator if pending if (conv.titleGenerationStatus === 'pending') { const loadingEl = actions.createEl('span', { cls: 'claudian-action-btn claudian-action-loading' }); setIcon(loadingEl, 'loader-2'); loadingEl.setAttribute('aria-label', 'Generating title...'); } else if (conv.titleGenerationStatus === 'failed') { const regenerateBtn = actions.createEl('button', { cls: 'claudian-action-btn' }); setIcon(regenerateBtn, 'refresh-cw'); regenerateBtn.setAttribute('aria-label', 'Regenerate title'); regenerateBtn.addEventListener('click', async (e) => { e.stopPropagation(); try { await this.regenerateTitle(conv.id); } catch { new Notice('Failed to regenerate response'); } }); } const renameBtn = actions.createEl('button', { cls: 'claudian-action-btn' }); setIcon(renameBtn, 'pencil'); renameBtn.setAttribute('aria-label', 'Rename'); renameBtn.addEventListener('click', (e) => { e.stopPropagation(); this.showRenameInput(item, conv.id, conv.title); }); const deleteBtn = actions.createEl('button', { cls: 'claudian-action-btn claudian-delete-btn' }); setIcon(deleteBtn, 'trash-2'); deleteBtn.setAttribute('aria-label', 'Delete'); deleteBtn.addEventListener('click', async (e) => { e.stopPropagation(); if (state.isStreaming) return; try { await plugin.deleteConversation(conv.id); options.onRerender(); if (conv.id === state.currentConversationId) { await this.loadActive(); } } catch { new Notice('Failed to delete conversation'); } }); } } /** Shows inline rename input for a conversation. */ private showRenameInput(item: HTMLElement, convId: string, currentTitle: string): void { const titleEl = item.querySelector('.claudian-history-item-title') as HTMLElement; if (!titleEl) return; const input = document.createElement('input'); input.type = 'text'; input.className = 'claudian-rename-input'; input.value = currentTitle; titleEl.replaceWith(input); input.focus(); input.select(); const finishRename = async () => { try { const newTitle = input.value.trim() || currentTitle; await this.deps.plugin.renameConversation(convId, newTitle); this.updateHistoryDropdown(); } catch { new Notice('Failed to rename conversation'); } }; input.addEventListener('blur', finishRename); input.addEventListener('keydown', async (e) => { // Check !e.isComposing for IME support (Chinese, Japanese, Korean, etc.) if (e.key === 'Enter' && !e.isComposing) { input.blur(); } else if (e.key === 'Escape' && !e.isComposing) { input.value = currentTitle; input.blur(); } }); } // ============================================ // Welcome & Greeting // ============================================ /** Generates a dynamic greeting based on time/day. */ getGreeting(): string { const now = new Date(); const hour = now.getHours(); const day = now.getDay(); // 0 = Sunday, 6 = Saturday const name = this.deps.plugin.settings.userName?.trim(); // Helper to optionally personalize a greeting (with fallback for no-name case) const personalize = (base: string, noNameFallback?: string): string => name ? `${base}, ${name}` : (noNameFallback ?? base); // Day-specific greetings (some personalized, some universal) const dayGreetings: Record = { 0: [personalize('Happy Sunday'), 'Sunday session?', 'Welcome to the weekend'], 1: [personalize('Happy Monday'), personalize('Back at it', 'Back at it!')], 2: [personalize('Happy Tuesday')], 3: [personalize('Happy Wednesday')], 4: [personalize('Happy Thursday')], 5: [personalize('Happy Friday'), personalize('That Friday feeling')], 6: [personalize('Happy Saturday', 'Happy Saturday!'), personalize('Welcome to the weekend')], }; // Time-specific greetings const getTimeGreetings = (): string[] => { if (hour >= 5 && hour < 12) { return [personalize('Good morning'), 'Coffee and Claudian time?']; } else if (hour >= 12 && hour < 18) { return [personalize('Good afternoon'), personalize('Hey there'), personalize("How's it going") + '?']; } else if (hour >= 18 && hour < 22) { return [personalize('Good evening'), personalize('Evening'), personalize('How was your day') + '?']; } else { return ['Hello, night owl', personalize('Evening')]; } }; // General greetings const generalGreetings = [ personalize('Hey there'), name ? `Hi ${name}, how are you?` : 'Hi, how are you?', personalize("How's it going") + '?', personalize('Welcome back') + '!', personalize("What's new") + '?', ...(name ? [`${name} returns!`] : []), 'You are absolutely right!', ]; // Combine day + time + general greetings, pick randomly const allGreetings = [ ...(dayGreetings[day] || []), ...getTimeGreetings(), ...generalGreetings, ]; return allGreetings[Math.floor(Math.random() * allGreetings.length)]; } /** Updates welcome element visibility based on message count. */ updateWelcomeVisibility(): void { const welcomeEl = this.deps.getWelcomeEl(); if (!welcomeEl) return; if (this.deps.state.messages.length === 0) { welcomeEl.style.display = ''; } else { welcomeEl.style.display = 'none'; } } /** * Initializes the welcome greeting for a new tab without a conversation. * Called when a new tab is activated and has no conversation loaded. */ initializeWelcome(): void { const welcomeEl = this.deps.getWelcomeEl(); if (!welcomeEl) return; // Initialize file context to auto-attach the currently focused note const fileCtx = this.deps.getFileContextManager(); fileCtx?.resetForNewConversation(); fileCtx?.autoAttachActiveFile(); // Only add greeting if not already present if (!welcomeEl.querySelector('.claudian-welcome-greeting')) { welcomeEl.createDiv({ cls: 'claudian-welcome-greeting', text: this.getGreeting() }); } this.updateWelcomeVisibility(); } // ============================================ // Utilities // ============================================ /** Generates a fallback title from the first message (used when AI fails). */ generateFallbackTitle(firstMessage: string): string { const firstSentence = firstMessage.split(/[.!?\n]/)[0].trim(); const autoTitle = firstSentence.substring(0, 50); const suffix = firstSentence.length > 50 ? '...' : ''; return `${autoTitle}${suffix}`; } /** Regenerates AI title for a conversation. */ async regenerateTitle(conversationId: string): Promise { const { plugin } = this.deps; if (!plugin.settings.enableAutoTitleGeneration) return; const titleService = this.deps.getTitleGenerationService(); if (!titleService) return; // Get the full conversation from cache const fullConv = await plugin.getConversationById(conversationId); if (!fullConv || fullConv.messages.length < 1) return; // Find first user message by role (not by index) const firstUserMsg = fullConv.messages.find(m => m.role === 'user'); if (!firstUserMsg) return; const userContent = firstUserMsg.displayContent || firstUserMsg.content; // Store current title to check if user renames during generation const expectedTitle = fullConv.title; // Set pending status before starting generation await plugin.updateConversation(conversationId, { titleGenerationStatus: 'pending' }); this.updateHistoryDropdown(); // Fire async AI title generation await titleService.generateTitle( conversationId, userContent, async (convId, result) => { // Check if conversation still exists and user hasn't manually renamed const currentConv = await plugin.getConversationById(convId); if (!currentConv) return; // Only apply AI title if user hasn't manually renamed (title still matches expected) const userManuallyRenamed = currentConv.title !== expectedTitle; if (result.success && !userManuallyRenamed) { await plugin.renameConversation(convId, result.title); await plugin.updateConversation(convId, { titleGenerationStatus: 'success' }); } else if (!userManuallyRenamed) { // Keep existing title, mark as failed (only if user hasn't renamed) await plugin.updateConversation(convId, { titleGenerationStatus: 'failed' }); } else { // User manually renamed, clear the status (user's choice takes precedence) await plugin.updateConversation(convId, { titleGenerationStatus: undefined }); } this.updateHistoryDropdown(); } ); } /** Formats a timestamp for display. */ formatDate(timestamp: number): string { const date = new Date(timestamp); const now = new Date(); if (date.toDateString() === now.toDateString()) { return date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', hour12: false }); } return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); } // ============================================ // History Dropdown Rendering (for ClaudianView) // ============================================ /** * Renders the history dropdown content to a provided container. * Used by ClaudianView to render the dropdown with custom selection callback. */ renderHistoryDropdown( container: HTMLElement, options: { onSelectConversation: (id: string) => Promise } ): void { this.renderHistoryItems(container, { onSelectConversation: options.onSelectConversation, onRerender: () => this.renderHistoryDropdown(container, options), }); } } ================================================ FILE: src/features/chat/controllers/InputController.ts ================================================ import { Notice } from 'obsidian'; import type { ApprovalCallbackOptions, ClaudianService } from '../../../core/agent'; import { detectBuiltInCommand } from '../../../core/commands'; import { TOOL_EXIT_PLAN_MODE } from '../../../core/tools/toolNames'; import type { ApprovalDecision, ChatMessage, ExitPlanModeDecision } from '../../../core/types'; import type ClaudianPlugin from '../../../main'; import { ResumeSessionDropdown } from '../../../shared/components/ResumeSessionDropdown'; import { InstructionModal } from '../../../shared/modals/InstructionConfirmModal'; import { appendBrowserContext, type BrowserSelectionContext } from '../../../utils/browser'; import { appendCanvasContext, type CanvasSelectionContext } from '../../../utils/canvas'; import { appendCurrentNote } from '../../../utils/context'; import { formatDurationMmSs } from '../../../utils/date'; import { appendEditorContext, type EditorSelectionContext } from '../../../utils/editor'; import { appendMarkdownSnippet } from '../../../utils/markdown'; import { COMPLETION_FLAVOR_WORDS } from '../constants'; import { type InlineAskQuestionConfig, InlineAskUserQuestion } from '../rendering/InlineAskUserQuestion'; import { InlineExitPlanMode } from '../rendering/InlineExitPlanMode'; import type { MessageRenderer } from '../rendering/MessageRenderer'; import { setToolIcon, updateToolCallResult } from '../rendering/ToolCallRenderer'; import type { InstructionRefineService } from '../services/InstructionRefineService'; import type { SubagentManager } from '../services/SubagentManager'; import type { TitleGenerationService } from '../services/TitleGenerationService'; import type { ChatState } from '../state/ChatState'; import type { QueryOptions } from '../state/types'; import type { AddExternalContextResult, FileContextManager, ImageContextManager, InstructionModeManager, McpServerSelector, StatusPanel } from '../ui'; import type { BrowserSelectionController } from './BrowserSelectionController'; import type { CanvasSelectionController } from './CanvasSelectionController'; import type { ConversationController } from './ConversationController'; import type { SelectionController } from './SelectionController'; import type { StreamController } from './StreamController'; const APPROVAL_OPTION_MAP: Record = { 'Deny': 'deny', 'Allow once': 'allow', 'Always allow': 'allow-always', }; export interface InputControllerDeps { plugin: ClaudianPlugin; state: ChatState; renderer: MessageRenderer; streamController: StreamController; selectionController: SelectionController; browserSelectionController?: BrowserSelectionController; canvasSelectionController: CanvasSelectionController; conversationController: ConversationController; getInputEl: () => HTMLTextAreaElement; getWelcomeEl: () => HTMLElement | null; getMessagesEl: () => HTMLElement; getFileContextManager: () => FileContextManager | null; getImageContextManager: () => ImageContextManager | null; getMcpServerSelector: () => McpServerSelector | null; getExternalContextSelector: () => { getExternalContexts: () => string[]; addExternalContext: (path: string) => AddExternalContextResult; } | null; getInstructionModeManager: () => InstructionModeManager | null; getInstructionRefineService: () => InstructionRefineService | null; getTitleGenerationService: () => TitleGenerationService | null; getStatusPanel: () => StatusPanel | null; getInputContainerEl: () => HTMLElement; generateId: () => string; resetInputHeight: () => void; getAgentService?: () => ClaudianService | null; getSubagentManager: () => SubagentManager; /** Returns true if ready. */ ensureServiceInitialized?: () => Promise; openConversation?: (conversationId: string) => Promise; onForkAll?: () => Promise; } export class InputController { private deps: InputControllerDeps; private pendingApprovalInline: InlineAskUserQuestion | null = null; private pendingAskInline: InlineAskUserQuestion | null = null; private pendingExitPlanModeInline: InlineExitPlanMode | null = null; private activeResumeDropdown: ResumeSessionDropdown | null = null; private inputContainerHideDepth = 0; constructor(deps: InputControllerDeps) { this.deps = deps; } private getAgentService(): ClaudianService | null { return this.deps.getAgentService?.() ?? null; } private isResumeSessionAtStillNeeded(resumeUuid: string, previousMessages: ChatMessage[]): boolean { for (let i = previousMessages.length - 1; i >= 0; i--) { if (previousMessages[i].role === 'assistant' && previousMessages[i].sdkAssistantUuid === resumeUuid) { // Still needed only if no messages follow the resume point return i === previousMessages.length - 1; } } return false; } // ============================================ // Message Sending // ============================================ async sendMessage(options?: { editorContextOverride?: EditorSelectionContext | null; browserContextOverride?: BrowserSelectionContext | null; canvasContextOverride?: CanvasSelectionContext | null; content?: string; }): Promise { const { plugin, state, renderer, streamController, selectionController, browserSelectionController, canvasSelectionController, conversationController } = this.deps; // During conversation creation/switching, don't send - input is preserved so user can retry if (state.isCreatingConversation || state.isSwitchingConversation) return; const inputEl = this.deps.getInputEl(); const imageContextManager = this.deps.getImageContextManager(); const fileContextManager = this.deps.getFileContextManager(); const mcpServerSelector = this.deps.getMcpServerSelector(); const externalContextSelector = this.deps.getExternalContextSelector(); const contentOverride = options?.content; const shouldUseInput = contentOverride === undefined; const content = (contentOverride ?? inputEl.value).trim(); const hasImages = imageContextManager?.hasImages() ?? false; if (!content && !hasImages) return; // Check for built-in commands first (e.g., /clear, /new, /add-dir) const builtInCmd = detectBuiltInCommand(content); if (builtInCmd) { if (shouldUseInput) { inputEl.value = ''; this.deps.resetInputHeight(); } await this.executeBuiltInCommand(builtInCmd.command.action, builtInCmd.args); return; } // If agent is working, queue the message instead of dropping it if (state.isStreaming) { const images = hasImages ? [...(imageContextManager?.getAttachedImages() || [])] : undefined; const editorContext = selectionController.getContext(); const browserContext = browserSelectionController?.getContext() ?? null; const canvasContext = canvasSelectionController.getContext(); // Append to existing queued message if any if (state.queuedMessage) { state.queuedMessage.content += '\n\n' + content; if (images && images.length > 0) { state.queuedMessage.images = [...(state.queuedMessage.images || []), ...images]; } state.queuedMessage.editorContext = editorContext; state.queuedMessage.browserContext = browserContext; state.queuedMessage.canvasContext = canvasContext; } else { state.queuedMessage = { content, images, editorContext, browserContext, canvasContext, }; } if (shouldUseInput) { inputEl.value = ''; this.deps.resetInputHeight(); } imageContextManager?.clearImages(); this.updateQueueIndicator(); return; } if (shouldUseInput) { inputEl.value = ''; this.deps.resetInputHeight(); } state.isStreaming = true; state.cancelRequested = false; state.ignoreUsageUpdates = false; // Allow usage updates for new query this.deps.getSubagentManager().resetSpawnedCount(); state.autoScrollEnabled = plugin.settings.enableAutoScroll ?? true; // Reset auto-scroll based on setting const streamGeneration = state.bumpStreamGeneration(); // Hide welcome message when sending first message const welcomeEl = this.deps.getWelcomeEl(); if (welcomeEl) { welcomeEl.style.display = 'none'; } fileContextManager?.startSession(); // Slash commands are passed directly to SDK for handling // SDK handles expansion, $ARGUMENTS, @file references, and frontmatter options const displayContent = content; let queryOptions: QueryOptions | undefined; const images = imageContextManager?.getAttachedImages() || []; const imagesForMessage = images.length > 0 ? [...images] : undefined; // Only clear images if we consumed user input (not for programmatic content override) if (shouldUseInput) { imageContextManager?.clearImages(); } const currentNotePath = fileContextManager?.getCurrentNotePath() || null; const shouldSendCurrentNote = fileContextManager?.shouldSendCurrentNote(currentNotePath) ?? false; const editorContextOverride = options?.editorContextOverride; const editorContext = editorContextOverride !== undefined ? editorContextOverride : selectionController.getContext(); const browserContextOverride = options?.browserContextOverride; const browserContext = browserContextOverride !== undefined ? browserContextOverride : (browserSelectionController?.getContext() ?? null); const externalContextPaths = externalContextSelector?.getExternalContexts(); const isCompact = /^\/compact(\s|$)/i.test(content); // User content first, context XML appended after (enables slash command detection) let promptToSend = content; let currentNoteForMessage: string | undefined; // SDK built-in commands (e.g., /compact) must be sent bare — context XML breaks detection if (!isCompact) { // Append current note context if available if (shouldSendCurrentNote && currentNotePath) { promptToSend = appendCurrentNote(promptToSend, currentNotePath); currentNoteForMessage = currentNotePath; } // Append editor context if available if (editorContext) { promptToSend = appendEditorContext(promptToSend, editorContext); } // Append browser selection context if available if (browserContext) { promptToSend = appendBrowserContext(promptToSend, browserContext); } // Append canvas selection context if available const canvasContextOverride = options?.canvasContextOverride; const canvasContext = canvasContextOverride !== undefined ? canvasContextOverride : canvasSelectionController.getContext(); if (canvasContext) { promptToSend = appendCanvasContext(promptToSend, canvasContext); } // Transform context file mentions (e.g., @folder/file.ts) to absolute paths if (fileContextManager) { promptToSend = fileContextManager.transformContextMentions(promptToSend); } } fileContextManager?.markCurrentNoteSent(); const userMsg: ChatMessage = { id: this.deps.generateId(), role: 'user', content: promptToSend, // Full prompt with XML context (for history rebuild) displayContent, // Original user input (for UI display) timestamp: Date.now(), currentNote: currentNoteForMessage, images: imagesForMessage, }; state.addMessage(userMsg); renderer.addMessage(userMsg); await this.triggerTitleGeneration(); const assistantMsg: ChatMessage = { id: this.deps.generateId(), role: 'assistant', content: '', timestamp: Date.now(), toolCalls: [], contentBlocks: [], }; state.addMessage(assistantMsg); const msgEl = renderer.addMessage(assistantMsg); const contentEl = msgEl.querySelector('.claudian-message-content') as HTMLElement; state.toolCallElements.clear(); state.currentContentEl = contentEl; state.currentTextEl = null; state.currentTextContent = ''; streamController.showThinkingIndicator( isCompact ? 'Compacting...' : undefined, isCompact ? 'claudian-thinking--compact' : undefined, ); state.responseStartTime = performance.now(); // Extract @-mentioned MCP servers from prompt const mcpMentions = plugin.mcpManager.extractMentions(promptToSend); // Transform @mcpname to @mcpname MCP in API request only promptToSend = plugin.mcpManager.transformMentions(promptToSend); // Add MCP options to query const enabledMcpServers = mcpServerSelector?.getEnabledServers(); if (mcpMentions.size > 0 || (enabledMcpServers && enabledMcpServers.size > 0)) { queryOptions = { ...queryOptions, mcpMentions, enabledMcpServers, }; } // Add external context paths to query if (externalContextPaths && externalContextPaths.length > 0) { queryOptions = { ...queryOptions, externalContextPaths, }; } let wasInterrupted = false; let wasInvalidated = false; let didEnqueueToSdk = false; // Lazy initialization: ensure service is ready before first query if (this.deps.ensureServiceInitialized) { const ready = await this.deps.ensureServiceInitialized(); if (!ready) { new Notice('Failed to initialize agent service. Please try again.'); streamController.hideThinkingIndicator(); state.isStreaming = false; return; } } const agentService = this.getAgentService(); if (!agentService) { new Notice('Agent service not available. Please reload the plugin.'); return; } // Restore pendingResumeAt from persisted conversation state (survives plugin reload) const conversationIdForSend = state.currentConversationId; if (conversationIdForSend) { const conv = plugin.getConversationSync(conversationIdForSend); if (conv?.resumeSessionAt) { if (this.isResumeSessionAtStillNeeded(conv.resumeSessionAt, state.messages.slice(0, -2))) { agentService.setPendingResumeAt(conv.resumeSessionAt); } else { try { await plugin.updateConversation(conversationIdForSend, { resumeSessionAt: undefined }); } catch { // Best-effort — don't block send } } } } try { // Pass history WITHOUT current turn (userMsg + assistantMsg we just added) // This prevents duplication when rebuilding context for new sessions const previousMessages = state.messages.slice(0, -2); for await (const chunk of agentService.query(promptToSend, imagesForMessage, previousMessages, queryOptions)) { if (chunk.type === 'sdk_user_uuid') { userMsg.sdkUserUuid = chunk.uuid; continue; } if (chunk.type === 'sdk_user_sent') { didEnqueueToSdk = true; continue; } if (state.streamGeneration !== streamGeneration) { wasInvalidated = true; break; } if (state.cancelRequested) { wasInterrupted = true; break; } await streamController.handleStreamChunk(chunk, assistantMsg); } } catch (error) { const errorMsg = error instanceof Error ? error.message : 'Unknown error'; await streamController.appendText(`\n\n**Error:** ${errorMsg}`); } finally { // ALWAYS clear the timer interval, even on stream invalidation (prevents memory leaks) state.clearFlavorTimerInterval(); // Skip remaining cleanup if stream was invalidated (tab closed or conversation switched) if (!wasInvalidated && state.streamGeneration === streamGeneration) { const didCancelThisTurn = wasInterrupted || state.cancelRequested; if (didCancelThisTurn && !state.pendingNewSessionPlan) { await streamController.appendText('\n\nInterrupted · What should Claudian do instead?'); } streamController.hideThinkingIndicator(); state.isStreaming = false; state.cancelRequested = false; // Capture response duration before resetting state (skip for interrupted responses and compaction) const hasCompactBoundary = assistantMsg.contentBlocks?.some(b => b.type === 'compact_boundary'); if (!didCancelThisTurn && !hasCompactBoundary) { const durationSeconds = state.responseStartTime ? Math.floor((performance.now() - state.responseStartTime) / 1000) : 0; if (durationSeconds > 0) { const flavorWord = COMPLETION_FLAVOR_WORDS[Math.floor(Math.random() * COMPLETION_FLAVOR_WORDS.length)]; assistantMsg.durationSeconds = durationSeconds; assistantMsg.durationFlavorWord = flavorWord; // Add footer to live message in DOM if (contentEl) { const footerEl = contentEl.createDiv({ cls: 'claudian-response-footer' }); footerEl.createSpan({ text: `* ${flavorWord} for ${formatDurationMmSs(durationSeconds)}`, cls: 'claudian-baked-duration', }); } } } state.currentContentEl = null; streamController.finalizeCurrentThinkingBlock(assistantMsg); streamController.finalizeCurrentTextBlock(assistantMsg); this.deps.getSubagentManager().resetStreamingState(); // Auto-hide completed todo panel on response end // Panel reappears only when new TodoWrite tool is called if (state.currentTodos && state.currentTodos.every(t => t.status === 'completed')) { state.currentTodos = null; } this.syncScrollToBottomAfterRenderUpdates(); // approve-new-session: the tool_result chunk is dropped because cancelRequested // was set before the stream loop could process it — manually set the result so // the saved conversation renders correctly when revisited if (state.pendingNewSessionPlan && assistantMsg.toolCalls) { for (const tc of assistantMsg.toolCalls) { if (tc.name === TOOL_EXIT_PLAN_MODE && !tc.result) { tc.status = 'completed'; tc.result = 'User approved the plan and started a new session.'; updateToolCallResult(tc.id, tc, state.toolCallElements); } } } // Only clear resumeSessionAt if enqueue succeeded; preserve checkpoint on failure for retry const saveExtras = didEnqueueToSdk ? { resumeSessionAt: undefined } : undefined; await conversationController.save(true, saveExtras); const userMsgIndex = state.messages.indexOf(userMsg); renderer.refreshActionButtons(userMsg, state.messages, userMsgIndex >= 0 ? userMsgIndex : undefined); // approve-new-session: create fresh conversation and send plan content // Must be inside the invalidation guard — if the tab was closed or // conversation switched, we must not create a new session on stale state. const planContent = state.pendingNewSessionPlan; if (planContent) { state.pendingNewSessionPlan = null; await conversationController.createNew(); this.deps.getInputEl().value = planContent; this.sendMessage().catch(() => { // sendMessage() handles its own errors internally; this prevents // unhandled rejection if an unexpected error slips through. }); } else { this.processQueuedMessage(); } } } } // ============================================ // Queue Management // ============================================ updateQueueIndicator(): void { const { state } = this.deps; if (!state.queueIndicatorEl) return; if (state.queuedMessage) { const rawContent = state.queuedMessage.content.trim(); const preview = rawContent.length > 40 ? rawContent.slice(0, 40) + '...' : rawContent; const hasImages = (state.queuedMessage.images?.length ?? 0) > 0; let display = preview; if (hasImages) { display = display ? `${display} [images]` : '[images]'; } state.queueIndicatorEl.setText(`⌙ Queued: ${display}`); state.queueIndicatorEl.style.display = 'block'; } else { state.queueIndicatorEl.style.display = 'none'; } } clearQueuedMessage(): void { const { state } = this.deps; state.queuedMessage = null; this.updateQueueIndicator(); } private restoreQueuedMessageToInput(): void { const { state } = this.deps; if (!state.queuedMessage) return; const { content, images } = state.queuedMessage; state.queuedMessage = null; this.updateQueueIndicator(); const inputEl = this.deps.getInputEl(); inputEl.value = content; if (images && images.length > 0) { this.deps.getImageContextManager()?.setImages(images); } } private processQueuedMessage(): void { const { state } = this.deps; if (!state.queuedMessage) return; const { content, images, editorContext, browserContext, canvasContext } = state.queuedMessage; state.queuedMessage = null; this.updateQueueIndicator(); const inputEl = this.deps.getInputEl(); inputEl.value = content; if (images && images.length > 0) { this.deps.getImageContextManager()?.setImages(images); } setTimeout( () => this.sendMessage({ editorContextOverride: editorContext, browserContextOverride: browserContext ?? null, canvasContextOverride: canvasContext, }), 0 ); } // ============================================ // Title Generation // ============================================ /** * Triggers AI title generation after first user message. * Handles setting fallback title, firing async generation, and updating UI. */ private async triggerTitleGeneration(): Promise { const { plugin, state, conversationController } = this.deps; if (state.messages.length !== 1) { return; } if (!state.currentConversationId) { const sessionId = this.getAgentService()?.getSessionId() ?? undefined; const conversation = await plugin.createConversation(sessionId); state.currentConversationId = conversation.id; } // Find first user message by role (not by index) const firstUserMsg = state.messages.find(m => m.role === 'user'); if (!firstUserMsg) { return; } const userContent = firstUserMsg.displayContent || firstUserMsg.content; // Set immediate fallback title const fallbackTitle = conversationController.generateFallbackTitle(userContent); await plugin.renameConversation(state.currentConversationId, fallbackTitle); if (!plugin.settings.enableAutoTitleGeneration) { return; } // Fire async AI title generation only if service available const titleService = this.deps.getTitleGenerationService(); if (!titleService) { // No titleService, just keep the fallback title with no status return; } // Mark as pending only when we're actually starting generation await plugin.updateConversation(state.currentConversationId, { titleGenerationStatus: 'pending' }); conversationController.updateHistoryDropdown(); const convId = state.currentConversationId; const expectedTitle = fallbackTitle; // Store to check if user renamed during generation titleService.generateTitle( convId, userContent, async (conversationId, result) => { // Check if conversation still exists and user hasn't manually renamed const currentConv = await plugin.getConversationById(conversationId); if (!currentConv) return; // Only apply AI title if user hasn't manually renamed (title still matches fallback) const userManuallyRenamed = currentConv.title !== expectedTitle; if (result.success && !userManuallyRenamed) { await plugin.renameConversation(conversationId, result.title); await plugin.updateConversation(conversationId, { titleGenerationStatus: 'success' }); } else if (!userManuallyRenamed) { // Keep fallback title, mark as failed (only if user hasn't renamed) await plugin.updateConversation(conversationId, { titleGenerationStatus: 'failed' }); } else { // User manually renamed, clear the status (user's choice takes precedence) await plugin.updateConversation(conversationId, { titleGenerationStatus: undefined }); } conversationController.updateHistoryDropdown(); } ).catch(() => { // Silently ignore title generation errors }); } // ============================================ // Streaming Control // ============================================ cancelStreaming(): void { const { state, streamController } = this.deps; if (!state.isStreaming) return; state.cancelRequested = true; // Restore queued message to input instead of discarding this.restoreQueuedMessageToInput(); this.getAgentService()?.cancel(); streamController.hideThinkingIndicator(); } private syncScrollToBottomAfterRenderUpdates(): void { const { plugin, state } = this.deps; if (!(plugin.settings.enableAutoScroll ?? true)) return; if (!state.autoScrollEnabled) return; requestAnimationFrame(() => { if (!(this.deps.plugin.settings.enableAutoScroll ?? true)) return; if (!this.deps.state.autoScrollEnabled) return; const messagesEl = this.deps.getMessagesEl(); messagesEl.scrollTop = messagesEl.scrollHeight; }); } // ============================================ // Instruction Mode // ============================================ async handleInstructionSubmit(rawInstruction: string): Promise { const { plugin } = this.deps; const instructionRefineService = this.deps.getInstructionRefineService(); const instructionModeManager = this.deps.getInstructionModeManager(); if (!instructionRefineService) return; const existingPrompt = plugin.settings.systemPrompt; let modal: InstructionModal | null = null; let wasCancelled = false; try { modal = new InstructionModal( plugin.app, rawInstruction, { onAccept: async (finalInstruction) => { const currentPrompt = plugin.settings.systemPrompt; plugin.settings.systemPrompt = appendMarkdownSnippet(currentPrompt, finalInstruction); await plugin.saveSettings(); new Notice('Instruction added to custom system prompt'); instructionModeManager?.clear(); }, onReject: () => { wasCancelled = true; instructionRefineService.cancel(); instructionModeManager?.clear(); }, onClarificationSubmit: async (response) => { const result = await instructionRefineService.continueConversation(response); if (wasCancelled) { return; } if (!result.success) { if (result.error === 'Cancelled') { return; } new Notice(result.error || 'Failed to process response'); modal?.showError(result.error || 'Failed to process response'); return; } if (result.clarification) { modal?.showClarification(result.clarification); } else if (result.refinedInstruction) { modal?.showConfirmation(result.refinedInstruction); } } } ); modal.open(); instructionRefineService.resetConversation(); const result = await instructionRefineService.refineInstruction( rawInstruction, existingPrompt ); if (wasCancelled) { return; } if (!result.success) { if (result.error === 'Cancelled') { instructionModeManager?.clear(); return; } new Notice(result.error || 'Failed to refine instruction'); modal.showError(result.error || 'Failed to refine instruction'); instructionModeManager?.clear(); return; } if (result.clarification) { modal.showClarification(result.clarification); } else if (result.refinedInstruction) { modal.showConfirmation(result.refinedInstruction); } else { new Notice('No instruction received'); modal.showError('No instruction received'); instructionModeManager?.clear(); } } catch (error) { const errorMsg = error instanceof Error ? error.message : 'Unknown error'; new Notice(`Error: ${errorMsg}`); modal?.showError(errorMsg); instructionModeManager?.clear(); } } // ============================================ // Approval Dialogs // ============================================ async handleApprovalRequest( toolName: string, _input: Record, description: string, approvalOptions?: ApprovalCallbackOptions, ): Promise { const inputContainerEl = this.deps.getInputContainerEl(); const parentEl = inputContainerEl.parentElement; if (!parentEl) { throw new Error('Input container is detached from DOM'); } // Build header element, then detach — InlineAskUserQuestion will re-attach it const headerEl = parentEl.createDiv({ cls: 'claudian-ask-approval-info' }); headerEl.remove(); const toolEl = headerEl.createDiv({ cls: 'claudian-ask-approval-tool' }); const iconEl = toolEl.createSpan({ cls: 'claudian-ask-approval-icon' }); iconEl.setAttribute('aria-hidden', 'true'); setToolIcon(iconEl, toolName); toolEl.createSpan({ text: toolName, cls: 'claudian-ask-approval-tool-name' }); if (approvalOptions?.decisionReason) { headerEl.createDiv({ text: approvalOptions.decisionReason, cls: 'claudian-ask-approval-reason' }); } if (approvalOptions?.blockedPath) { headerEl.createDiv({ text: approvalOptions.blockedPath, cls: 'claudian-ask-approval-blocked-path' }); } if (approvalOptions?.agentID) { headerEl.createDiv({ text: `Agent: ${approvalOptions.agentID}`, cls: 'claudian-ask-approval-agent' }); } headerEl.createDiv({ text: description, cls: 'claudian-ask-approval-desc' }); // Always include "Always allow" — SDK callback has no toggle const questionOptions = Object.keys(APPROVAL_OPTION_MAP); const input = { questions: [{ question: 'Allow this action?', options: questionOptions }] }; const result = await this.showInlineQuestion( parentEl, inputContainerEl, input, (inline) => { this.pendingApprovalInline = inline; }, undefined, { title: 'Permission required', headerEl, showCustomInput: false, immediateSelect: true }, ); if (!result) return 'cancel'; const selected = Object.values(result)[0]; const decision = APPROVAL_OPTION_MAP[selected]; if (!decision) { new Notice(`Unexpected approval selection: "${selected}"`); return 'cancel'; } return decision; } async handleAskUserQuestion( input: Record, signal?: AbortSignal, ): Promise | null> { const inputContainerEl = this.deps.getInputContainerEl(); const parentEl = inputContainerEl.parentElement; if (!parentEl) { throw new Error('Input container is detached from DOM'); } return this.showInlineQuestion( parentEl, inputContainerEl, input, (inline) => { this.pendingAskInline = inline; }, signal, ); } private showInlineQuestion( parentEl: HTMLElement, inputContainerEl: HTMLElement, input: Record, setPending: (inline: InlineAskUserQuestion | null) => void, signal?: AbortSignal, config?: InlineAskQuestionConfig, ): Promise | null> { this.deps.streamController.hideThinkingIndicator(); this.hideInputContainer(inputContainerEl); return new Promise | null>((resolve, reject) => { const inline = new InlineAskUserQuestion( parentEl, input, (result: Record | null) => { setPending(null); this.restoreInputContainer(inputContainerEl); resolve(result); }, signal, config, ); setPending(inline); try { inline.render(); } catch (err) { setPending(null); this.restoreInputContainer(inputContainerEl); reject(err); } }); } async handleExitPlanMode( input: Record, signal?: AbortSignal, ): Promise { const { state, streamController } = this.deps; const inputContainerEl = this.deps.getInputContainerEl(); const parentEl = inputContainerEl.parentElement; if (!parentEl) { throw new Error('Input container is detached from DOM'); } streamController.hideThinkingIndicator(); this.hideInputContainer(inputContainerEl); const enrichedInput = state.planFilePath ? { ...input, planFilePath: state.planFilePath } : input; const renderContent = (el: HTMLElement, markdown: string) => this.deps.renderer.renderContent(el, markdown); return new Promise((resolve, reject) => { const inline = new InlineExitPlanMode( parentEl, enrichedInput, (decision: ExitPlanModeDecision | null) => { this.pendingExitPlanModeInline = null; this.restoreInputContainer(inputContainerEl); resolve(decision); }, signal, renderContent, ); this.pendingExitPlanModeInline = inline; try { inline.render(); } catch (err) { this.pendingExitPlanModeInline = null; this.restoreInputContainer(inputContainerEl); reject(err); } }); } dismissPendingApproval(): void { if (this.pendingApprovalInline) { this.pendingApprovalInline.destroy(); this.pendingApprovalInline = null; } if (this.pendingAskInline) { this.pendingAskInline.destroy(); this.pendingAskInline = null; } if (this.pendingExitPlanModeInline) { this.pendingExitPlanModeInline.destroy(); this.pendingExitPlanModeInline = null; } this.resetInputContainerVisibility(); } private hideInputContainer(inputContainerEl: HTMLElement): void { this.inputContainerHideDepth++; inputContainerEl.style.display = 'none'; } private restoreInputContainer(inputContainerEl: HTMLElement): void { if (this.inputContainerHideDepth <= 0) return; this.inputContainerHideDepth--; if (this.inputContainerHideDepth === 0) { inputContainerEl.style.display = ''; } } private resetInputContainerVisibility(): void { if (this.inputContainerHideDepth > 0) { this.inputContainerHideDepth = 0; this.deps.getInputContainerEl().style.display = ''; } } // ============================================ // Built-in Commands // ============================================ private async executeBuiltInCommand(action: string, args: string): Promise { const { conversationController } = this.deps; switch (action) { case 'clear': await conversationController.createNew(); break; case 'add-dir': { const externalContextSelector = this.deps.getExternalContextSelector(); if (!externalContextSelector) { new Notice('External context selector not available.'); return; } const result = externalContextSelector.addExternalContext(args); if (result.success) { new Notice(`Added external context: ${result.normalizedPath}`); } else { new Notice(result.error); } break; } case 'resume': this.showResumeDropdown(); break; case 'fork': { if (!this.deps.onForkAll) { new Notice('Fork not available.'); return; } await this.deps.onForkAll(); break; } default: // Unknown command - notify user new Notice(`Unknown command: ${action}`); } } // ============================================ // Resume Session Dropdown // ============================================ handleResumeKeydown(e: KeyboardEvent): boolean { if (!this.activeResumeDropdown?.isVisible()) return false; return this.activeResumeDropdown.handleKeydown(e); } isResumeDropdownVisible(): boolean { return this.activeResumeDropdown?.isVisible() ?? false; } destroyResumeDropdown(): void { if (this.activeResumeDropdown) { this.activeResumeDropdown.destroy(); this.activeResumeDropdown = null; } } private showResumeDropdown(): void { const { plugin, state, conversationController } = this.deps; // Clean up any existing dropdown this.destroyResumeDropdown(); const conversations = plugin.getConversationList(); if (conversations.length === 0) { new Notice('No conversations to resume'); return; } const openConversation = this.deps.openConversation ?? ((id: string) => conversationController.switchTo(id)); this.activeResumeDropdown = new ResumeSessionDropdown( this.deps.getInputContainerEl(), this.deps.getInputEl(), conversations, state.currentConversationId, { onSelect: (id) => { this.destroyResumeDropdown(); openConversation(id).catch((err: unknown) => { const msg = err instanceof Error ? err.message : String(err); new Notice(`Failed to open conversation: ${msg}`); }); }, onDismiss: () => { this.destroyResumeDropdown(); }, } ); } } ================================================ FILE: src/features/chat/controllers/NavigationController.ts ================================================ import type { KeyboardNavigationSettings } from '../../../core/types'; /** Scroll speed in pixels per frame (~60fps = 480px/sec). */ const SCROLL_SPEED = 8; export interface NavigationControllerDeps { getMessagesEl: () => HTMLElement; getInputEl: () => HTMLTextAreaElement; getSettings: () => KeyboardNavigationSettings; isStreaming: () => boolean; /** Returns true if a UI component (dropdown, modal, mode) should handle Escape instead. */ shouldSkipEscapeHandling?: () => boolean; } export class NavigationController { private deps: NavigationControllerDeps; private scrollDirection: 'up' | 'down' | null = null; private animationFrameId: number | null = null; private initialized = false; private disposed = false; // Bound handlers for cleanup private boundMessagesKeydown: (e: KeyboardEvent) => void; private boundKeyup: (e: KeyboardEvent) => void; private boundInputKeydown: (e: KeyboardEvent) => void; constructor(deps: NavigationControllerDeps) { this.deps = deps; this.boundMessagesKeydown = this.handleMessagesKeydown.bind(this); this.boundKeyup = this.handleKeyup.bind(this); this.boundInputKeydown = this.handleInputKeydown.bind(this); } initialize(): void { if (this.initialized || this.disposed) return; const messagesEl = this.deps.getMessagesEl(); const inputEl = this.deps.getInputEl(); // Guard against missing DOM elements if (!messagesEl || !inputEl) return; // Make messages panel focusable (focus style handled in CSS) messagesEl.setAttribute('tabindex', '0'); messagesEl.addClass('claudian-messages-focusable'); // Attach event listeners messagesEl.addEventListener('keydown', this.boundMessagesKeydown); document.addEventListener('keyup', this.boundKeyup); // Use capture phase to run before other handlers inputEl.addEventListener('keydown', this.boundInputKeydown, { capture: true }); this.initialized = true; } /** Cleans up event listeners and animation frames. */ dispose(): void { if (this.disposed) return; this.disposed = true; this.stopScrolling(); // Always clean up document listener first (most important for preventing leaks) document.removeEventListener('keyup', this.boundKeyup); // Element cleanup - may already be destroyed during view teardown const messagesEl = this.deps.getMessagesEl(); messagesEl?.removeEventListener('keydown', this.boundMessagesKeydown); messagesEl?.removeClass('claudian-messages-focusable'); const inputEl = this.deps.getInputEl(); inputEl?.removeEventListener('keydown', this.boundInputKeydown, { capture: true }); } // ============================================ // Messages Panel Keyboard Handling // ============================================ private handleMessagesKeydown(e: KeyboardEvent): void { // Ignore if any modifier is held - allow system shortcuts (Ctrl+W, Cmd+W, etc.) if (e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) return; const settings = this.deps.getSettings(); const key = e.key.toLowerCase(); // Scroll up if (key === settings.scrollUpKey.toLowerCase()) { e.preventDefault(); this.startScrolling('up'); return; } // Scroll down if (key === settings.scrollDownKey.toLowerCase()) { e.preventDefault(); this.startScrolling('down'); return; } // Focus input (vim 'i' for insert mode) if (key === settings.focusInputKey.toLowerCase()) { e.preventDefault(); this.deps.getInputEl().focus(); return; } } private handleKeyup(e: KeyboardEvent): void { const settings = this.deps.getSettings(); const key = e.key.toLowerCase(); // Stop scrolling when scroll key is released if ( key === settings.scrollUpKey.toLowerCase() || key === settings.scrollDownKey.toLowerCase() ) { this.stopScrolling(); } } // ============================================ // Input Keyboard Handling (Escape) // ============================================ private handleInputKeydown(e: KeyboardEvent): void { if (e.key !== 'Escape') return; // Ignore if composing (IME support for Chinese, Japanese, Korean, etc.) if (e.isComposing) return; // If streaming, let existing handler interrupt (don't interfere) if (this.deps.isStreaming()) { return; } if (this.deps.shouldSkipEscapeHandling?.()) { return; } // Not streaming, no active UI: blur input and focus messages panel e.preventDefault(); e.stopPropagation(); this.deps.getInputEl().blur(); this.deps.getMessagesEl().focus(); } // ============================================ // Continuous Scrolling with requestAnimationFrame // ============================================ private startScrolling(direction: 'up' | 'down'): void { if (this.scrollDirection === direction) { return; // Already scrolling in this direction } this.scrollDirection = direction; this.scrollLoop(); } private stopScrolling(): void { this.scrollDirection = null; if (this.animationFrameId !== null) { cancelAnimationFrame(this.animationFrameId); this.animationFrameId = null; } } private scrollLoop = (): void => { if (this.scrollDirection === null || this.disposed) return; const messagesEl = this.deps.getMessagesEl(); if (!messagesEl) { // Element was destroyed - stop scrolling silently (expected on cleanup) this.stopScrolling(); return; } const scrollAmount = this.scrollDirection === 'up' ? -SCROLL_SPEED : SCROLL_SPEED; messagesEl.scrollTop += scrollAmount; this.animationFrameId = requestAnimationFrame(this.scrollLoop); }; // ============================================ // Public API // ============================================ /** Focuses the messages panel. */ focusMessages(): void { this.deps.getMessagesEl().focus(); } /** Focuses the input. */ focusInput(): void { this.deps.getInputEl().focus(); } } ================================================ FILE: src/features/chat/controllers/SelectionController.ts ================================================ import type { App } from 'obsidian'; import { MarkdownView } from 'obsidian'; import { hideSelectionHighlight, showSelectionHighlight } from '../../../shared/components/SelectionHighlight'; import { type EditorSelectionContext, getEditorView } from '../../../utils/editor'; import type { StoredSelection } from '../state/types'; import { updateContextRowHasContent } from './contextRowVisibility'; /** Polling interval for editor selection (ms). */ const SELECTION_POLL_INTERVAL = 250; /** Grace period for editor blur when handing focus to chat input (ms). */ const INPUT_HANDOFF_GRACE_MS = 1500; export class SelectionController { private app: App; private indicatorEl: HTMLElement; private inputEl: HTMLElement; private contextRowEl: HTMLElement; private onVisibilityChange: (() => void) | null; private storedSelection: StoredSelection | null = null; private inputHandoffGraceUntil: number | null = null; private pollInterval: ReturnType | null = null; private readonly inputPointerDownHandler = () => { if (!this.storedSelection) return; this.inputHandoffGraceUntil = Date.now() + INPUT_HANDOFF_GRACE_MS; }; constructor( app: App, indicatorEl: HTMLElement, inputEl: HTMLElement, contextRowEl: HTMLElement, onVisibilityChange?: () => void ) { this.app = app; this.indicatorEl = indicatorEl; this.inputEl = inputEl; this.contextRowEl = contextRowEl; this.onVisibilityChange = onVisibilityChange ?? null; } start(): void { if (this.pollInterval) return; this.inputEl.addEventListener('pointerdown', this.inputPointerDownHandler); this.pollInterval = setInterval(() => this.poll(), SELECTION_POLL_INTERVAL); } stop(): void { if (this.pollInterval) { clearInterval(this.pollInterval); this.pollInterval = null; } this.inputEl.removeEventListener('pointerdown', this.inputPointerDownHandler); this.clear(); } dispose(): void { this.stop(); } // ============================================ // Selection Polling // ============================================ private poll(): void { const view = this.app.workspace.getActiveViewOfType(MarkdownView); if (!view) { this.clearWhenMarkdownIsNotActive(); return; } // Reading/preview mode has no usable CM6 selection — use DOM selection instead if (view.getMode() === 'preview') { this.pollReadingMode(view); return; } const editor = view.editor; const editorView = getEditorView(editor); if (!editorView) { this.clearWhenMarkdownIsNotActive(); return; } const selectedText = editor.getSelection(); if (selectedText.trim()) { this.inputHandoffGraceUntil = null; const fromPos = editor.getCursor('from'); const toPos = editor.getCursor('to'); const from = editor.posToOffset(fromPos); const to = editor.posToOffset(toPos); const startLine = fromPos.line + 1; // 1-indexed for display const notePath = view.file?.path || 'unknown'; const lineCount = selectedText.split(/\r?\n/).length; const s = this.storedSelection; const sameRange = s && s.editorView === editorView && s.from === from && s.to === to && s.notePath === notePath; const unchanged = sameRange && s.selectedText === selectedText && s.lineCount === lineCount && s.startLine === startLine; if (!unchanged) { if (s && !sameRange) { this.clearHighlight(); } this.storedSelection = { notePath, selectedText, lineCount, startLine, from, to, editorView }; this.updateIndicator(); } } else { this.handleDeselection(); } } private pollReadingMode(view: MarkdownView): void { const containerEl = view.containerEl; if (!containerEl) { this.clearWhenMarkdownIsNotActive(); return; } const selection = document.getSelection(); const selectedText = selection?.toString() ?? ''; if (selectedText.trim()) { const anchorNode = selection?.anchorNode; const focusNode = selection?.focusNode; if ( (!anchorNode || !containerEl.contains(anchorNode)) && (!focusNode || !containerEl.contains(focusNode)) ) { this.handleDeselection(); return; } this.inputHandoffGraceUntil = null; const notePath = view.file?.path || 'unknown'; const lineCount = selectedText.split(/\r?\n/).length; const unchanged = this.storedSelection && this.storedSelection.editorView === undefined && this.storedSelection.notePath === notePath && this.storedSelection.selectedText === selectedText && this.storedSelection.lineCount === lineCount; if (!unchanged) { this.clearHighlight(); this.storedSelection = { notePath, selectedText, lineCount }; this.updateIndicator(); } } else { this.handleDeselection(); } } private handleDeselection(): void { if (!this.storedSelection) return; if (document.activeElement === this.inputEl) { this.inputHandoffGraceUntil = null; return; } if (this.inputHandoffGraceUntil !== null && Date.now() <= this.inputHandoffGraceUntil) { return; } this.inputHandoffGraceUntil = null; this.clearHighlight(); this.storedSelection = null; this.updateIndicator(); } private clearWhenMarkdownIsNotActive(): void { if (!this.storedSelection) return; if (document.activeElement === this.inputEl) return; this.inputHandoffGraceUntil = null; this.clearHighlight(); this.storedSelection = null; this.updateIndicator(); } // ============================================ // Highlight Management // ============================================ showHighlight(): void { const sel = this.storedSelection; if (!sel?.editorView || sel.from === undefined || sel.to === undefined) return; showSelectionHighlight(sel.editorView, sel.from, sel.to); } private clearHighlight(): void { if (!this.storedSelection?.editorView) return; hideSelectionHighlight(this.storedSelection.editorView); } // ============================================ // Indicator // ============================================ private updateIndicator(): void { if (!this.indicatorEl) return; if (this.storedSelection) { const lineText = this.storedSelection.lineCount === 1 ? 'line' : 'lines'; this.indicatorEl.textContent = `${this.storedSelection.lineCount} ${lineText} selected`; this.indicatorEl.style.display = 'block'; } else { this.indicatorEl.style.display = 'none'; } this.updateContextRowVisibility(); } updateContextRowVisibility(): void { if (!this.contextRowEl) return; updateContextRowHasContent(this.contextRowEl); this.onVisibilityChange?.(); } // ============================================ // Context Access // ============================================ getContext(): EditorSelectionContext | null { if (!this.storedSelection) return null; return { notePath: this.storedSelection.notePath, mode: 'selection', selectedText: this.storedSelection.selectedText, lineCount: this.storedSelection.lineCount, ...(this.storedSelection.startLine !== undefined && { startLine: this.storedSelection.startLine }), }; } hasSelection(): boolean { return this.storedSelection !== null; } // ============================================ // Clear // ============================================ clear(): void { this.inputHandoffGraceUntil = null; this.clearHighlight(); this.storedSelection = null; this.updateIndicator(); } } ================================================ FILE: src/features/chat/controllers/StreamController.ts ================================================ import { TFile } from 'obsidian'; import type { ClaudianService } from '../../../core/agent'; import { extractResolvedAnswers, extractResolvedAnswersFromResultText, parseTodoInput } from '../../../core/tools'; import { isEditTool, isSubagentToolName, isWriteEditTool, skipsBlockedDetection, TOOL_AGENT_OUTPUT, TOOL_ASK_USER_QUESTION, TOOL_TASK, TOOL_TODO_WRITE, TOOL_WRITE, } from '../../../core/tools/toolNames'; import type { ChatMessage, StreamChunk, SubagentInfo, ToolCallInfo } from '../../../core/types'; import type { SDKToolUseResult } from '../../../core/types/diff'; import type ClaudianPlugin from '../../../main'; import { formatDurationMmSs } from '../../../utils/date'; import { extractDiffData } from '../../../utils/diff'; import { getVaultPath, normalizePathForVault } from '../../../utils/path'; import { loadSubagentFinalResult, loadSubagentToolCalls } from '../../../utils/sdkSession'; import { FLAVOR_TEXTS } from '../constants'; import { appendThinkingContent, createThinkingBlock, createWriteEditBlock, finalizeThinkingBlock, finalizeWriteEditBlock, getToolName, getToolSummary, isBlockedToolResult, renderToolCall, updateToolCallResult, updateWriteEditWithDiff, } from '../rendering'; import type { MessageRenderer } from '../rendering/MessageRenderer'; import type { SubagentManager } from '../services/SubagentManager'; import type { ChatState } from '../state/ChatState'; import type { FileContextManager } from '../ui'; export interface StreamControllerDeps { plugin: ClaudianPlugin; state: ChatState; renderer: MessageRenderer; subagentManager: SubagentManager; getMessagesEl: () => HTMLElement; getFileContextManager: () => FileContextManager | null; updateQueueIndicator: () => void; /** Get the agent service from the tab. */ getAgentService?: () => ClaudianService | null; } export class StreamController { private static readonly ASYNC_SUBAGENT_RESULT_RETRY_DELAYS_MS = [200, 600, 1500] as const; private deps: StreamControllerDeps; constructor(deps: StreamControllerDeps) { this.deps = deps; } // ============================================ // Stream Chunk Handling // ============================================ async handleStreamChunk(chunk: StreamChunk, msg: ChatMessage): Promise { const { state } = this.deps; // Route subagent chunks if ('parentToolUseId' in chunk && chunk.parentToolUseId) { await this.handleSubagentChunk(chunk, msg); this.scrollToBottom(); return; } switch (chunk.type) { case 'thinking': // Flush pending tools before rendering new content type this.flushPendingTools(); if (state.currentTextEl) { this.finalizeCurrentTextBlock(msg); } await this.appendThinking(chunk.content); break; case 'text': // Flush pending tools before rendering new content type this.flushPendingTools(); if (state.currentThinkingState) { this.finalizeCurrentThinkingBlock(msg); } msg.content += chunk.content; await this.appendText(chunk.content); break; case 'tool_use': { if (state.currentThinkingState) { this.finalizeCurrentThinkingBlock(msg); } this.finalizeCurrentTextBlock(msg); if (isSubagentToolName(chunk.name)) { // Flush pending tools before Agent this.flushPendingTools(); this.handleTaskToolUseViaManager(chunk, msg); break; } if (chunk.name === TOOL_AGENT_OUTPUT) { this.handleAgentOutputToolUse(chunk, msg); break; } this.handleRegularToolUse(chunk, msg); break; } case 'tool_result': { await this.handleToolResult(chunk, msg); break; } case 'blocked': // Flush pending tools before rendering blocked message this.flushPendingTools(); await this.appendText(`\n\n⚠️ **Blocked:** ${chunk.content}`); break; case 'error': // Flush pending tools before rendering error message this.flushPendingTools(); await this.appendText(`\n\n❌ **Error:** ${chunk.content}`); break; case 'done': // Flush any remaining pending tools this.flushPendingTools(); break; case 'compact_boundary': { this.flushPendingTools(); if (state.currentThinkingState) { this.finalizeCurrentThinkingBlock(msg); } this.finalizeCurrentTextBlock(msg); msg.contentBlocks = msg.contentBlocks || []; msg.contentBlocks.push({ type: 'compact_boundary' }); this.renderCompactBoundary(); break; } case 'sdk_assistant_uuid': msg.sdkAssistantUuid = chunk.uuid; break; case 'sdk_user_uuid': case 'sdk_user_sent': break; case 'usage': { // Skip usage updates from other sessions or when flagged (during session reset) const currentSessionId = this.deps.getAgentService?.()?.getSessionId() ?? null; const chunkSessionId = chunk.sessionId ?? null; if ( (chunkSessionId && currentSessionId && chunkSessionId !== currentSessionId) || (chunkSessionId && !currentSessionId) ) { break; } // Skip usage updates when subagents ran (SDK reports cumulative usage including subagents) if (this.deps.subagentManager.subagentsSpawnedThisStream > 0) { break; } if (!state.ignoreUsageUpdates) { state.usage = chunk.usage; } break; } case 'context_window_update': { // Authoritative context window from SDK result — override heuristic value if (state.usage && chunk.contextWindow > 0) { const contextWindow = chunk.contextWindow; const percentage = Math.min(100, Math.max(0, Math.round((state.usage.contextTokens / contextWindow) * 100))); state.usage = { ...state.usage, contextWindow, percentage }; } break; } } this.scrollToBottom(); } // ============================================ // Tool Use Handling // ============================================ /** * Handles regular tool_use chunks by buffering them. * Tools are rendered when flushPendingTools is called (on next content type or tool_result). */ private handleRegularToolUse( chunk: { type: 'tool_use'; id: string; name: string; input: Record }, msg: ChatMessage ): void { const { state } = this.deps; // Check if this is an update to an existing tool call const existingToolCall = msg.toolCalls?.find(tc => tc.id === chunk.id); if (existingToolCall) { const newInput = chunk.input || {}; if (Object.keys(newInput).length > 0) { existingToolCall.input = { ...existingToolCall.input, ...newInput }; // Re-parse TodoWrite on input updates (streaming may complete the input) if (existingToolCall.name === TOOL_TODO_WRITE) { const todos = parseTodoInput(existingToolCall.input); if (todos) { this.deps.state.currentTodos = todos; } } // Capture plan file path on input updates (file_path may arrive in a later chunk) if (existingToolCall.name === TOOL_WRITE) { this.capturePlanFilePath(existingToolCall.input); } // If already rendered, update the header name + summary const toolEl = state.toolCallElements.get(chunk.id); if (toolEl) { const nameEl = toolEl.querySelector('.claudian-tool-name') as HTMLElement | null ?? toolEl.querySelector('.claudian-write-edit-name') as HTMLElement | null; if (nameEl) { nameEl.setText(getToolName(existingToolCall.name, existingToolCall.input)); } const summaryEl = toolEl.querySelector('.claudian-tool-summary') as HTMLElement | null ?? toolEl.querySelector('.claudian-write-edit-summary') as HTMLElement | null; if (summaryEl) { summaryEl.setText(getToolSummary(existingToolCall.name, existingToolCall.input)); } } // If still pending, the updated input is already in the toolCall object } return; } // Create new tool call const toolCall: ToolCallInfo = { id: chunk.id, name: chunk.name, input: chunk.input, status: 'running', isExpanded: false, }; msg.toolCalls = msg.toolCalls || []; msg.toolCalls.push(toolCall); // Add to contentBlocks for ordering msg.contentBlocks = msg.contentBlocks || []; msg.contentBlocks.push({ type: 'tool_use', toolId: chunk.id }); // TodoWrite: update panel state immediately (side effect), but still buffer render if (chunk.name === TOOL_TODO_WRITE) { const todos = parseTodoInput(chunk.input); if (todos) { this.deps.state.currentTodos = todos; } } // Track Write to ~/.claude/plans/ for plan mode (used by approve-new-session) if (chunk.name === TOOL_WRITE) { this.capturePlanFilePath(chunk.input); } // Buffer the tool call instead of rendering immediately if (state.currentContentEl) { state.pendingTools.set(chunk.id, { toolCall, parentEl: state.currentContentEl, }); this.showThinkingIndicator(); } } private capturePlanFilePath(input: Record): void { const filePath = input.file_path as string | undefined; if (filePath && filePath.replace(/\\/g, '/').includes('/.claude/plans/')) { this.deps.state.planFilePath = filePath; } } /** * Flushes all pending tool calls by rendering them. * Called when a different content type arrives or stream ends. */ private flushPendingTools(): void { const { state } = this.deps; if (state.pendingTools.size === 0) { return; } // Render pending tools in order (Map preserves insertion order) for (const toolId of state.pendingTools.keys()) { this.renderPendingTool(toolId); } state.pendingTools.clear(); } /** * Renders a single pending tool call and moves it from pending to rendered state. */ private renderPendingTool(toolId: string): void { const { state } = this.deps; const pending = state.pendingTools.get(toolId); if (!pending) return; const { toolCall, parentEl } = pending; if (!parentEl) return; if (isWriteEditTool(toolCall.name)) { const writeEditState = createWriteEditBlock(parentEl, toolCall); state.writeEditStates.set(toolId, writeEditState); state.toolCallElements.set(toolId, writeEditState.wrapperEl); } else { renderToolCall(parentEl, toolCall, state.toolCallElements); } state.pendingTools.delete(toolId); } private async handleToolResult( chunk: { type: 'tool_result'; id: string; content: string; isError?: boolean; toolUseResult?: SDKToolUseResult }, msg: ChatMessage ): Promise { const { state, subagentManager } = this.deps; // Resolve pending Task before processing result. if (subagentManager.hasPendingTask(chunk.id)) { this.renderPendingTaskFromTaskResultViaManager(chunk, msg); } // Check if it's a sync subagent result const subagentState = subagentManager.getSyncSubagent(chunk.id); if (subagentState) { this.finalizeSubagent(chunk, msg); return; } // Check if it's an async task result if (this.handleAsyncTaskToolResult(chunk)) { this.showThinkingIndicator(); return; } // Check if it's an agent output result if (await this.handleAgentOutputToolResult(chunk)) { this.showThinkingIndicator(); return; } // Check if tool is still pending (buffered) - render it now before applying result if (state.pendingTools.has(chunk.id)) { this.renderPendingTool(chunk.id); } const existingToolCall = msg.toolCalls?.find(tc => tc.id === chunk.id); // Regular tool result const isBlocked = isBlockedToolResult(chunk.content, chunk.isError); if (existingToolCall) { // Tools that resolve via dedicated callbacks (not content-based) skip // blocked detection — their status is determined solely by isError if (chunk.isError) { existingToolCall.status = 'error'; } else if (!skipsBlockedDetection(existingToolCall.name) && isBlocked) { existingToolCall.status = 'blocked'; } else { existingToolCall.status = 'completed'; } existingToolCall.result = chunk.content; if (existingToolCall.name === TOOL_ASK_USER_QUESTION) { const answers = extractResolvedAnswers(chunk.toolUseResult) ?? extractResolvedAnswersFromResultText(chunk.content); if (answers) existingToolCall.resolvedAnswers = answers; } const writeEditState = state.writeEditStates.get(chunk.id); if (writeEditState && isWriteEditTool(existingToolCall.name)) { if (!chunk.isError && !isBlocked) { const diffData = extractDiffData(chunk.toolUseResult, existingToolCall); if (diffData) { existingToolCall.diffData = diffData; updateWriteEditWithDiff(writeEditState, diffData); } } finalizeWriteEditBlock(writeEditState, chunk.isError || isBlocked); } else { updateToolCallResult(chunk.id, existingToolCall, state.toolCallElements); } // Notify Obsidian vault so the file tree refreshes after Write/Edit/NotebookEdit if (!chunk.isError && !isBlocked && isEditTool(existingToolCall.name)) { this.notifyVaultFileChange(existingToolCall.input); } } this.showThinkingIndicator(); } // ============================================ // Text Block Management // ============================================ async appendText(text: string): Promise { const { state, renderer } = this.deps; if (!state.currentContentEl) return; this.hideThinkingIndicator(); if (!state.currentTextEl) { state.currentTextEl = state.currentContentEl.createDiv({ cls: 'claudian-text-block' }); state.currentTextContent = ''; } state.currentTextContent += text; await renderer.renderContent(state.currentTextEl, state.currentTextContent); } finalizeCurrentTextBlock(msg?: ChatMessage): void { const { state, renderer } = this.deps; if (msg && state.currentTextContent) { msg.contentBlocks = msg.contentBlocks || []; msg.contentBlocks.push({ type: 'text', content: state.currentTextContent }); // Copy button added here (not during streaming) to match history-loaded messages if (state.currentTextEl) { renderer.addTextCopyButton(state.currentTextEl, state.currentTextContent); } } state.currentTextEl = null; state.currentTextContent = ''; } // ============================================ // Thinking Block Management // ============================================ async appendThinking(content: string): Promise { const { state, renderer } = this.deps; if (!state.currentContentEl) return; this.hideThinkingIndicator(); if (!state.currentThinkingState) { state.currentThinkingState = createThinkingBlock( state.currentContentEl, (el, md) => renderer.renderContent(el, md) ); } await appendThinkingContent(state.currentThinkingState, content, (el, md) => renderer.renderContent(el, md)); } finalizeCurrentThinkingBlock(msg?: ChatMessage): void { const { state } = this.deps; if (!state.currentThinkingState) return; const durationSeconds = finalizeThinkingBlock(state.currentThinkingState); if (msg && state.currentThinkingState.content) { msg.contentBlocks = msg.contentBlocks || []; msg.contentBlocks.push({ type: 'thinking', content: state.currentThinkingState.content, durationSeconds, }); } state.currentThinkingState = null; } // ============================================ // Subagent Tool Handling (via SubagentManager) // ============================================ /** Delegates Agent tool_use to SubagentManager and updates message based on result. */ private handleTaskToolUseViaManager( chunk: { type: 'tool_use'; id: string; name: string; input: Record }, msg: ChatMessage ): void { const { state, subagentManager } = this.deps; this.ensureTaskToolCall(msg, chunk.id, chunk.input); const result = subagentManager.handleTaskToolUse(chunk.id, chunk.input, state.currentContentEl); switch (result.action) { case 'created_sync': this.recordSubagentInMessage(msg, result.subagentState.info, chunk.id); this.showThinkingIndicator(); break; case 'created_async': this.recordSubagentInMessage(msg, result.info, chunk.id, 'async'); this.showThinkingIndicator(); break; case 'buffered': this.showThinkingIndicator(); break; case 'label_updated': break; } } /** Renders a pending Agent tool call via SubagentManager and updates message. */ private renderPendingTaskViaManager(toolId: string, msg: ChatMessage): void { const result = this.deps.subagentManager.renderPendingTask(toolId, this.deps.state.currentContentEl); if (!result) return; if (result.mode === 'sync') { this.recordSubagentInMessage(msg, result.subagentState.info, toolId); } else { this.recordSubagentInMessage(msg, result.info, toolId, 'async'); } } /** Resolves a pending Agent tool call when its own tool_result arrives. */ private renderPendingTaskFromTaskResultViaManager( chunk: { id: string; content: string; isError?: boolean; toolUseResult?: unknown }, msg: ChatMessage ): void { const result = this.deps.subagentManager.renderPendingTaskFromTaskResult( chunk.id, chunk.content, chunk.isError || false, this.deps.state.currentContentEl, chunk.toolUseResult ); if (!result) return; if (result.mode === 'sync') { this.recordSubagentInMessage(msg, result.subagentState.info, chunk.id); } else { this.recordSubagentInMessage(msg, result.info, chunk.id, 'async'); } } private recordSubagentInMessage( msg: ChatMessage, info: SubagentInfo, toolId: string, mode?: 'async' ): void { const taskToolCall = this.ensureTaskToolCall(msg, toolId); this.applySubagentToTaskToolCall(taskToolCall, info); msg.contentBlocks = msg.contentBlocks || []; const existingBlock = msg.contentBlocks.find( block => block.type === 'subagent' && block.subagentId === toolId ); if (existingBlock && mode && existingBlock.type === 'subagent') { existingBlock.mode = mode; } else if (!existingBlock) { msg.contentBlocks.push(mode ? { type: 'subagent', subagentId: toolId, mode } : { type: 'subagent', subagentId: toolId } ); } } private async handleSubagentChunk(chunk: StreamChunk, msg: ChatMessage): Promise { if (!('parentToolUseId' in chunk) || !chunk.parentToolUseId) { return; } const parentToolUseId = chunk.parentToolUseId; const { subagentManager } = this.deps; // If parent Agent call is still pending, child chunk confirms it's sync - render now if (subagentManager.hasPendingTask(parentToolUseId)) { this.renderPendingTaskViaManager(parentToolUseId, msg); } const subagentState = subagentManager.getSyncSubagent(parentToolUseId); if (!subagentState) { return; } switch (chunk.type) { case 'tool_use': { const toolCall: ToolCallInfo = { id: chunk.id, name: chunk.name, input: chunk.input, status: 'running', isExpanded: false, }; subagentManager.addSyncToolCall(parentToolUseId, toolCall); this.showThinkingIndicator(); break; } case 'tool_result': { const toolCall = subagentState.info.toolCalls.find((tc: ToolCallInfo) => tc.id === chunk.id); if (toolCall) { const isBlocked = isBlockedToolResult(chunk.content, chunk.isError); toolCall.status = isBlocked ? 'blocked' : (chunk.isError ? 'error' : 'completed'); toolCall.result = chunk.content; subagentManager.updateSyncToolResult(parentToolUseId, chunk.id, toolCall); } break; } case 'text': case 'thinking': break; } } /** Finalizes a sync subagent when its Agent tool_result is received. */ private finalizeSubagent( chunk: { type: 'tool_result'; id: string; content: string; isError?: boolean; toolUseResult?: unknown }, msg: ChatMessage ): void { const isError = chunk.isError || false; const finalized = this.deps.subagentManager.finalizeSyncSubagent( chunk.id, chunk.content, isError, chunk.toolUseResult ); const extractedResult = finalized?.result ?? chunk.content; const taskToolCall = this.ensureTaskToolCall(msg, chunk.id); taskToolCall.status = isError ? 'error' : 'completed'; taskToolCall.result = extractedResult; if (taskToolCall.subagent) { taskToolCall.subagent.status = isError ? 'error' : 'completed'; taskToolCall.subagent.result = extractedResult; } if (finalized) { this.applySubagentToTaskToolCall(taskToolCall, finalized); } this.showThinkingIndicator(); } // ============================================ // Async Subagent Handling // ============================================ /** Handles TaskOutput tool_use (invisible, links to async subagent). */ private handleAgentOutputToolUse( chunk: { type: 'tool_use'; id: string; name: string; input: Record }, _msg: ChatMessage ): void { const toolCall: ToolCallInfo = { id: chunk.id, name: chunk.name, input: chunk.input, status: 'running', isExpanded: false, }; this.deps.subagentManager.handleAgentOutputToolUse(toolCall); // Show flavor text while waiting for TaskOutput result this.showThinkingIndicator(); } private handleAsyncTaskToolResult( chunk: { type: 'tool_result'; id: string; content: string; isError?: boolean; toolUseResult?: unknown } ): boolean { const { subagentManager } = this.deps; if (!subagentManager.isPendingAsyncTask(chunk.id)) { return false; } subagentManager.handleTaskToolResult(chunk.id, chunk.content, chunk.isError, chunk.toolUseResult); return true; } /** Handles TaskOutput result to finalize async subagent. */ private async handleAgentOutputToolResult( chunk: { type: 'tool_result'; id: string; content: string; isError?: boolean; toolUseResult?: unknown } ): Promise { const { subagentManager } = this.deps; const isLinked = subagentManager.isLinkedAgentOutputTool(chunk.id); const handled = subagentManager.handleAgentOutputToolResult( chunk.id, chunk.content, chunk.isError || false, chunk.toolUseResult ); await this.hydrateAsyncSubagentToolCalls(handled); return isLinked || handled !== undefined; } private async hydrateAsyncSubagentToolCalls(subagent: SubagentInfo | undefined): Promise { if (!subagent) return; if (subagent.mode !== 'async') return; if (!subagent.agentId) return; const asyncStatus = subagent.asyncStatus ?? subagent.status; if (asyncStatus !== 'completed' && asyncStatus !== 'error') return; const sessionId = this.deps.getAgentService?.()?.getSessionId(); if (!sessionId) return; const vaultPath = getVaultPath(this.deps.plugin.app); if (!vaultPath) return; const { hasHydrated, finalResultHydrated } = await this.tryHydrateAsyncSubagent( subagent, vaultPath, sessionId, true ); if (hasHydrated) { this.deps.subagentManager.refreshAsyncSubagent(subagent); } if (!finalResultHydrated) { this.scheduleAsyncSubagentResultRetry(subagent, vaultPath, sessionId, 0); } } private async tryHydrateAsyncSubagent( subagent: SubagentInfo, vaultPath: string, sessionId: string, hydrateToolCalls: boolean ): Promise<{ hasHydrated: boolean; finalResultHydrated: boolean }> { let hasHydrated = false; let finalResultHydrated = false; if (hydrateToolCalls && !subagent.toolCalls?.length) { const recoveredToolCalls = await loadSubagentToolCalls( vaultPath, sessionId, subagent.agentId || '' ); if (recoveredToolCalls.length > 0) { subagent.toolCalls = recoveredToolCalls.map((toolCall) => ({ ...toolCall, input: { ...toolCall.input }, })); hasHydrated = true; } } const recoveredFinalResult = await loadSubagentFinalResult( vaultPath, sessionId, subagent.agentId || '' ); if (recoveredFinalResult && recoveredFinalResult.trim().length > 0) { finalResultHydrated = true; if (recoveredFinalResult !== subagent.result) { subagent.result = recoveredFinalResult; hasHydrated = true; } } return { hasHydrated, finalResultHydrated }; } private scheduleAsyncSubagentResultRetry( subagent: SubagentInfo, vaultPath: string, sessionId: string, attempt: number ): void { if (!subagent.agentId) return; if (attempt >= StreamController.ASYNC_SUBAGENT_RESULT_RETRY_DELAYS_MS.length) return; const delay = StreamController.ASYNC_SUBAGENT_RESULT_RETRY_DELAYS_MS[attempt]; setTimeout(() => { void this.retryAsyncSubagentResult(subagent, vaultPath, sessionId, attempt); }, delay); } private async retryAsyncSubagentResult( subagent: SubagentInfo, vaultPath: string, sessionId: string, attempt: number ): Promise { if (!subagent.agentId) return; const asyncStatus = subagent.asyncStatus ?? subagent.status; if (asyncStatus !== 'completed' && asyncStatus !== 'error') return; const { hasHydrated, finalResultHydrated } = await this.tryHydrateAsyncSubagent( subagent, vaultPath, sessionId, false ); if (hasHydrated) { this.deps.subagentManager.refreshAsyncSubagent(subagent); } if (!finalResultHydrated) { this.scheduleAsyncSubagentResultRetry(subagent, vaultPath, sessionId, attempt + 1); } } /** Callback from SubagentManager when async state changes. Updates messages only (DOM handled by manager). */ onAsyncSubagentStateChange(subagent: SubagentInfo): void { this.updateSubagentInMessages(subagent); this.scrollToBottom(); } private updateSubagentInMessages(subagent: SubagentInfo): void { const { state } = this.deps; for (let i = state.messages.length - 1; i >= 0; i--) { const msg = state.messages[i]; if (msg.role !== 'assistant') continue; if (this.linkTaskToolCallToSubagent(msg, subagent)) { return; } } } private ensureTaskToolCall( msg: ChatMessage, toolId: string, input?: Record ): ToolCallInfo { msg.toolCalls = msg.toolCalls || []; const existing = msg.toolCalls.find( tc => tc.id === toolId && isSubagentToolName(tc.name) ); if (existing) { if (input && Object.keys(input).length > 0) { existing.input = { ...existing.input, ...input }; } return existing; } const taskToolCall: ToolCallInfo = { id: toolId, name: TOOL_TASK, input: input ? { ...input } : {}, status: 'running', isExpanded: false, }; msg.toolCalls.push(taskToolCall); return taskToolCall; } private applySubagentToTaskToolCall(taskToolCall: ToolCallInfo, subagent: SubagentInfo): void { taskToolCall.subagent = subagent; if (subagent.status === 'completed') taskToolCall.status = 'completed'; else if (subagent.status === 'error') taskToolCall.status = 'error'; else taskToolCall.status = 'running'; if (subagent.result !== undefined) { taskToolCall.result = subagent.result; } } private linkTaskToolCallToSubagent(msg: ChatMessage, subagent: SubagentInfo): boolean { const taskToolCall = msg.toolCalls?.find( tc => tc.id === subagent.id && isSubagentToolName(tc.name) ); if (!taskToolCall) return false; this.applySubagentToTaskToolCall(taskToolCall, subagent); return true; } // ============================================ // Thinking Indicator // ============================================ /** Debounce delay before showing thinking indicator (ms). */ private static readonly THINKING_INDICATOR_DELAY = 400; /** * Schedules showing the thinking indicator after a delay. * If content arrives before the delay, the indicator won't show. * This prevents the indicator from appearing during active streaming. * Note: Flavor text is hidden when model thinking block is active (thinking takes priority). */ showThinkingIndicator(overrideText?: string, overrideCls?: string): void { const { state } = this.deps; // Early return if no content element if (!state.currentContentEl) return; // Clear any existing timeout if (state.thinkingIndicatorTimeout) { clearTimeout(state.thinkingIndicatorTimeout); state.thinkingIndicatorTimeout = null; } // Don't show flavor text while model thinking block is active if (state.currentThinkingState) { return; } // If indicator already exists, just re-append it to the bottom if (state.thinkingEl) { state.currentContentEl.appendChild(state.thinkingEl); this.deps.updateQueueIndicator(); return; } // Schedule showing the indicator after a delay state.thinkingIndicatorTimeout = setTimeout(() => { state.thinkingIndicatorTimeout = null; // Double-check we still have a content element, no indicator exists, and no thinking block if (!state.currentContentEl || state.thinkingEl || state.currentThinkingState) return; const cls = overrideCls ? `claudian-thinking ${overrideCls}` : 'claudian-thinking'; state.thinkingEl = state.currentContentEl.createDiv({ cls }); const text = overrideText || FLAVOR_TEXTS[Math.floor(Math.random() * FLAVOR_TEXTS.length)]; state.thinkingEl.createSpan({ text }); // Create timer span with initial value const timerSpan = state.thinkingEl.createSpan({ cls: 'claudian-thinking-hint' }); const updateTimer = () => { if (!state.responseStartTime) return; // Check if element is still connected to DOM (prevents orphaned interval updates) if (!timerSpan.isConnected) { if (state.flavorTimerInterval) { clearInterval(state.flavorTimerInterval); state.flavorTimerInterval = null; } return; } const elapsedSeconds = Math.floor((performance.now() - state.responseStartTime) / 1000); timerSpan.setText(` (esc to interrupt · ${formatDurationMmSs(elapsedSeconds)})`); }; updateTimer(); // Initial update // Start interval to update timer every second if (state.flavorTimerInterval) { clearInterval(state.flavorTimerInterval); } state.flavorTimerInterval = setInterval(updateTimer, 1000); // Queue indicator line (initially hidden) state.queueIndicatorEl = state.thinkingEl.createDiv({ cls: 'claudian-queue-indicator' }); this.deps.updateQueueIndicator(); }, StreamController.THINKING_INDICATOR_DELAY); } /** Hides the thinking indicator and cancels any pending show timeout. */ hideThinkingIndicator(): void { const { state } = this.deps; // Cancel any pending show timeout if (state.thinkingIndicatorTimeout) { clearTimeout(state.thinkingIndicatorTimeout); state.thinkingIndicatorTimeout = null; } // Clear timer interval (but preserve responseStartTime for duration capture) state.clearFlavorTimerInterval(); if (state.thinkingEl) { state.thinkingEl.remove(); state.thinkingEl = null; } state.queueIndicatorEl = null; } // ============================================ // Compact Boundary // ============================================ private renderCompactBoundary(): void { const { state } = this.deps; if (!state.currentContentEl) return; this.hideThinkingIndicator(); const el = state.currentContentEl.createDiv({ cls: 'claudian-compact-boundary' }); el.createSpan({ cls: 'claudian-compact-boundary-label', text: 'Conversation compacted' }); } // ============================================ // Utilities // ============================================ /** * Nudges Obsidian's vault after a Write/Edit/NotebookEdit so the file tree * refreshes. Direct `fs` writes bypass the Vault API, and macOS + iCloud * FSWatcher often misses the event. */ private notifyVaultFileChange(input: Record): void { const rawPath = (input.file_path ?? input.notebook_path) as string | undefined; const vaultPath = getVaultPath(this.deps.plugin.app); const relativePath = normalizePathForVault(rawPath, vaultPath); if (!relativePath || relativePath.startsWith('/')) return; setTimeout(() => { const { vault } = this.deps.plugin.app; const file = vault.getAbstractFileByPath(relativePath); if (file instanceof TFile) { // Existing file — tell listeners the content changed vault.trigger('modify', file); } else { // New file — scan parent directory so Obsidian discovers it const parentDir = relativePath.includes('/') ? relativePath.substring(0, relativePath.lastIndexOf('/')) : ''; vault.adapter.list(parentDir).catch(() => { /* ignore */ }); } }, 200); } /** Scrolls messages to bottom if auto-scroll is enabled. */ private scrollToBottom(): void { const { state, plugin } = this.deps; if (!(plugin.settings.enableAutoScroll ?? true)) return; if (!state.autoScrollEnabled) return; const messagesEl = this.deps.getMessagesEl(); messagesEl.scrollTop = messagesEl.scrollHeight; } resetStreamingState(): void { const { state } = this.deps; this.hideThinkingIndicator(); state.currentContentEl = null; state.currentTextEl = null; state.currentTextContent = ''; state.currentThinkingState = null; this.deps.subagentManager.resetStreamingState(); state.pendingTools.clear(); // Reset response timer (duration already captured at this point) state.responseStartTime = null; } } ================================================ FILE: src/features/chat/controllers/contextRowVisibility.ts ================================================ export function updateContextRowHasContent(contextRowEl: HTMLElement): void { const editorIndicator = contextRowEl.querySelector('.claudian-selection-indicator') as HTMLElement | null; const browserIndicator = contextRowEl.querySelector('.claudian-browser-selection-indicator') as HTMLElement | null; const canvasIndicator = contextRowEl.querySelector('.claudian-canvas-indicator') as HTMLElement | null; const fileIndicator = contextRowEl.querySelector('.claudian-file-indicator') as HTMLElement | null; const imagePreview = contextRowEl.querySelector('.claudian-image-preview') as HTMLElement | null; const hasEditorSelection = editorIndicator?.style.display === 'block'; const hasBrowserSelection = browserIndicator !== null && browserIndicator.style.display === 'block'; const hasCanvasSelection = canvasIndicator?.style.display === 'block'; const hasFileChips = fileIndicator?.style.display === 'flex'; const hasImageChips = imagePreview?.style.display === 'flex'; contextRowEl.classList.toggle( 'has-content', hasEditorSelection || hasBrowserSelection || hasCanvasSelection || hasFileChips || hasImageChips ); } ================================================ FILE: src/features/chat/controllers/index.ts ================================================ export { BrowserSelectionController } from './BrowserSelectionController'; export { CanvasSelectionController } from './CanvasSelectionController'; export { type ConversationCallbacks, ConversationController, type ConversationControllerDeps } from './ConversationController'; export { InputController, type InputControllerDeps } from './InputController'; export { NavigationController, type NavigationControllerDeps } from './NavigationController'; export { SelectionController } from './SelectionController'; export { StreamController, type StreamControllerDeps } from './StreamController'; ================================================ FILE: src/features/chat/rendering/DiffRenderer.ts ================================================ import type { DiffLine } from '../../../core/types/diff'; export interface DiffHunk { lines: DiffLine[]; oldStart: number; newStart: number; } export function splitIntoHunks(diffLines: DiffLine[], contextLines = 3): DiffHunk[] { if (diffLines.length === 0) return []; // Find indices of all changed lines const changedIndices: number[] = []; for (let i = 0; i < diffLines.length; i++) { if (diffLines[i].type !== 'equal') { changedIndices.push(i); } } // If no changes, return empty if (changedIndices.length === 0) return []; // Group changed lines into ranges with context const ranges: Array<{ start: number; end: number }> = []; for (const idx of changedIndices) { const start = Math.max(0, idx - contextLines); const end = Math.min(diffLines.length - 1, idx + contextLines); // Merge with previous range if overlapping or adjacent if (ranges.length > 0 && start <= ranges[ranges.length - 1].end + 1) { ranges[ranges.length - 1].end = end; } else { ranges.push({ start, end }); } } // Convert ranges to hunks const hunks: DiffHunk[] = []; for (const range of ranges) { const lines = diffLines.slice(range.start, range.end + 1); // Find the starting line numbers for this hunk let oldStart = 1; let newStart = 1; // Count lines before this range for (let i = 0; i < range.start; i++) { const line = diffLines[i]; if (line.type === 'equal' || line.type === 'delete') oldStart++; if (line.type === 'equal' || line.type === 'insert') newStart++; } hunks.push({ lines, oldStart, newStart }); } return hunks; } /** Max lines to render for all-inserts diffs (new file creation). */ const NEW_FILE_DISPLAY_CAP = 20; export function renderDiffContent( containerEl: HTMLElement, diffLines: DiffLine[], contextLines = 3 ): void { containerEl.empty(); // New file creation: all lines are inserts — cap display to avoid large DOM const allInserts = diffLines.length > 0 && diffLines.every(l => l.type === 'insert'); if (allInserts && diffLines.length > NEW_FILE_DISPLAY_CAP) { const hunkEl = containerEl.createDiv({ cls: 'claudian-diff-hunk' }); for (const line of diffLines.slice(0, NEW_FILE_DISPLAY_CAP)) { const lineEl = hunkEl.createDiv({ cls: 'claudian-diff-line claudian-diff-insert' }); const prefixEl = lineEl.createSpan({ cls: 'claudian-diff-prefix' }); prefixEl.setText('+'); const contentEl = lineEl.createSpan({ cls: 'claudian-diff-text' }); contentEl.setText(line.text || ' '); } const remaining = diffLines.length - NEW_FILE_DISPLAY_CAP; const separator = containerEl.createDiv({ cls: 'claudian-diff-separator' }); separator.setText(`... ${remaining} more lines`); return; } const hunks = splitIntoHunks(diffLines, contextLines); if (hunks.length === 0) { // No changes const noChanges = containerEl.createDiv({ cls: 'claudian-diff-no-changes' }); noChanges.setText('No changes'); return; } hunks.forEach((hunk, hunkIndex) => { // Add separator between hunks if (hunkIndex > 0) { const separator = containerEl.createDiv({ cls: 'claudian-diff-separator' }); separator.setText('...'); } // Render hunk lines const hunkEl = containerEl.createDiv({ cls: 'claudian-diff-hunk' }); for (const line of hunk.lines) { const lineEl = hunkEl.createDiv({ cls: `claudian-diff-line claudian-diff-${line.type}` }); // Line prefix const prefix = line.type === 'insert' ? '+' : line.type === 'delete' ? '-' : ' '; const prefixEl = lineEl.createSpan({ cls: 'claudian-diff-prefix' }); prefixEl.setText(prefix); // Line content const contentEl = lineEl.createSpan({ cls: 'claudian-diff-text' }); contentEl.setText(line.text || ' '); // Show space for empty lines } }); } ================================================ FILE: src/features/chat/rendering/InlineAskUserQuestion.ts ================================================ import type { AskUserQuestionItem, AskUserQuestionOption } from '../../../core/types/tools'; const HINTS_TEXT = 'Enter to select \u00B7 Tab/Arrow keys to navigate \u00B7 Esc to cancel'; const HINTS_TEXT_IMMEDIATE = 'Enter to select \u00B7 Arrow keys to navigate \u00B7 Esc to cancel'; export interface InlineAskQuestionConfig { title?: string; headerEl?: HTMLElement; showCustomInput?: boolean; immediateSelect?: boolean; } export class InlineAskUserQuestion { private containerEl: HTMLElement; private input: Record; private resolveCallback: (result: Record | null) => void; private resolved = false; private signal?: AbortSignal; private config: Required> & { headerEl?: HTMLElement }; private questions: AskUserQuestionItem[] = []; private answers = new Map>(); private customInputs = new Map(); private activeTabIndex = 0; private focusedItemIndex = 0; private isInputFocused = false; private rootEl!: HTMLElement; private tabBar!: HTMLElement; private contentArea!: HTMLElement; private tabElements: HTMLElement[] = []; private currentItems: HTMLElement[] = []; private boundKeyDown: (e: KeyboardEvent) => void; private abortHandler: (() => void) | null = null; constructor( containerEl: HTMLElement, input: Record, resolve: (result: Record | null) => void, signal?: AbortSignal, config?: InlineAskQuestionConfig, ) { this.containerEl = containerEl; this.input = input; this.resolveCallback = resolve; this.signal = signal; this.config = { title: config?.title ?? 'Claude has a question', headerEl: config?.headerEl, showCustomInput: config?.showCustomInput ?? true, immediateSelect: config?.immediateSelect ?? false, }; this.boundKeyDown = this.handleKeyDown.bind(this); } render(): void { this.rootEl = this.containerEl.createDiv({ cls: 'claudian-ask-question-inline' }); const titleEl = this.rootEl.createDiv({ cls: 'claudian-ask-inline-title' }); titleEl.setText(this.config.title); if (this.config.headerEl) { this.rootEl.appendChild(this.config.headerEl); } this.questions = this.parseQuestions(); if (this.questions.length === 0) { this.handleResolve(null); return; } if (this.config.immediateSelect && this.questions.length !== 1) { this.config.immediateSelect = false; } for (let i = 0; i < this.questions.length; i++) { this.answers.set(i, new Set()); this.customInputs.set(i, ''); } if (!this.config.immediateSelect) { this.tabBar = this.rootEl.createDiv({ cls: 'claudian-ask-tab-bar' }); this.renderTabBar(); } this.contentArea = this.rootEl.createDiv({ cls: 'claudian-ask-content' }); this.renderTabContent(); this.rootEl.setAttribute('tabindex', '0'); this.rootEl.addEventListener('keydown', this.boundKeyDown); // Defer focus to after the element is in the DOM and laid out requestAnimationFrame(() => { this.rootEl.focus(); this.rootEl.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); }); if (this.signal) { this.abortHandler = () => this.handleResolve(null); this.signal.addEventListener('abort', this.abortHandler, { once: true }); } } destroy(): void { this.handleResolve(null); } private parseQuestions(): AskUserQuestionItem[] { const raw = this.input.questions; if (!Array.isArray(raw)) return []; return raw .filter( (q): q is { question: string; header?: string; options: unknown[]; multiSelect?: boolean } => typeof q === 'object' && q !== null && typeof q.question === 'string' && Array.isArray(q.options) && q.options.length > 0, ) .map((q, idx) => ({ question: q.question, header: typeof q.header === 'string' ? q.header.slice(0, 12) : `Q${idx + 1}`, options: this.deduplicateOptions(q.options.map((o) => this.coerceOption(o))), multiSelect: q.multiSelect === true, })); } private coerceOption(opt: unknown): AskUserQuestionOption { if (typeof opt === 'object' && opt !== null) { const obj = opt as Record; const label = this.extractLabel(obj); const description = typeof obj.description === 'string' ? obj.description : ''; return { label, description }; } return { label: typeof opt === 'string' ? opt : String(opt), description: '' }; } private deduplicateOptions(options: AskUserQuestionOption[]): AskUserQuestionOption[] { const seen = new Set(); return options.filter((o) => { if (seen.has(o.label)) return false; seen.add(o.label); return true; }); } private extractLabel(obj: Record): string { if (typeof obj.label === 'string') return obj.label; if (typeof obj.value === 'string') return obj.value; if (typeof obj.text === 'string') return obj.text; if (typeof obj.name === 'string') return obj.name; return String(obj); } private renderTabBar(): void { this.tabBar.empty(); this.tabElements = []; for (let idx = 0; idx < this.questions.length; idx++) { const answered = this.isQuestionAnswered(idx); const tab = this.tabBar.createSpan({ cls: 'claudian-ask-tab' }); tab.createSpan({ text: this.questions[idx].header, cls: 'claudian-ask-tab-label' }); tab.createSpan({ text: answered ? ' \u2713' : '', cls: 'claudian-ask-tab-tick' }); tab.setAttribute('title', this.questions[idx].question); if (idx === this.activeTabIndex) tab.addClass('is-active'); if (answered) tab.addClass('is-answered'); tab.addEventListener('click', () => this.switchTab(idx)); this.tabElements.push(tab); } const allAnswered = this.questions.every((_, i) => this.isQuestionAnswered(i)); const submitTab = this.tabBar.createSpan({ cls: 'claudian-ask-tab' }); submitTab.createSpan({ text: allAnswered ? '\u2713 ' : '', cls: 'claudian-ask-tab-submit-check' }); submitTab.createSpan({ text: 'Submit', cls: 'claudian-ask-tab-label' }); if (this.activeTabIndex === this.questions.length) submitTab.addClass('is-active'); submitTab.addEventListener('click', () => this.switchTab(this.questions.length)); this.tabElements.push(submitTab); } private isQuestionAnswered(idx: number): boolean { return this.answers.get(idx)!.size > 0 || this.customInputs.get(idx)!.trim().length > 0; } private switchTab(index: number): void { const clamped = Math.max(0, Math.min(index, this.questions.length)); if (clamped === this.activeTabIndex) return; this.activeTabIndex = clamped; this.focusedItemIndex = 0; this.isInputFocused = false; if (!this.config.immediateSelect) { this.renderTabBar(); } this.renderTabContent(); this.rootEl.focus(); } private renderTabContent(): void { this.contentArea.empty(); this.currentItems = []; if (this.activeTabIndex < this.questions.length) { this.renderQuestionTab(this.activeTabIndex); } else { this.renderSubmitTab(); } } private renderQuestionTab(idx: number): void { const q = this.questions[idx]; const isMulti = q.multiSelect; const selected = this.answers.get(idx)!; this.contentArea.createDiv({ text: q.question, cls: 'claudian-ask-question-text', }); const listEl = this.contentArea.createDiv({ cls: 'claudian-ask-list' }); for (let optIdx = 0; optIdx < q.options.length; optIdx++) { const option = q.options[optIdx]; const isFocused = optIdx === this.focusedItemIndex; const isSelected = selected.has(option.label); const row = listEl.createDiv({ cls: 'claudian-ask-item' }); if (isFocused) row.addClass('is-focused'); if (isSelected) row.addClass('is-selected'); row.createSpan({ text: isFocused ? '\u203A' : '\u00A0', cls: 'claudian-ask-cursor' }); row.createSpan({ text: `${optIdx + 1}. `, cls: 'claudian-ask-item-num' }); if (isMulti) { this.renderMultiSelectCheckbox(row, isSelected); } const labelBlock = row.createDiv({ cls: 'claudian-ask-item-content' }); const labelRow = labelBlock.createDiv({ cls: 'claudian-ask-label-row' }); labelRow.createSpan({ text: option.label, cls: 'claudian-ask-item-label' }); if (!isMulti && isSelected) { labelRow.createSpan({ text: ' \u2713', cls: 'claudian-ask-check-mark' }); } if (option.description) { labelBlock.createDiv({ text: option.description, cls: 'claudian-ask-item-desc' }); } row.addEventListener('click', () => { this.focusedItemIndex = optIdx; this.updateFocusIndicator(); this.selectOption(idx, option.label); }); this.currentItems.push(row); } if (this.config.showCustomInput) { const customIdx = q.options.length; const customFocused = customIdx === this.focusedItemIndex; const customText = this.customInputs.get(idx) ?? ''; const hasCustomText = customText.trim().length > 0; const customRow = listEl.createDiv({ cls: 'claudian-ask-item claudian-ask-custom-item' }); if (customFocused) customRow.addClass('is-focused'); customRow.createSpan({ text: customFocused ? '\u203A' : '\u00A0', cls: 'claudian-ask-cursor' }); customRow.createSpan({ text: `${customIdx + 1}. `, cls: 'claudian-ask-item-num' }); if (isMulti) { this.renderMultiSelectCheckbox(customRow, hasCustomText); } const inputEl = customRow.createEl('input', { type: 'text', cls: 'claudian-ask-custom-text', placeholder: 'Type something.', value: customText, }); inputEl.addEventListener('input', () => { this.customInputs.set(idx, inputEl.value); if (!isMulti && inputEl.value.trim()) { selected.clear(); this.updateOptionVisuals(idx); } this.updateTabIndicators(); }); inputEl.addEventListener('focus', () => { this.isInputFocused = true; }); inputEl.addEventListener('blur', () => { this.isInputFocused = false; }); this.currentItems.push(customRow); } this.contentArea.createDiv({ text: this.config.immediateSelect ? HINTS_TEXT_IMMEDIATE : HINTS_TEXT, cls: 'claudian-ask-hints', }); } private renderSubmitTab(): void { this.contentArea.createDiv({ text: 'Review your answers', cls: 'claudian-ask-review-title', }); const reviewEl = this.contentArea.createDiv({ cls: 'claudian-ask-review' }); for (let idx = 0; idx < this.questions.length; idx++) { const q = this.questions[idx]; const answerText = this.getAnswerText(idx); const pairEl = reviewEl.createDiv({ cls: 'claudian-ask-review-pair' }); pairEl.createDiv({ text: `${idx + 1}.`, cls: 'claudian-ask-review-num' }); const bodyEl = pairEl.createDiv({ cls: 'claudian-ask-review-body' }); bodyEl.createDiv({ text: q.question, cls: 'claudian-ask-review-q-text' }); bodyEl.createDiv({ text: answerText || 'Not answered', cls: answerText ? 'claudian-ask-review-a-text' : 'claudian-ask-review-empty', }); pairEl.addEventListener('click', () => this.switchTab(idx)); } this.contentArea.createDiv({ text: 'Ready to submit your answers?', cls: 'claudian-ask-review-prompt', }); const actionsEl = this.contentArea.createDiv({ cls: 'claudian-ask-list' }); const allAnswered = this.questions.every((_, i) => this.isQuestionAnswered(i)); const submitRow = actionsEl.createDiv({ cls: 'claudian-ask-item' }); if (this.focusedItemIndex === 0) submitRow.addClass('is-focused'); if (!allAnswered) submitRow.addClass('is-disabled'); submitRow.createSpan({ text: this.focusedItemIndex === 0 ? '\u203A' : '\u00A0', cls: 'claudian-ask-cursor' }); submitRow.createSpan({ text: '1. ', cls: 'claudian-ask-item-num' }); submitRow.createSpan({ text: 'Submit answers', cls: 'claudian-ask-item-label' }); submitRow.addEventListener('click', () => { this.focusedItemIndex = 0; this.updateFocusIndicator(); this.handleSubmit(); }); this.currentItems.push(submitRow); const cancelRow = actionsEl.createDiv({ cls: 'claudian-ask-item' }); if (this.focusedItemIndex === 1) cancelRow.addClass('is-focused'); cancelRow.createSpan({ text: this.focusedItemIndex === 1 ? '\u203A' : '\u00A0', cls: 'claudian-ask-cursor' }); cancelRow.createSpan({ text: '2. ', cls: 'claudian-ask-item-num' }); cancelRow.createSpan({ text: 'Cancel', cls: 'claudian-ask-item-label' }); cancelRow.addEventListener('click', () => { this.focusedItemIndex = 1; this.handleResolve(null); }); this.currentItems.push(cancelRow); this.contentArea.createDiv({ text: HINTS_TEXT, cls: 'claudian-ask-hints', }); } private getAnswerText(idx: number): string { const selected = this.answers.get(idx)!; const custom = this.customInputs.get(idx)!; const parts: string[] = []; if (selected.size > 0) parts.push([...selected].join(', ')); if (custom.trim()) parts.push(custom.trim()); return parts.join(', '); } private selectOption(qIdx: number, label: string): void { const q = this.questions[qIdx]; const selected = this.answers.get(qIdx)!; const isMulti = q.multiSelect; if (isMulti) { if (selected.has(label)) { selected.delete(label); } else { selected.add(label); } } else { selected.clear(); selected.add(label); this.customInputs.set(qIdx, ''); } this.updateOptionVisuals(qIdx); if (this.config.immediateSelect) { const result: Record = {}; result[q.question] = label; this.handleResolve(result); return; } this.updateTabIndicators(); if (!isMulti) { this.switchTab(this.activeTabIndex + 1); } } private renderMultiSelectCheckbox(parent: HTMLElement, checked: boolean): void { parent.createSpan({ text: checked ? '[\u2713] ' : '[ ] ', cls: `claudian-ask-check${checked ? ' is-checked' : ''}`, }); } private updateOptionVisuals(qIdx: number): void { const q = this.questions[qIdx]; const selected = this.answers.get(qIdx)!; const isMulti = q.multiSelect; for (let i = 0; i < q.options.length; i++) { const item = this.currentItems[i]; const isSelected = selected.has(q.options[i].label); item.toggleClass('is-selected', isSelected); if (isMulti) { const checkSpan = item.querySelector('.claudian-ask-check') as HTMLElement | null; if (checkSpan) { checkSpan.textContent = isSelected ? '[\u2713] ' : '[ ] '; checkSpan.toggleClass('is-checked', isSelected); } } else { const labelRow = item.querySelector('.claudian-ask-label-row') as HTMLElement | null; const existingMark = item.querySelector('.claudian-ask-check-mark'); if (isSelected && !existingMark && labelRow) { labelRow.createSpan({ text: ' \u2713', cls: 'claudian-ask-check-mark' }); } else if (!isSelected && existingMark) { existingMark.remove(); } } } } private updateFocusIndicator(): void { for (let i = 0; i < this.currentItems.length; i++) { const item = this.currentItems[i]; const cursor = item.querySelector('.claudian-ask-cursor'); if (i === this.focusedItemIndex) { item.addClass('is-focused'); if (cursor) cursor.textContent = '\u203A'; item.scrollIntoView({ block: 'nearest' }); if (item.hasClass('claudian-ask-custom-item')) { const input = item.querySelector('.claudian-ask-custom-text') as HTMLInputElement; if (input) { input.focus(); this.isInputFocused = true; } } } else { item.removeClass('is-focused'); if (cursor) cursor.textContent = '\u00A0'; if (item.hasClass('claudian-ask-custom-item')) { const input = item.querySelector('.claudian-ask-custom-text') as HTMLInputElement; if (input && document.activeElement === input) { input.blur(); this.isInputFocused = false; } } } } } private updateTabIndicators(): void { for (let idx = 0; idx < this.questions.length; idx++) { const tab = this.tabElements[idx]; const tick = tab.querySelector('.claudian-ask-tab-tick'); const answered = this.isQuestionAnswered(idx); tab.toggleClass('is-answered', answered); if (tick) tick.textContent = answered ? ' \u2713' : ''; } const submitTab = this.tabElements[this.questions.length]; if (submitTab) { const submitCheck = submitTab.querySelector('.claudian-ask-tab-submit-check'); const allAnswered = this.questions.every((_, i) => this.isQuestionAnswered(i)); if (submitCheck) submitCheck.textContent = allAnswered ? '\u2713 ' : ''; } } private handleNavigationKey(e: KeyboardEvent, maxFocusIndex: number): boolean { switch (e.key) { case 'ArrowDown': e.preventDefault(); e.stopPropagation(); this.focusedItemIndex = Math.min(this.focusedItemIndex + 1, maxFocusIndex); this.updateFocusIndicator(); return true; case 'ArrowUp': e.preventDefault(); e.stopPropagation(); this.focusedItemIndex = Math.max(this.focusedItemIndex - 1, 0); this.updateFocusIndicator(); return true; case 'ArrowLeft': if (this.config.immediateSelect) return false; e.preventDefault(); e.stopPropagation(); this.switchTab(this.activeTabIndex - 1); return true; case 'Tab': if (this.config.immediateSelect) return false; e.preventDefault(); e.stopPropagation(); if (e.shiftKey) { this.switchTab(this.activeTabIndex - 1); } else { this.switchTab(this.activeTabIndex + 1); } return true; case 'Escape': e.preventDefault(); e.stopPropagation(); this.handleResolve(null); return true; default: return false; } } private handleKeyDown(e: KeyboardEvent): void { if (this.isInputFocused) { if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); this.isInputFocused = false; (document.activeElement as HTMLElement)?.blur(); this.rootEl.focus(); return; } if (e.key === 'Tab' || e.key === 'Enter') { e.preventDefault(); e.stopPropagation(); this.isInputFocused = false; (document.activeElement as HTMLElement)?.blur(); if (e.key === 'Tab' && e.shiftKey) { this.switchTab(this.activeTabIndex - 1); } else { this.switchTab(this.activeTabIndex + 1); } return; } return; } if (this.config.immediateSelect) { const q = this.questions[this.activeTabIndex]; const maxIdx = q.options.length - 1; if (this.handleNavigationKey(e, maxIdx)) return; if (e.key === 'Enter') { e.preventDefault(); e.stopPropagation(); if (this.focusedItemIndex <= maxIdx) { this.selectOption(this.activeTabIndex, q.options[this.focusedItemIndex].label); } } return; } const isSubmitTab = this.activeTabIndex === this.questions.length; const q = this.questions[this.activeTabIndex]; const maxFocusIndex = isSubmitTab ? 1 : (this.config.showCustomInput ? q.options.length : q.options.length - 1); if (this.handleNavigationKey(e, maxFocusIndex)) return; if (isSubmitTab) { if (e.key === 'Enter') { e.preventDefault(); e.stopPropagation(); if (this.focusedItemIndex === 0) this.handleSubmit(); else this.handleResolve(null); } return; } // Question tab: ArrowRight and Enter switch (e.key) { case 'ArrowRight': e.preventDefault(); e.stopPropagation(); this.switchTab(this.activeTabIndex + 1); break; case 'Enter': e.preventDefault(); e.stopPropagation(); if (this.focusedItemIndex < q.options.length) { this.selectOption(this.activeTabIndex, q.options[this.focusedItemIndex].label); } else if (this.config.showCustomInput) { this.isInputFocused = true; const input = this.contentArea.querySelector( '.claudian-ask-custom-text', ) as HTMLInputElement; input?.focus(); } break; } } private handleSubmit(): void { const allAnswered = this.questions.every((_, i) => this.isQuestionAnswered(i)); if (!allAnswered) return; const result: Record = {}; for (let i = 0; i < this.questions.length; i++) { result[this.questions[i].question] = this.getAnswerText(i); } this.handleResolve(result); } private handleResolve(result: Record | null): void { if (!this.resolved) { this.resolved = true; this.rootEl?.removeEventListener('keydown', this.boundKeyDown); if (this.signal && this.abortHandler) { this.signal.removeEventListener('abort', this.abortHandler); this.abortHandler = null; } this.rootEl?.remove(); this.resolveCallback(result); } } } ================================================ FILE: src/features/chat/rendering/InlineExitPlanMode.ts ================================================ import * as nodePath from 'path'; import type { ExitPlanModeDecision } from '../../../core/types/tools'; import type { RenderContentFn } from './MessageRenderer'; const HINTS_TEXT = 'Arrow keys to navigate \u00B7 Enter to select \u00B7 Esc to cancel'; export class InlineExitPlanMode { private containerEl: HTMLElement; private input: Record; private resolveCallback: (decision: ExitPlanModeDecision | null) => void; private resolved = false; private signal?: AbortSignal; private renderContent?: RenderContentFn; private planContent: string | null = null; private planReadError: string | null = null; private rootEl!: HTMLElement; private focusedIndex = 0; private items: HTMLElement[] = []; private feedbackInput!: HTMLInputElement; private isInputFocused = false; private boundKeyDown: (e: KeyboardEvent) => void; private abortHandler: (() => void) | null = null; constructor( containerEl: HTMLElement, input: Record, resolve: (decision: ExitPlanModeDecision | null) => void, signal?: AbortSignal, renderContent?: RenderContentFn, ) { this.containerEl = containerEl; this.input = input; this.resolveCallback = resolve; this.signal = signal; this.renderContent = renderContent; this.boundKeyDown = this.handleKeyDown.bind(this); } render(): void { this.rootEl = this.containerEl.createDiv({ cls: 'claudian-plan-approval-inline' }); const titleEl = this.rootEl.createDiv({ cls: 'claudian-plan-inline-title' }); titleEl.setText('Plan complete'); this.planContent = this.readPlanContent(); if (this.planContent) { const contentEl = this.rootEl.createDiv({ cls: 'claudian-plan-content-preview' }); if (this.renderContent) { void this.renderContent(contentEl, this.planContent); } else { contentEl.createDiv({ cls: 'claudian-plan-content-text', text: this.planContent }); } } else if (this.planReadError) { this.rootEl.createDiv({ cls: 'claudian-plan-content-preview claudian-plan-read-error', text: `Could not read plan file: ${this.planReadError}. "Approve (new session)" will not include plan details.`, }); } const allowedPrompts = this.input.allowedPrompts as Array<{ tool: string; prompt: string }> | undefined; if (allowedPrompts && Array.isArray(allowedPrompts) && allowedPrompts.length > 0) { const permEl = this.rootEl.createDiv({ cls: 'claudian-plan-permissions' }); permEl.createDiv({ text: 'Requested permissions:', cls: 'claudian-plan-permissions-label' }); const listEl = permEl.createEl('ul', { cls: 'claudian-plan-permissions-list' }); for (const perm of allowedPrompts) { listEl.createEl('li', { text: perm.prompt }); } } const actionsEl = this.rootEl.createDiv({ cls: 'claudian-ask-list' }); const newSessionRow = actionsEl.createDiv({ cls: 'claudian-ask-item' }); newSessionRow.addClass('is-focused'); newSessionRow.createSpan({ text: '\u203A', cls: 'claudian-ask-cursor' }); newSessionRow.createSpan({ text: '1. ', cls: 'claudian-ask-item-num' }); newSessionRow.createSpan({ text: 'Approve (new session)', cls: 'claudian-ask-item-label' }); newSessionRow.addEventListener('click', () => { this.focusedIndex = 0; this.updateFocus(); this.handleResolve({ type: 'approve-new-session', planContent: this.extractPlanContent(), }); }); this.items.push(newSessionRow); const approveRow = actionsEl.createDiv({ cls: 'claudian-ask-item' }); approveRow.createSpan({ text: '\u00A0', cls: 'claudian-ask-cursor' }); approveRow.createSpan({ text: '2. ', cls: 'claudian-ask-item-num' }); approveRow.createSpan({ text: 'Approve (current session)', cls: 'claudian-ask-item-label' }); approveRow.addEventListener('click', () => { this.focusedIndex = 1; this.updateFocus(); this.handleResolve({ type: 'approve' }); }); this.items.push(approveRow); const feedbackRow = actionsEl.createDiv({ cls: 'claudian-ask-item claudian-ask-custom-item' }); feedbackRow.createSpan({ text: '\u00A0', cls: 'claudian-ask-cursor' }); feedbackRow.createSpan({ text: '3. ', cls: 'claudian-ask-item-num' }); this.feedbackInput = feedbackRow.createEl('input', { type: 'text', cls: 'claudian-ask-custom-text', placeholder: 'Enter feedback to continue planning...', }); this.feedbackInput.addEventListener('focus', () => { this.isInputFocused = true; }); this.feedbackInput.addEventListener('blur', () => { this.isInputFocused = false; }); feedbackRow.addEventListener('click', () => { this.focusedIndex = 2; this.updateFocus(); }); this.items.push(feedbackRow); this.rootEl.createDiv({ text: HINTS_TEXT, cls: 'claudian-ask-hints' }); this.rootEl.setAttribute('tabindex', '0'); this.rootEl.addEventListener('keydown', this.boundKeyDown); requestAnimationFrame(() => { this.rootEl.focus(); this.rootEl.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); }); if (this.signal) { this.abortHandler = () => this.handleResolve(null); this.signal.addEventListener('abort', this.abortHandler, { once: true }); } } destroy(): void { this.handleResolve(null); } private readPlanContent(): string | null { const planFilePath = this.input.planFilePath as string | undefined; if (!planFilePath) return null; const resolved = nodePath.resolve(planFilePath).replace(/\\/g, '/'); if (!resolved.includes('/.claude/plans/')) { this.planReadError = 'path outside allowed plan directory'; return null; } try { // eslint-disable-next-line @typescript-eslint/no-require-imports const fs = require('fs'); const content = fs.readFileSync(planFilePath, 'utf-8') as string; return content.trim() || null; } catch (err) { this.planReadError = err instanceof Error ? err.message : 'unknown error'; return null; } } private extractPlanContent(): string { if (this.planContent) { return `Implement this plan:\n\n${this.planContent}`; } return 'Implement the approved plan.'; } private handleKeyDown(e: KeyboardEvent): void { if (this.isInputFocused) { if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); this.isInputFocused = false; this.feedbackInput.blur(); this.rootEl.focus(); return; } if (e.key === 'Enter' && this.feedbackInput.value.trim()) { e.preventDefault(); e.stopPropagation(); this.handleResolve({ type: 'feedback', text: this.feedbackInput.value.trim() }); return; } return; } switch (e.key) { case 'ArrowDown': e.preventDefault(); e.stopPropagation(); this.focusedIndex = Math.min(this.focusedIndex + 1, this.items.length - 1); this.updateFocus(); break; case 'ArrowUp': e.preventDefault(); e.stopPropagation(); this.focusedIndex = Math.max(this.focusedIndex - 1, 0); this.updateFocus(); break; case 'Enter': e.preventDefault(); e.stopPropagation(); if (this.focusedIndex === 0) { this.handleResolve({ type: 'approve-new-session', planContent: this.extractPlanContent(), }); } else if (this.focusedIndex === 1) { this.handleResolve({ type: 'approve' }); } else if (this.focusedIndex === 2) { this.feedbackInput.focus(); } break; case 'Escape': e.preventDefault(); e.stopPropagation(); this.handleResolve(null); break; } } private updateFocus(): void { for (let i = 0; i < this.items.length; i++) { const item = this.items[i]; const cursor = item.querySelector('.claudian-ask-cursor'); if (i === this.focusedIndex) { item.addClass('is-focused'); if (cursor) cursor.textContent = '\u203A'; item.scrollIntoView({ block: 'nearest' }); if (item.hasClass('claudian-ask-custom-item')) { const input = item.querySelector('.claudian-ask-custom-text') as HTMLInputElement; if (input) { input.focus(); this.isInputFocused = true; } } } else { item.removeClass('is-focused'); if (cursor) cursor.textContent = '\u00A0'; if (item.hasClass('claudian-ask-custom-item')) { const input = item.querySelector('.claudian-ask-custom-text') as HTMLInputElement; if (input && document.activeElement === input) { input.blur(); this.isInputFocused = false; } } } } } private handleResolve(decision: ExitPlanModeDecision | null): void { if (!this.resolved) { this.resolved = true; this.rootEl?.removeEventListener('keydown', this.boundKeyDown); if (this.signal && this.abortHandler) { this.signal.removeEventListener('abort', this.abortHandler); this.abortHandler = null; } this.rootEl?.remove(); this.resolveCallback(decision); } } } ================================================ FILE: src/features/chat/rendering/MessageRenderer.ts ================================================ import type { App, Component } from 'obsidian'; import { MarkdownRenderer, Notice } from 'obsidian'; import { isSubagentToolName, isWriteEditTool, TOOL_AGENT_OUTPUT } from '../../../core/tools/toolNames'; import type { ChatMessage, ImageAttachment, SubagentInfo, ToolCallInfo } from '../../../core/types'; import { t } from '../../../i18n'; import type ClaudianPlugin from '../../../main'; import { formatDurationMmSs } from '../../../utils/date'; import { processFileLinks, registerFileLinkHandler } from '../../../utils/fileLink'; import { replaceImageEmbedsWithHtml } from '../../../utils/imageEmbed'; import { findRewindContext } from '../rewind'; import { renderStoredAsyncSubagent, renderStoredSubagent, } from './SubagentRenderer'; import { renderStoredThinkingBlock } from './ThinkingBlockRenderer'; import { renderStoredToolCall } from './ToolCallRenderer'; import { renderStoredWriteEdit } from './WriteEditRenderer'; export type RenderContentFn = (el: HTMLElement, markdown: string) => Promise; export class MessageRenderer { private app: App; private plugin: ClaudianPlugin; private component: Component; private messagesEl: HTMLElement; private rewindCallback?: (messageId: string) => Promise; private forkCallback?: (messageId: string) => Promise; private liveMessageEls = new Map(); private static readonly REWIND_ICON = ``; private static readonly FORK_ICON = ``; constructor( plugin: ClaudianPlugin, component: Component, messagesEl: HTMLElement, rewindCallback?: (messageId: string) => Promise, forkCallback?: (messageId: string) => Promise, ) { this.app = plugin.app; this.plugin = plugin; this.component = component; this.messagesEl = messagesEl; this.rewindCallback = rewindCallback; this.forkCallback = forkCallback; // Register delegated click handler for file links registerFileLinkHandler(this.app, this.messagesEl, this.component); } /** Sets the messages container element. */ setMessagesEl(el: HTMLElement): void { this.messagesEl = el; } // ============================================ // Streaming Message Rendering // ============================================ /** * Adds a new message to the chat during streaming. * Returns the message element for content updates. */ addMessage(msg: ChatMessage): HTMLElement { // Render images above message bubble for user messages if (msg.role === 'user' && msg.images && msg.images.length > 0) { this.renderMessageImages(this.messagesEl, msg.images); } // Skip empty bubble for image-only messages if (msg.role === 'user') { const textToShow = msg.displayContent ?? msg.content; if (!textToShow) { this.scrollToBottom(); const lastChild = this.messagesEl.lastElementChild as HTMLElement; return lastChild ?? this.messagesEl; } } const msgEl = this.messagesEl.createDiv({ cls: `claudian-message claudian-message-${msg.role}`, attr: { 'data-message-id': msg.id, 'data-role': msg.role, }, }); const contentEl = msgEl.createDiv({ cls: 'claudian-message-content', attr: { dir: 'auto' } }); if (msg.role === 'user') { const textToShow = msg.displayContent ?? msg.content; if (textToShow) { const textEl = contentEl.createDiv({ cls: 'claudian-text-block' }); void this.renderContent(textEl, textToShow); this.addUserCopyButton(msgEl, textToShow); } if (this.rewindCallback || this.forkCallback) { this.liveMessageEls.set(msg.id, msgEl); } } this.scrollToBottom(); return msgEl; } // ============================================ // Stored Message Rendering (Batch/Replay) // ============================================ /** * Renders all messages for conversation load/switch. * @param messages Array of messages to render * @param getGreeting Function to get greeting text * @returns The newly created welcome element */ renderMessages( messages: ChatMessage[], getGreeting: () => string ): HTMLElement { this.messagesEl.empty(); this.liveMessageEls.clear(); // Recreate welcome element after clearing const newWelcomeEl = this.messagesEl.createDiv({ cls: 'claudian-welcome' }); newWelcomeEl.createDiv({ cls: 'claudian-welcome-greeting', text: getGreeting() }); for (let i = 0; i < messages.length; i++) { this.renderStoredMessage(messages[i], messages, i); } this.scrollToBottom(); return newWelcomeEl; } renderStoredMessage(msg: ChatMessage, allMessages?: ChatMessage[], index?: number): void { // Render interrupt messages with special styling (not as user bubbles) if (msg.isInterrupt) { this.renderInterruptMessage(); return; } // Skip rebuilt context messages (history sent to SDK on session reset) // These are internal context for the AI, not actual user messages to display if (msg.isRebuiltContext) { return; } // Render images above bubble for user messages if (msg.role === 'user' && msg.images && msg.images.length > 0) { this.renderMessageImages(this.messagesEl, msg.images); } // Skip empty bubble for image-only messages if (msg.role === 'user') { const textToShow = msg.displayContent ?? msg.content; if (!textToShow) { return; } } const msgEl = this.messagesEl.createDiv({ cls: `claudian-message claudian-message-${msg.role}`, attr: { 'data-message-id': msg.id, 'data-role': msg.role, }, }); const contentEl = msgEl.createDiv({ cls: 'claudian-message-content', attr: { dir: 'auto' } }); if (msg.role === 'user') { const textToShow = msg.displayContent ?? msg.content; if (textToShow) { const textEl = contentEl.createDiv({ cls: 'claudian-text-block' }); void this.renderContent(textEl, textToShow); this.addUserCopyButton(msgEl, textToShow); } if (msg.sdkUserUuid && this.isRewindEligible(allMessages, index)) { if (this.rewindCallback) { this.addRewindButton(msgEl, msg.id); } if (this.forkCallback) { this.addForkButton(msgEl, msg.id); } } } else if (msg.role === 'assistant') { this.renderAssistantContent(msg, contentEl); } } private isRewindEligible(allMessages?: ChatMessage[], index?: number): boolean { if (!allMessages || index === undefined) return false; const ctx = findRewindContext(allMessages, index); return !!ctx.prevAssistantUuid && ctx.hasResponse; } /** * Renders an interrupt indicator (stored interrupts from SDK history). * Uses the same styling as streaming interrupts. */ private renderInterruptMessage(): void { const msgEl = this.messagesEl.createDiv({ cls: 'claudian-message claudian-message-assistant' }); const contentEl = msgEl.createDiv({ cls: 'claudian-message-content', attr: { dir: 'auto' } }); const textEl = contentEl.createDiv({ cls: 'claudian-text-block' }); textEl.innerHTML = 'Interrupted · What should Claudian do instead?'; } /** * Renders assistant message content (content blocks or fallback). */ private renderAssistantContent(msg: ChatMessage, contentEl: HTMLElement): void { if (msg.contentBlocks && msg.contentBlocks.length > 0) { const renderedToolIds = new Set(); for (const block of msg.contentBlocks) { if (block.type === 'thinking') { renderStoredThinkingBlock( contentEl, block.content, block.durationSeconds, (el, md) => this.renderContent(el, md) ); } else if (block.type === 'text') { // Skip empty or whitespace-only text blocks to avoid extra gaps if (!block.content || !block.content.trim()) { continue; } const textEl = contentEl.createDiv({ cls: 'claudian-text-block' }); void this.renderContent(textEl, block.content); this.addTextCopyButton(textEl, block.content); } else if (block.type === 'tool_use') { const toolCall = msg.toolCalls?.find(tc => tc.id === block.toolId); if (toolCall) { this.renderToolCall(contentEl, toolCall); renderedToolIds.add(toolCall.id); } } else if (block.type === 'compact_boundary') { const boundaryEl = contentEl.createDiv({ cls: 'claudian-compact-boundary' }); boundaryEl.createSpan({ cls: 'claudian-compact-boundary-label', text: 'Conversation compacted' }); } else if (block.type === 'subagent') { const taskToolCall = msg.toolCalls?.find( tc => tc.id === block.subagentId && isSubagentToolName(tc.name) ); if (!taskToolCall) continue; this.renderTaskSubagent(contentEl, taskToolCall, block.mode); renderedToolIds.add(taskToolCall.id); } } // Defensive fallback: preserve tool visibility when contentBlocks/toolCalls drift on reload. if (msg.toolCalls && msg.toolCalls.length > 0) { for (const toolCall of msg.toolCalls) { if (renderedToolIds.has(toolCall.id)) continue; this.renderToolCall(contentEl, toolCall); renderedToolIds.add(toolCall.id); } } } else { // Fallback for old conversations without contentBlocks if (msg.content) { const textEl = contentEl.createDiv({ cls: 'claudian-text-block' }); void this.renderContent(textEl, msg.content); this.addTextCopyButton(textEl, msg.content); } if (msg.toolCalls) { for (const toolCall of msg.toolCalls) { this.renderToolCall(contentEl, toolCall); } } } // Render response duration footer (skip when message contains a compaction boundary) const hasCompactBoundary = msg.contentBlocks?.some(b => b.type === 'compact_boundary'); if (msg.durationSeconds && msg.durationSeconds > 0 && !hasCompactBoundary) { const flavorWord = msg.durationFlavorWord || 'Baked'; const footerEl = contentEl.createDiv({ cls: 'claudian-response-footer' }); footerEl.createSpan({ text: `* ${flavorWord} for ${formatDurationMmSs(msg.durationSeconds)}`, cls: 'claudian-baked-duration', }); } } /** * Renders a tool call with special handling for Write/Edit and Agent (subagent). * TaskOutput is hidden as it's an internal tool for async subagent communication. */ private renderToolCall(contentEl: HTMLElement, toolCall: ToolCallInfo): void { // Skip TaskOutput - it's invisible (internal async subagent communication) if (toolCall.name === TOOL_AGENT_OUTPUT) { return; } if (isWriteEditTool(toolCall.name)) { renderStoredWriteEdit(contentEl, toolCall); } else if (isSubagentToolName(toolCall.name)) { this.renderTaskSubagent(contentEl, toolCall); } else { renderStoredToolCall(contentEl, toolCall); } } private renderTaskSubagent( contentEl: HTMLElement, toolCall: ToolCallInfo, modeHint?: 'sync' | 'async' ): void { const subagentInfo = this.resolveTaskSubagent(toolCall, modeHint); if (subagentInfo.mode === 'async') { renderStoredAsyncSubagent(contentEl, subagentInfo); return; } renderStoredSubagent(contentEl, subagentInfo); } private resolveTaskSubagent(toolCall: ToolCallInfo, modeHint?: 'sync' | 'async'): SubagentInfo { if (toolCall.subagent) { if (!modeHint || toolCall.subagent.mode === modeHint) { return toolCall.subagent; } return { ...toolCall.subagent, mode: modeHint, }; } const description = (toolCall.input?.description as string) || 'Subagent task'; const prompt = (toolCall.input?.prompt as string) || ''; const mode = modeHint ?? (toolCall.input?.run_in_background === true ? 'async' : 'sync'); if (mode !== 'async') { return { id: toolCall.id, description, prompt, status: this.mapToolStatusToSubagentStatus(toolCall.status), toolCalls: [], isExpanded: false, result: toolCall.result, }; } const asyncStatus = this.inferAsyncStatusFromTaskTool(toolCall); return { id: toolCall.id, description, prompt, mode: 'async', status: asyncStatus, asyncStatus, toolCalls: [], isExpanded: false, result: toolCall.result, }; } private mapToolStatusToSubagentStatus( status: ToolCallInfo['status'] ): 'completed' | 'error' | 'running' { switch (status) { case 'completed': return 'completed'; case 'error': case 'blocked': return 'error'; default: return 'running'; } } private inferAsyncStatusFromTaskTool(toolCall: ToolCallInfo): 'running' | 'completed' | 'error' { if (toolCall.status === 'error' || toolCall.status === 'blocked') return 'error'; if (toolCall.status === 'running') return 'running'; const lowerResult = (toolCall.result || '').toLowerCase(); if ( lowerResult.includes('not_ready') || lowerResult.includes('not ready') || lowerResult.includes('"status":"running"') || lowerResult.includes('"status":"pending"') || lowerResult.includes('"retrieval_status":"running"') || lowerResult.includes('"retrieval_status":"not_ready"') ) { return 'running'; } return 'completed'; } // ============================================ // Image Rendering // ============================================ /** * Renders image attachments above a message. */ renderMessageImages(containerEl: HTMLElement, images: ImageAttachment[]): void { const imagesEl = containerEl.createDiv({ cls: 'claudian-message-images' }); for (const image of images) { const imageWrapper = imagesEl.createDiv({ cls: 'claudian-message-image' }); const imgEl = imageWrapper.createEl('img', { attr: { alt: image.name, }, }); void this.setImageSrc(imgEl, image); // Click to view full size imgEl.addEventListener('click', () => { void this.showFullImage(image); }); } } /** * Shows full-size image in modal overlay. */ showFullImage(image: ImageAttachment): void { const dataUri = `data:${image.mediaType};base64,${image.data}`; const overlay = document.body.createDiv({ cls: 'claudian-image-modal-overlay' }); const modal = overlay.createDiv({ cls: 'claudian-image-modal' }); modal.createEl('img', { attr: { src: dataUri, alt: image.name, }, }); const closeBtn = modal.createDiv({ cls: 'claudian-image-modal-close' }); closeBtn.setText('\u00D7'); const handleEsc = (e: KeyboardEvent) => { if (e.key === 'Escape') { close(); } }; const close = () => { document.removeEventListener('keydown', handleEsc); overlay.remove(); }; closeBtn.addEventListener('click', close); overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); document.addEventListener('keydown', handleEsc); } /** * Sets image src from attachment data. */ setImageSrc(imgEl: HTMLImageElement, image: ImageAttachment): void { const dataUri = `data:${image.mediaType};base64,${image.data}`; imgEl.setAttribute('src', dataUri); } // ============================================ // Content Rendering // ============================================ /** * Renders markdown content with code block enhancements. */ async renderContent(el: HTMLElement, markdown: string): Promise { el.empty(); try { // Replace image embeds with HTML img tags before rendering const processedMarkdown = replaceImageEmbedsWithHtml( markdown, this.app, this.plugin.settings.mediaFolder ); await MarkdownRenderer.renderMarkdown(processedMarkdown, el, '', this.component); // Wrap pre elements and move buttons outside scroll area el.querySelectorAll('pre').forEach((pre) => { // Skip if already wrapped if (pre.parentElement?.classList.contains('claudian-code-wrapper')) return; // Create wrapper const wrapper = createEl('div', { cls: 'claudian-code-wrapper' }); pre.parentElement?.insertBefore(wrapper, pre); wrapper.appendChild(pre); // Check for language class and add label const code = pre.querySelector('code[class*="language-"]'); if (code) { const match = code.className.match(/language-(\w+)/); if (match) { wrapper.classList.add('has-language'); const label = createEl('span', { cls: 'claudian-code-lang-label', text: match[1], }); wrapper.appendChild(label); label.addEventListener('click', async () => { try { await navigator.clipboard.writeText(code.textContent || ''); label.setText('copied!'); setTimeout(() => label.setText(match[1]), 1500); } catch { // Clipboard API may fail in non-secure contexts } }); } } // Move Obsidian's copy button outside pre into wrapper const copyBtn = pre.querySelector('.copy-code-button'); if (copyBtn) { wrapper.appendChild(copyBtn); } }); // Process file paths to make them clickable links processFileLinks(this.app, el); } catch { el.createDiv({ cls: 'claudian-render-error', text: 'Failed to render message content.', }); } } // ============================================ // Copy Button // ============================================ /** Clipboard icon SVG for copy button. */ private static readonly COPY_ICON = ``; /** * Adds a copy button to a text block. * Button shows clipboard icon on hover, changes to "copied!" on click. * @param textEl The rendered text element * @param markdown The original markdown content to copy */ addTextCopyButton(textEl: HTMLElement, markdown: string): void { const copyBtn = textEl.createSpan({ cls: 'claudian-text-copy-btn' }); copyBtn.innerHTML = MessageRenderer.COPY_ICON; let feedbackTimeout: ReturnType | null = null; copyBtn.addEventListener('click', async (e) => { e.stopPropagation(); try { await navigator.clipboard.writeText(markdown); } catch { // Clipboard API may fail in non-secure contexts return; } // Clear any pending timeout from rapid clicks if (feedbackTimeout) { clearTimeout(feedbackTimeout); } // Show "copied!" feedback copyBtn.innerHTML = ''; copyBtn.setText('copied!'); copyBtn.classList.add('copied'); feedbackTimeout = setTimeout(() => { copyBtn.innerHTML = MessageRenderer.COPY_ICON; copyBtn.classList.remove('copied'); feedbackTimeout = null; }, 1500); }); } refreshActionButtons(msg: ChatMessage, allMessages?: ChatMessage[], index?: number): void { if (!msg.sdkUserUuid) return; if (!this.isRewindEligible(allMessages, index)) return; const msgEl = this.liveMessageEls.get(msg.id); if (!msgEl) return; if (this.rewindCallback && !msgEl.querySelector('.claudian-message-rewind-btn')) { this.addRewindButton(msgEl, msg.id); } if (this.forkCallback && !msgEl.querySelector('.claudian-message-fork-btn')) { this.addForkButton(msgEl, msg.id); } this.cleanupLiveMessageEl(msg.id, msgEl); } private cleanupLiveMessageEl(msgId: string, msgEl: HTMLElement): void { const needsRewind = this.rewindCallback && !msgEl.querySelector('.claudian-message-rewind-btn'); const needsFork = this.forkCallback && !msgEl.querySelector('.claudian-message-fork-btn'); if (!needsRewind && !needsFork) { this.liveMessageEls.delete(msgId); } } private getOrCreateActionsToolbar(msgEl: HTMLElement): HTMLElement { const existing = msgEl.querySelector('.claudian-user-msg-actions') as HTMLElement | null; if (existing) return existing; return msgEl.createDiv({ cls: 'claudian-user-msg-actions' }); } private addUserCopyButton(msgEl: HTMLElement, content: string): void { const toolbar = this.getOrCreateActionsToolbar(msgEl); const copyBtn = toolbar.createSpan({ cls: 'claudian-user-msg-copy-btn' }); copyBtn.innerHTML = MessageRenderer.COPY_ICON; copyBtn.setAttribute('aria-label', 'Copy message'); let feedbackTimeout: ReturnType | null = null; copyBtn.addEventListener('click', async (e) => { e.stopPropagation(); try { await navigator.clipboard.writeText(content); } catch { return; } if (feedbackTimeout) clearTimeout(feedbackTimeout); copyBtn.innerHTML = ''; copyBtn.setText('copied!'); copyBtn.classList.add('copied'); feedbackTimeout = setTimeout(() => { copyBtn.innerHTML = MessageRenderer.COPY_ICON; copyBtn.classList.remove('copied'); feedbackTimeout = null; }, 1500); }); } private addRewindButton(msgEl: HTMLElement, messageId: string): void { const toolbar = this.getOrCreateActionsToolbar(msgEl); const btn = toolbar.createSpan({ cls: 'claudian-message-rewind-btn' }); if (toolbar.firstChild !== btn) toolbar.insertBefore(btn, toolbar.firstChild); btn.innerHTML = MessageRenderer.REWIND_ICON; btn.setAttribute('aria-label', t('chat.rewind.ariaLabel')); btn.addEventListener('click', async (e) => { e.stopPropagation(); try { await this.rewindCallback?.(messageId); } catch (err) { new Notice(t('chat.rewind.failed', { error: err instanceof Error ? err.message : 'Unknown error' })); } }); } private addForkButton(msgEl: HTMLElement, messageId: string): void { const toolbar = this.getOrCreateActionsToolbar(msgEl); const btn = toolbar.createSpan({ cls: 'claudian-message-fork-btn' }); if (toolbar.firstChild !== btn) toolbar.insertBefore(btn, toolbar.firstChild); btn.innerHTML = MessageRenderer.FORK_ICON; btn.setAttribute('aria-label', t('chat.fork.ariaLabel')); btn.addEventListener('click', async (e) => { e.stopPropagation(); try { await this.forkCallback?.(messageId); } catch (err) { new Notice(t('chat.fork.failed', { error: err instanceof Error ? err.message : 'Unknown error' })); } }); } // ============================================ // Utilities // ============================================ /** Scrolls messages container to bottom. */ scrollToBottom(): void { this.messagesEl.scrollTop = this.messagesEl.scrollHeight; } /** Scrolls to bottom if already near bottom (within threshold). */ scrollToBottomIfNeeded(threshold = 100): void { const { scrollTop, scrollHeight, clientHeight } = this.messagesEl; const isNearBottom = scrollHeight - scrollTop - clientHeight < threshold; if (isNearBottom) { requestAnimationFrame(() => { this.messagesEl.scrollTop = this.messagesEl.scrollHeight; }); } } } ================================================ FILE: src/features/chat/rendering/SubagentRenderer.ts ================================================ import { setIcon } from 'obsidian'; import { getToolIcon, TOOL_TASK } from '../../../core/tools'; import type { SubagentInfo, ToolCallInfo } from '../../../core/types'; import { setupCollapsible } from './collapsible'; import { getToolLabel, getToolName, getToolSummary, renderExpandedContent, setToolIcon, } from './ToolCallRenderer'; interface SubagentToolView { wrapperEl: HTMLElement; nameEl: HTMLElement; summaryEl: HTMLElement; statusEl: HTMLElement; contentEl: HTMLElement; } interface SubagentSection { wrapperEl: HTMLElement; bodyEl: HTMLElement; } export interface SubagentState { wrapperEl: HTMLElement; contentEl: HTMLElement; headerEl: HTMLElement; labelEl: HTMLElement; countEl: HTMLElement; statusEl: HTMLElement; promptSectionEl: HTMLElement; promptBodyEl: HTMLElement; toolsContainerEl: HTMLElement; resultSectionEl: HTMLElement | null; resultBodyEl: HTMLElement | null; toolElements: Map; info: SubagentInfo; } const SUBAGENT_TOOL_STATUS_ICONS: Partial> = { completed: 'check', error: 'x', blocked: 'shield-off', }; function extractTaskDescription(input: Record): string { return (input.description as string) || 'Subagent task'; } function extractTaskPrompt(input: Record): string { return (input.prompt as string) || ''; } function truncateDescription(description: string, maxLength = 40): string { if (description.length <= maxLength) return description; return description.substring(0, maxLength) + '...'; } function createSection(parentEl: HTMLElement, title: string, bodyClass?: string): SubagentSection { const wrapperEl = parentEl.createDiv({ cls: 'claudian-subagent-section' }); const headerEl = wrapperEl.createDiv({ cls: 'claudian-subagent-section-header' }); headerEl.setAttribute('tabindex', '0'); headerEl.setAttribute('role', 'button'); const titleEl = headerEl.createDiv({ cls: 'claudian-subagent-section-title' }); titleEl.setText(title); const bodyEl = wrapperEl.createDiv({ cls: 'claudian-subagent-section-body' }); if (bodyClass) bodyEl.addClass(bodyClass); const state = { isExpanded: false }; setupCollapsible(wrapperEl, headerEl, bodyEl, state, { baseAriaLabel: title, }); return { wrapperEl, bodyEl }; } function setPromptText(promptBodyEl: HTMLElement, prompt: string): void { promptBodyEl.empty(); const textEl = promptBodyEl.createDiv({ cls: 'claudian-subagent-prompt-text' }); textEl.setText(prompt || 'No prompt provided'); } function updateSyncHeaderAria(state: SubagentState): void { const toolCount = state.info.toolCalls.length; state.headerEl.setAttribute( 'aria-label', `Subagent task: ${truncateDescription(state.info.description)} - ${toolCount} tool uses - Status: ${state.info.status} - click to expand` ); state.statusEl.setAttribute('aria-label', `Status: ${state.info.status}`); } function renderSubagentToolContent(contentEl: HTMLElement, toolCall: ToolCallInfo): void { contentEl.empty(); if (!toolCall.result) { const emptyEl = contentEl.createDiv({ cls: 'claudian-subagent-tool-empty' }); emptyEl.setText(toolCall.status === 'running' ? 'Running...' : 'No output recorded'); return; } renderExpandedContent(contentEl, toolCall.name, toolCall.result); } function setSubagentToolStatus(view: SubagentToolView, status: ToolCallInfo['status']): void { view.statusEl.className = 'claudian-subagent-tool-status'; view.statusEl.addClass(`status-${status}`); view.statusEl.empty(); view.statusEl.setAttribute('aria-label', `Status: ${status}`); const statusIcon = SUBAGENT_TOOL_STATUS_ICONS[status]; if (statusIcon) { setIcon(view.statusEl, statusIcon); } } function updateSubagentToolView(view: SubagentToolView, toolCall: ToolCallInfo): void { view.wrapperEl.className = `claudian-subagent-tool-item claudian-subagent-tool-${toolCall.status}`; view.nameEl.setText(getToolName(toolCall.name, toolCall.input)); view.summaryEl.setText(getToolSummary(toolCall.name, toolCall.input)); setSubagentToolStatus(view, toolCall.status); renderSubagentToolContent(view.contentEl, toolCall); } function createSubagentToolView(parentEl: HTMLElement, toolCall: ToolCallInfo): SubagentToolView { const wrapperEl = parentEl.createDiv({ cls: `claudian-subagent-tool-item claudian-subagent-tool-${toolCall.status}`, }); wrapperEl.dataset.toolId = toolCall.id; const headerEl = wrapperEl.createDiv({ cls: 'claudian-subagent-tool-header' }); headerEl.setAttribute('tabindex', '0'); headerEl.setAttribute('role', 'button'); const iconEl = headerEl.createDiv({ cls: 'claudian-subagent-tool-icon' }); iconEl.setAttribute('aria-hidden', 'true'); setToolIcon(iconEl, toolCall.name); const nameEl = headerEl.createDiv({ cls: 'claudian-subagent-tool-name' }); const summaryEl = headerEl.createDiv({ cls: 'claudian-subagent-tool-summary' }); const statusEl = headerEl.createDiv({ cls: 'claudian-subagent-tool-status' }); const contentEl = wrapperEl.createDiv({ cls: 'claudian-subagent-tool-content' }); const collapseState = { isExpanded: toolCall.isExpanded ?? false }; setupCollapsible(wrapperEl, headerEl, contentEl, collapseState, { initiallyExpanded: toolCall.isExpanded ?? false, onToggle: (expanded) => { toolCall.isExpanded = expanded; }, baseAriaLabel: getToolLabel(toolCall.name, toolCall.input), }); const view: SubagentToolView = { wrapperEl, nameEl, summaryEl, statusEl, contentEl, }; updateSubagentToolView(view, toolCall); return view; } function ensureResultSection(state: SubagentState): SubagentSection { if (state.resultSectionEl && state.resultBodyEl) { return { wrapperEl: state.resultSectionEl, bodyEl: state.resultBodyEl }; } const section = createSection(state.contentEl, 'Result', 'claudian-subagent-result-body'); section.wrapperEl.addClass('claudian-subagent-section-result'); state.resultSectionEl = section.wrapperEl; state.resultBodyEl = section.bodyEl; return section; } function setResultText(state: SubagentState, text: string): void { const section = ensureResultSection(state); section.bodyEl.empty(); const resultEl = section.bodyEl.createDiv({ cls: 'claudian-subagent-result-output' }); resultEl.setText(text); } function hydrateSyncSubagentStateFromStored(state: SubagentState, subagent: SubagentInfo): void { state.info.description = subagent.description; state.info.prompt = subagent.prompt; state.info.mode = subagent.mode; state.info.status = subagent.status; state.info.result = subagent.result; state.labelEl.setText(truncateDescription(subagent.description)); setPromptText(state.promptBodyEl, subagent.prompt || ''); for (const originalToolCall of subagent.toolCalls) { const toolCall: ToolCallInfo = { ...originalToolCall, input: { ...originalToolCall.input }, }; addSubagentToolCall(state, toolCall); if (toolCall.status !== 'running' || toolCall.result) { updateSubagentToolResult(state, toolCall.id, toolCall); } } if (subagent.status === 'completed' || subagent.status === 'error') { const fallback = subagent.status === 'error' ? 'ERROR' : 'DONE'; finalizeSubagentBlock(state, subagent.result || fallback, subagent.status === 'error'); } else { state.statusEl.className = 'claudian-subagent-status status-running'; state.statusEl.empty(); updateSyncHeaderAria(state); } } export function createSubagentBlock( parentEl: HTMLElement, taskToolId: string, taskInput: Record ): SubagentState { const description = extractTaskDescription(taskInput); const prompt = extractTaskPrompt(taskInput); const info: SubagentInfo = { id: taskToolId, description, prompt, status: 'running', toolCalls: [], isExpanded: false, }; const wrapperEl = parentEl.createDiv({ cls: 'claudian-subagent-list' }); wrapperEl.dataset.subagentId = taskToolId; const headerEl = wrapperEl.createDiv({ cls: 'claudian-subagent-header' }); headerEl.setAttribute('tabindex', '0'); headerEl.setAttribute('role', 'button'); const iconEl = headerEl.createDiv({ cls: 'claudian-subagent-icon' }); iconEl.setAttribute('aria-hidden', 'true'); setIcon(iconEl, getToolIcon(TOOL_TASK)); const labelEl = headerEl.createDiv({ cls: 'claudian-subagent-label' }); labelEl.setText(truncateDescription(description)); const countEl = headerEl.createDiv({ cls: 'claudian-subagent-count' }); countEl.setText('0 tool uses'); const statusEl = headerEl.createDiv({ cls: 'claudian-subagent-status status-running' }); statusEl.setAttribute('aria-label', 'Status: running'); const contentEl = wrapperEl.createDiv({ cls: 'claudian-subagent-content' }); const promptSection = createSection(contentEl, 'Prompt', 'claudian-subagent-prompt-body'); promptSection.wrapperEl.addClass('claudian-subagent-section-prompt'); setPromptText(promptSection.bodyEl, prompt); const toolsContainerEl = contentEl.createDiv({ cls: 'claudian-subagent-tools' }); setupCollapsible(wrapperEl, headerEl, contentEl, info); const state: SubagentState = { wrapperEl, contentEl, headerEl, labelEl, countEl, statusEl, promptSectionEl: promptSection.wrapperEl, promptBodyEl: promptSection.bodyEl, toolsContainerEl, resultSectionEl: null, resultBodyEl: null, toolElements: new Map(), info, }; updateSyncHeaderAria(state); return state; } export function addSubagentToolCall( state: SubagentState, toolCall: ToolCallInfo ): void { state.info.toolCalls.push(toolCall); const toolCount = state.info.toolCalls.length; state.countEl.setText(`${toolCount} tool uses`); const toolView = createSubagentToolView(state.toolsContainerEl, toolCall); state.toolElements.set(toolCall.id, toolView); updateSyncHeaderAria(state); } export function updateSubagentToolResult( state: SubagentState, toolId: string, toolCall: ToolCallInfo ): void { const idx = state.info.toolCalls.findIndex(tc => tc.id === toolId); if (idx !== -1) { state.info.toolCalls[idx] = toolCall; } const toolView = state.toolElements.get(toolId); if (!toolView) { return; } updateSubagentToolView(toolView, toolCall); } export function finalizeSubagentBlock( state: SubagentState, result: string, isError: boolean ): void { state.info.status = isError ? 'error' : 'completed'; state.info.result = result; state.labelEl.setText(truncateDescription(state.info.description)); state.countEl.setText(`${state.info.toolCalls.length} tool uses`); state.statusEl.className = 'claudian-subagent-status'; state.statusEl.addClass(`status-${state.info.status}`); state.statusEl.empty(); if (state.info.status === 'completed') { setIcon(state.statusEl, 'check'); state.wrapperEl.removeClass('error'); state.wrapperEl.addClass('done'); } else { setIcon(state.statusEl, 'x'); state.wrapperEl.removeClass('done'); state.wrapperEl.addClass('error'); } const finalText = result?.trim() ? result : (isError ? 'ERROR' : 'DONE'); setResultText(state, finalText); updateSyncHeaderAria(state); } export function renderStoredSubagent( parentEl: HTMLElement, subagent: SubagentInfo ): HTMLElement { const state = createSubagentBlock(parentEl, subagent.id, { description: subagent.description, prompt: subagent.prompt, }); hydrateSyncSubagentStateFromStored(state, subagent); return state.wrapperEl; } export interface AsyncSubagentState { wrapperEl: HTMLElement; contentEl: HTMLElement; headerEl: HTMLElement; labelEl: HTMLElement; statusTextEl: HTMLElement; // Running / Completed / Error / Orphaned statusEl: HTMLElement; info: SubagentInfo; } function setAsyncWrapperStatus(wrapperEl: HTMLElement, status: string): void { const classes = ['pending', 'running', 'awaiting', 'completed', 'error', 'orphaned', 'async']; classes.forEach(cls => wrapperEl.removeClass(cls)); wrapperEl.addClass('async'); wrapperEl.addClass(status); } function getAsyncDisplayStatus(asyncStatus: string | undefined): 'running' | 'completed' | 'error' | 'orphaned' { switch (asyncStatus) { case 'completed': return 'completed'; case 'error': return 'error'; case 'orphaned': return 'orphaned'; default: return 'running'; } } function getAsyncStatusText(asyncStatus: string | undefined): string { switch (asyncStatus) { case 'pending': return 'Initializing'; case 'completed': return ''; // Just show tick icon, no text case 'error': return 'Error'; case 'orphaned': return 'Orphaned'; default: return 'Running in background'; } } function getAsyncStatusAriaLabel(asyncStatus: string | undefined): string { switch (asyncStatus) { case 'pending': return 'Initializing'; case 'completed': return 'Completed'; case 'error': return 'Error'; case 'orphaned': return 'Orphaned'; default: return 'Running in background'; } } function updateAsyncLabel(state: AsyncSubagentState): void { state.labelEl.setText(truncateDescription(state.info.description)); const statusLabel = getAsyncStatusAriaLabel(state.info.asyncStatus); state.headerEl.setAttribute( 'aria-label', `Background task: ${truncateDescription(state.info.description)} - ${statusLabel} - click to expand` ); } function renderAsyncContentLikeSync( contentEl: HTMLElement, subagent: SubagentInfo, displayStatus: 'running' | 'completed' | 'error' | 'orphaned' ): void { contentEl.empty(); const promptSection = createSection(contentEl, 'Prompt', 'claudian-subagent-prompt-body'); promptSection.wrapperEl.addClass('claudian-subagent-section-prompt'); setPromptText(promptSection.bodyEl, subagent.prompt || ''); const toolsContainerEl = contentEl.createDiv({ cls: 'claudian-subagent-tools' }); for (const originalToolCall of subagent.toolCalls) { const toolCall: ToolCallInfo = { ...originalToolCall, input: { ...originalToolCall.input }, }; createSubagentToolView(toolsContainerEl, toolCall); } if (displayStatus === 'running') { return; } const resultSection = createSection(contentEl, 'Result', 'claudian-subagent-result-body'); resultSection.wrapperEl.addClass('claudian-subagent-section-result'); const resultEl = resultSection.bodyEl.createDiv({ cls: 'claudian-subagent-result-output' }); if (displayStatus === 'orphaned') { resultEl.setText(subagent.result || 'Conversation ended before task completed'); return; } const fallback = displayStatus === 'error' ? 'ERROR' : 'DONE'; const finalText = subagent.result?.trim() ? subagent.result : fallback; resultEl.setText(finalText); } /** * Create an async subagent block for a background Agent tool call. * Expandable to show the task prompt. Collapsed by default. */ export function createAsyncSubagentBlock( parentEl: HTMLElement, taskToolId: string, taskInput: Record ): AsyncSubagentState { const description = (taskInput.description as string) || 'Background task'; const prompt = (taskInput.prompt as string) || ''; const info: SubagentInfo = { id: taskToolId, description, prompt, mode: 'async', status: 'running', toolCalls: [], isExpanded: false, asyncStatus: 'pending', }; const wrapperEl = parentEl.createDiv({ cls: 'claudian-subagent-list' }); setAsyncWrapperStatus(wrapperEl, 'pending'); wrapperEl.dataset.asyncSubagentId = taskToolId; const headerEl = wrapperEl.createDiv({ cls: 'claudian-subagent-header' }); headerEl.setAttribute('tabindex', '0'); headerEl.setAttribute('role', 'button'); headerEl.setAttribute('aria-expanded', 'false'); headerEl.setAttribute('aria-label', `Background task: ${description} - Initializing - click to expand`); const iconEl = headerEl.createDiv({ cls: 'claudian-subagent-icon' }); iconEl.setAttribute('aria-hidden', 'true'); setIcon(iconEl, getToolIcon(TOOL_TASK)); const labelEl = headerEl.createDiv({ cls: 'claudian-subagent-label' }); labelEl.setText(truncateDescription(description)); const statusTextEl = headerEl.createDiv({ cls: 'claudian-subagent-status-text' }); statusTextEl.setText('Initializing'); const statusEl = headerEl.createDiv({ cls: 'claudian-subagent-status status-running' }); statusEl.setAttribute('aria-label', 'Status: running'); const contentEl = wrapperEl.createDiv({ cls: 'claudian-subagent-content' }); renderAsyncContentLikeSync(contentEl, info, 'running'); setupCollapsible(wrapperEl, headerEl, contentEl, info); return { wrapperEl, contentEl, headerEl, labelEl, statusTextEl, statusEl, info, }; } export function updateAsyncSubagentRunning( state: AsyncSubagentState, agentId: string ): void { state.info.asyncStatus = 'running'; state.info.agentId = agentId; setAsyncWrapperStatus(state.wrapperEl, 'running'); updateAsyncLabel(state); state.statusTextEl.setText('Running in background'); renderAsyncContentLikeSync(state.contentEl, state.info, 'running'); } export function finalizeAsyncSubagent( state: AsyncSubagentState, result: string, isError: boolean ): void { state.info.asyncStatus = isError ? 'error' : 'completed'; state.info.status = isError ? 'error' : 'completed'; state.info.result = result; setAsyncWrapperStatus(state.wrapperEl, isError ? 'error' : 'completed'); updateAsyncLabel(state); state.statusTextEl.setText(isError ? 'Error' : ''); state.statusEl.className = 'claudian-subagent-status'; state.statusEl.addClass(`status-${isError ? 'error' : 'completed'}`); state.statusEl.empty(); if (isError) { setIcon(state.statusEl, 'x'); } else { setIcon(state.statusEl, 'check'); } if (isError) { state.wrapperEl.addClass('error'); } else { state.wrapperEl.addClass('done'); } renderAsyncContentLikeSync(state.contentEl, state.info, isError ? 'error' : 'completed'); } export function markAsyncSubagentOrphaned(state: AsyncSubagentState): void { state.info.asyncStatus = 'orphaned'; state.info.status = 'error'; state.info.result = 'Conversation ended before task completed'; setAsyncWrapperStatus(state.wrapperEl, 'orphaned'); updateAsyncLabel(state); state.statusTextEl.setText('Orphaned'); state.statusEl.className = 'claudian-subagent-status status-error'; state.statusEl.empty(); setIcon(state.statusEl, 'alert-circle'); state.wrapperEl.addClass('error'); state.wrapperEl.addClass('orphaned'); renderAsyncContentLikeSync(state.contentEl, state.info, 'orphaned'); } /** * Render a stored async subagent from conversation history. * Expandable to show the task prompt. Collapsed by default. */ export function renderStoredAsyncSubagent( parentEl: HTMLElement, subagent: SubagentInfo ): HTMLElement { const wrapperEl = parentEl.createDiv({ cls: 'claudian-subagent-list' }); const displayStatus = getAsyncDisplayStatus(subagent.asyncStatus); setAsyncWrapperStatus(wrapperEl, displayStatus); if (displayStatus === 'completed') { wrapperEl.addClass('done'); } else if (displayStatus === 'error' || displayStatus === 'orphaned') { wrapperEl.addClass('error'); } wrapperEl.dataset.asyncSubagentId = subagent.id; const statusText = getAsyncStatusText(subagent.asyncStatus); const statusAriaLabel = getAsyncStatusAriaLabel(subagent.asyncStatus); const headerEl = wrapperEl.createDiv({ cls: 'claudian-subagent-header' }); headerEl.setAttribute('tabindex', '0'); headerEl.setAttribute('role', 'button'); headerEl.setAttribute('aria-expanded', 'false'); headerEl.setAttribute( 'aria-label', `Background task: ${subagent.description} - ${statusAriaLabel} - click to expand` ); const iconEl = headerEl.createDiv({ cls: 'claudian-subagent-icon' }); iconEl.setAttribute('aria-hidden', 'true'); setIcon(iconEl, getToolIcon(TOOL_TASK)); const labelEl = headerEl.createDiv({ cls: 'claudian-subagent-label' }); labelEl.setText(truncateDescription(subagent.description)); const statusTextEl = headerEl.createDiv({ cls: 'claudian-subagent-status-text' }); statusTextEl.setText(statusText); let statusIconClass: string; switch (displayStatus) { case 'error': case 'orphaned': statusIconClass = 'status-error'; break; case 'completed': statusIconClass = 'status-completed'; break; default: statusIconClass = 'status-running'; } const statusEl = headerEl.createDiv({ cls: `claudian-subagent-status ${statusIconClass}` }); statusEl.setAttribute('aria-label', `Status: ${statusAriaLabel}`); switch (displayStatus) { case 'completed': setIcon(statusEl, 'check'); break; case 'error': setIcon(statusEl, 'x'); break; case 'orphaned': setIcon(statusEl, 'alert-circle'); break; } const contentEl = wrapperEl.createDiv({ cls: 'claudian-subagent-content' }); renderAsyncContentLikeSync(contentEl, subagent, displayStatus); const state = { isExpanded: false }; setupCollapsible(wrapperEl, headerEl, contentEl, state); return wrapperEl; } ================================================ FILE: src/features/chat/rendering/ThinkingBlockRenderer.ts ================================================ import { collapseElement, setupCollapsible } from './collapsible'; export type RenderContentFn = (el: HTMLElement, markdown: string) => Promise; export interface ThinkingBlockState { wrapperEl: HTMLElement; contentEl: HTMLElement; labelEl: HTMLElement; content: string; startTime: number; timerInterval: ReturnType | null; isExpanded: boolean; } export function createThinkingBlock( parentEl: HTMLElement, renderContent: RenderContentFn ): ThinkingBlockState { const wrapperEl = parentEl.createDiv({ cls: 'claudian-thinking-block' }); // Header (clickable to expand/collapse) const header = wrapperEl.createDiv({ cls: 'claudian-thinking-header' }); header.setAttribute('tabindex', '0'); header.setAttribute('role', 'button'); header.setAttribute('aria-expanded', 'false'); header.setAttribute('aria-label', 'Extended thinking - click to expand'); // Label with timer const labelEl = header.createSpan({ cls: 'claudian-thinking-label' }); const startTime = Date.now(); labelEl.setText('Thinking 0s...'); // Start timer interval to update label every second const timerInterval = setInterval(() => { const elapsed = Math.floor((Date.now() - startTime) / 1000); labelEl.setText(`Thinking ${elapsed}s...`); }, 1000); // Collapsible content (collapsed by default) const contentEl = wrapperEl.createDiv({ cls: 'claudian-thinking-content' }); // Create state object first so toggle can reference it const state: ThinkingBlockState = { wrapperEl, contentEl, labelEl, content: '', startTime, timerInterval, isExpanded: false, }; // Setup collapsible behavior (handles click, keyboard, ARIA, CSS) setupCollapsible(wrapperEl, header, contentEl, state); return state; } export async function appendThinkingContent( state: ThinkingBlockState, content: string, renderContent: RenderContentFn ) { state.content += content; await renderContent(state.contentEl, state.content); } export function finalizeThinkingBlock(state: ThinkingBlockState): number { // Stop the timer if (state.timerInterval) { clearInterval(state.timerInterval); state.timerInterval = null; } // Calculate final duration const durationSeconds = Math.floor((Date.now() - state.startTime) / 1000); // Update label to show final duration (without "...") state.labelEl.setText(`Thought for ${durationSeconds}s`); // Collapse when done and sync state const header = state.wrapperEl.querySelector('.claudian-thinking-header'); if (header) { collapseElement(state.wrapperEl, header as HTMLElement, state.contentEl, state); } return durationSeconds; } export function cleanupThinkingBlock(state: ThinkingBlockState | null) { if (state?.timerInterval) { clearInterval(state.timerInterval); } } export function renderStoredThinkingBlock( parentEl: HTMLElement, content: string, durationSeconds: number | undefined, renderContent: RenderContentFn ): HTMLElement { const wrapperEl = parentEl.createDiv({ cls: 'claudian-thinking-block' }); // Header (clickable to expand/collapse) const header = wrapperEl.createDiv({ cls: 'claudian-thinking-header' }); header.setAttribute('tabindex', '0'); header.setAttribute('role', 'button'); header.setAttribute('aria-label', 'Extended thinking - click to expand'); // Label with duration const labelEl = header.createSpan({ cls: 'claudian-thinking-label' }); const labelText = durationSeconds !== undefined ? `Thought for ${durationSeconds}s` : 'Thought'; labelEl.setText(labelText); // Collapsible content const contentEl = wrapperEl.createDiv({ cls: 'claudian-thinking-content' }); renderContent(contentEl, content); // Setup collapsible behavior (handles click, keyboard, ARIA, CSS) const state = { isExpanded: false }; setupCollapsible(wrapperEl, header, contentEl, state); return wrapperEl; } ================================================ FILE: src/features/chat/rendering/TodoListRenderer.ts ================================================ export { extractLastTodosFromMessages, parseTodoInput, type TodoItem, } from '../../../core/tools/todo'; ================================================ FILE: src/features/chat/rendering/ToolCallRenderer.ts ================================================ import { setIcon } from 'obsidian'; import { extractResolvedAnswersFromResultText, type TodoItem } from '../../../core/tools'; import { getToolIcon, MCP_ICON_MARKER } from '../../../core/tools/toolIcons'; import { TOOL_ASK_USER_QUESTION, TOOL_BASH, TOOL_EDIT, TOOL_ENTER_PLAN_MODE, TOOL_EXIT_PLAN_MODE, TOOL_GLOB, TOOL_GREP, TOOL_LS, TOOL_READ, TOOL_SKILL, TOOL_TODO_WRITE, TOOL_TOOL_SEARCH, TOOL_WEB_FETCH, TOOL_WEB_SEARCH, TOOL_WRITE, } from '../../../core/tools/toolNames'; import type { ToolCallInfo } from '../../../core/types'; import { MCP_ICON_SVG } from '../../../shared/icons'; import { setupCollapsible } from './collapsible'; import { renderTodoItems } from './todoUtils'; export function setToolIcon(el: HTMLElement, name: string): void { const icon = getToolIcon(name); if (icon === MCP_ICON_MARKER) { el.innerHTML = MCP_ICON_SVG; } else { setIcon(el, icon); } } export function getToolName(name: string, input: Record): string { switch (name) { case TOOL_TODO_WRITE: { const todos = input.todos as Array<{ status: string }> | undefined; if (todos && Array.isArray(todos) && todos.length > 0) { const completed = todos.filter(t => t.status === 'completed').length; return `Tasks ${completed}/${todos.length}`; } return 'Tasks'; } case TOOL_ENTER_PLAN_MODE: return 'Entering plan mode'; case TOOL_EXIT_PLAN_MODE: return 'Plan complete'; default: return name; } } export function getToolSummary(name: string, input: Record): string { switch (name) { case TOOL_READ: case TOOL_WRITE: case TOOL_EDIT: { const filePath = (input.file_path as string) || ''; return fileNameOnly(filePath); } case TOOL_BASH: { const cmd = (input.command as string) || ''; return truncateText(cmd, 60); } case TOOL_GLOB: case TOOL_GREP: return (input.pattern as string) || ''; case TOOL_WEB_SEARCH: return truncateText((input.query as string) || '', 60); case TOOL_WEB_FETCH: return truncateText((input.url as string) || '', 60); case TOOL_LS: return fileNameOnly((input.path as string) || '.'); case TOOL_SKILL: return (input.skill as string) || ''; case TOOL_TOOL_SEARCH: return truncateText(parseToolSearchQuery(input.query as string | undefined), 60); case TOOL_TODO_WRITE: return ''; default: return ''; } } /** Combined name+summary for ARIA labels (collapsible regions need a single descriptive phrase). */ export function getToolLabel(name: string, input: Record): string { switch (name) { case TOOL_READ: return `Read: ${shortenPath(input.file_path as string) || 'file'}`; case TOOL_WRITE: return `Write: ${shortenPath(input.file_path as string) || 'file'}`; case TOOL_EDIT: return `Edit: ${shortenPath(input.file_path as string) || 'file'}`; case TOOL_BASH: { const cmd = (input.command as string) || 'command'; return `Bash: ${cmd.length > 40 ? cmd.substring(0, 40) + '...' : cmd}`; } case TOOL_GLOB: return `Glob: ${input.pattern || 'files'}`; case TOOL_GREP: return `Grep: ${input.pattern || 'pattern'}`; case TOOL_WEB_SEARCH: { const query = (input.query as string) || 'search'; return `WebSearch: ${query.length > 40 ? query.substring(0, 40) + '...' : query}`; } case TOOL_WEB_FETCH: { const url = (input.url as string) || 'url'; return `WebFetch: ${url.length > 40 ? url.substring(0, 40) + '...' : url}`; } case TOOL_LS: return `LS: ${shortenPath(input.path as string) || '.'}`; case TOOL_TODO_WRITE: { const todos = input.todos as Array<{ status: string }> | undefined; if (todos && Array.isArray(todos)) { const completed = todos.filter(t => t.status === 'completed').length; return `Tasks (${completed}/${todos.length})`; } return 'Tasks'; } case TOOL_SKILL: { const skillName = (input.skill as string) || 'skill'; return `Skill: ${skillName}`; } case TOOL_TOOL_SEARCH: { const tools = parseToolSearchQuery(input.query as string | undefined); return `ToolSearch: ${tools || 'tools'}`; } case TOOL_ENTER_PLAN_MODE: return 'Entering plan mode'; case TOOL_EXIT_PLAN_MODE: return 'Plan complete'; default: return name; } } export function fileNameOnly(filePath: string): string { if (!filePath) return ''; const normalized = filePath.replace(/\\/g, '/'); return normalized.split('/').pop() ?? normalized; } function shortenPath(filePath: string | undefined): string { if (!filePath) return ''; const normalized = filePath.replace(/\\/g, '/'); const parts = normalized.split('/'); if (parts.length <= 3) return normalized; return '.../' + parts.slice(-2).join('/'); } function truncateText(text: string, maxLength: number): string { if (text.length <= maxLength) return text; return text.substring(0, maxLength) + '...'; } function parseToolSearchQuery(query: string | undefined): string { if (!query) return ''; const selectPrefix = 'select:'; const body = query.startsWith(selectPrefix) ? query.slice(selectPrefix.length) : query; return body.split(',').map(s => s.trim()).filter(Boolean).join(', '); } interface WebSearchLink { title: string; url: string; } function parseWebSearchResult(result: string): { links: WebSearchLink[]; summary: string } | null { const linksMatch = result.match(/Links:\s*(\[[\s\S]*?\])(?:\n|$)/); if (!linksMatch) return null; try { const parsed = JSON.parse(linksMatch[1]) as WebSearchLink[]; if (!Array.isArray(parsed) || parsed.length === 0) return null; const linksEndIndex = result.indexOf(linksMatch[0]) + linksMatch[0].length; const summary = result.slice(linksEndIndex).trim(); return { links: parsed.filter(l => l.title && l.url), summary }; } catch { return null; } } function renderWebSearchExpanded(container: HTMLElement, result: string): void { const parsed = parseWebSearchResult(result); if (!parsed || parsed.links.length === 0) { renderLinesExpanded(container, result, 20); return; } const linksEl = container.createDiv({ cls: 'claudian-tool-lines' }); for (const link of parsed.links) { const linkEl = linksEl.createEl('a', { cls: 'claudian-tool-link' }); linkEl.setAttribute('href', link.url); linkEl.setAttribute('target', '_blank'); linkEl.setAttribute('rel', 'noopener noreferrer'); const iconEl = linkEl.createSpan({ cls: 'claudian-tool-link-icon' }); setIcon(iconEl, 'external-link'); linkEl.createSpan({ cls: 'claudian-tool-link-title', text: link.title }); } if (parsed.summary) { const summaryEl = container.createDiv({ cls: 'claudian-tool-web-summary' }); summaryEl.setText(parsed.summary.length > 800 ? parsed.summary.slice(0, 800) + '...' : parsed.summary); } } function renderFileSearchExpanded(container: HTMLElement, result: string): void { const lines = result.split(/\r?\n/).filter(line => line.trim()); if (lines.length === 0) { container.createDiv({ cls: 'claudian-tool-empty', text: 'No matches found' }); return; } renderLinesExpanded(container, result, 15, true); } function renderLinesExpanded( container: HTMLElement, result: string, maxLines: number, hoverable = false ): void { const lines = result.split(/\r?\n/); const truncated = lines.length > maxLines; const displayLines = truncated ? lines.slice(0, maxLines) : lines; const linesEl = container.createDiv({ cls: 'claudian-tool-lines' }); for (const line of displayLines) { const stripped = line.replace(/^\s*\d+→/, ''); const lineEl = linesEl.createDiv({ cls: 'claudian-tool-line' }); if (hoverable) lineEl.addClass('hoverable'); lineEl.setText(stripped || ' '); } if (truncated) { linesEl.createDiv({ cls: 'claudian-tool-truncated', text: `... ${lines.length - maxLines} more lines`, }); } } function renderToolSearchExpanded(container: HTMLElement, result: string): void { let toolNames: string[] = []; try { const parsed = JSON.parse(result) as Array<{ type: string; tool_name: string }>; if (Array.isArray(parsed)) { toolNames = parsed .filter(item => item.type === 'tool_reference' && item.tool_name) .map(item => item.tool_name); } } catch { // Fall back to showing raw result } if (toolNames.length === 0) { renderLinesExpanded(container, result, 20); return; } for (const name of toolNames) { const lineEl = container.createDiv({ cls: 'claudian-tool-search-item' }); const iconEl = lineEl.createSpan({ cls: 'claudian-tool-search-icon' }); setToolIcon(iconEl, name); lineEl.createSpan({ text: name }); } } function renderWebFetchExpanded(container: HTMLElement, result: string): void { const maxChars = 500; const linesEl = container.createDiv({ cls: 'claudian-tool-lines' }); const lineEl = linesEl.createDiv({ cls: 'claudian-tool-line' }); lineEl.style.whiteSpace = 'pre-wrap'; lineEl.style.wordBreak = 'break-word'; if (result.length > maxChars) { lineEl.setText(result.slice(0, maxChars)); linesEl.createDiv({ cls: 'claudian-tool-truncated', text: `... ${result.length - maxChars} more characters`, }); } else { lineEl.setText(result); } } export function renderExpandedContent(container: HTMLElement, toolName: string, result: string | undefined): void { if (!result) { container.createDiv({ cls: 'claudian-tool-empty', text: 'No result' }); return; } switch (toolName) { case TOOL_BASH: renderLinesExpanded(container, result, 20); break; case TOOL_READ: renderLinesExpanded(container, result, 15); break; case TOOL_GLOB: case TOOL_GREP: case TOOL_LS: renderFileSearchExpanded(container, result); break; case TOOL_WEB_SEARCH: renderWebSearchExpanded(container, result); break; case TOOL_WEB_FETCH: renderWebFetchExpanded(container, result); break; case TOOL_TOOL_SEARCH: renderToolSearchExpanded(container, result); break; default: renderLinesExpanded(container, result, 20); break; } } function getTodos(input: Record): TodoItem[] | undefined { const todos = input.todos; if (!todos || !Array.isArray(todos)) return undefined; return todos as TodoItem[]; } function getCurrentTask(input: Record): TodoItem | undefined { const todos = getTodos(input); if (!todos) return undefined; return todos.find(t => t.status === 'in_progress'); } function areAllTodosCompleted(input: Record): boolean { const todos = getTodos(input); if (!todos || todos.length === 0) return false; return todos.every(t => t.status === 'completed'); } function resetStatusElement(statusEl: HTMLElement, statusClass: string, ariaLabel: string): void { statusEl.className = 'claudian-tool-status'; statusEl.empty(); statusEl.addClass(statusClass); statusEl.setAttribute('aria-label', ariaLabel); } const STATUS_ICONS: Record = { completed: 'check', error: 'x', blocked: 'shield-off', }; function setTodoWriteStatus(statusEl: HTMLElement, input: Record): void { const isComplete = areAllTodosCompleted(input); const status = isComplete ? 'completed' : 'running'; const ariaLabel = isComplete ? 'Status: completed' : 'Status: in progress'; resetStatusElement(statusEl, `status-${status}`, ariaLabel); if (isComplete) setIcon(statusEl, 'check'); } function setToolStatus(statusEl: HTMLElement, status: ToolCallInfo['status']): void { resetStatusElement(statusEl, `status-${status}`, `Status: ${status}`); const icon = STATUS_ICONS[status]; if (icon) setIcon(statusEl, icon); } export function renderTodoWriteResult( container: HTMLElement, input: Record ): void { container.empty(); container.addClass('claudian-todo-panel-content'); container.addClass('claudian-todo-list-container'); const todos = input.todos as TodoItem[] | undefined; if (!todos || !Array.isArray(todos)) { const item = container.createSpan({ cls: 'claudian-tool-result-item' }); item.setText('Tasks updated'); return; } renderTodoItems(container, todos); } export function isBlockedToolResult(content: string, isError?: boolean): boolean { const lower = content.toLowerCase(); if (lower.includes('blocked by blocklist')) return true; if (lower.includes('outside the vault')) return true; if (lower.includes('access denied')) return true; if (lower.includes('user denied')) return true; if (lower.includes('approval')) return true; if (isError && lower.includes('deny')) return true; return false; } interface ToolElementStructure { toolEl: HTMLElement; header: HTMLElement; iconEl: HTMLElement; nameEl: HTMLElement; summaryEl: HTMLElement; statusEl: HTMLElement; content: HTMLElement; currentTaskEl: HTMLElement | null; } function createToolElementStructure( parentEl: HTMLElement, toolCall: ToolCallInfo ): ToolElementStructure { const toolEl = parentEl.createDiv({ cls: 'claudian-tool-call' }); const header = toolEl.createDiv({ cls: 'claudian-tool-header' }); header.setAttribute('tabindex', '0'); header.setAttribute('role', 'button'); const iconEl = header.createSpan({ cls: 'claudian-tool-icon' }); iconEl.setAttribute('aria-hidden', 'true'); setToolIcon(iconEl, toolCall.name); const nameEl = header.createSpan({ cls: 'claudian-tool-name' }); nameEl.setText(getToolName(toolCall.name, toolCall.input)); const summaryEl = header.createSpan({ cls: 'claudian-tool-summary' }); summaryEl.setText(getToolSummary(toolCall.name, toolCall.input)); const currentTaskEl = toolCall.name === TOOL_TODO_WRITE ? createCurrentTaskPreview(header, toolCall.input) : null; const statusEl = header.createSpan({ cls: 'claudian-tool-status' }); const content = toolEl.createDiv({ cls: 'claudian-tool-content' }); return { toolEl, header, iconEl, nameEl, summaryEl, statusEl, content, currentTaskEl }; } function formatAnswer(raw: unknown): string { if (Array.isArray(raw)) return raw.join(', '); if (typeof raw === 'string') return raw; return ''; } function resolveAskUserAnswers(toolCall: ToolCallInfo): Record | undefined { if (toolCall.resolvedAnswers) return toolCall.resolvedAnswers as Record; const parsed = extractResolvedAnswersFromResultText(toolCall.result); if (parsed) { toolCall.resolvedAnswers = parsed; return parsed; } return undefined; } function renderAskUserQuestionResult(container: HTMLElement, toolCall: ToolCallInfo): boolean { container.empty(); const questions = toolCall.input.questions as Array<{ question: string }> | undefined; const answers = resolveAskUserAnswers(toolCall); if (!questions || !Array.isArray(questions) || !answers) return false; const reviewEl = container.createDiv({ cls: 'claudian-ask-review' }); for (let i = 0; i < questions.length; i++) { const q = questions[i]; const answer = formatAnswer(answers[q.question]); const pairEl = reviewEl.createDiv({ cls: 'claudian-ask-review-pair' }); pairEl.createDiv({ text: `${i + 1}.`, cls: 'claudian-ask-review-num' }); const bodyEl = pairEl.createDiv({ cls: 'claudian-ask-review-body' }); bodyEl.createDiv({ text: q.question, cls: 'claudian-ask-review-q-text' }); bodyEl.createDiv({ text: answer || 'Not answered', cls: answer ? 'claudian-ask-review-a-text' : 'claudian-ask-review-empty', }); } return true; } function renderAskUserQuestionFallback(container: HTMLElement, toolCall: ToolCallInfo, initialText?: string): void { contentFallback(container, initialText || toolCall.result || 'Waiting for answer...'); } function contentFallback(container: HTMLElement, text: string): void { const resultRow = container.createDiv({ cls: 'claudian-tool-result-row' }); const resultText = resultRow.createSpan({ cls: 'claudian-tool-result-text' }); resultText.setText(text); } function createCurrentTaskPreview( header: HTMLElement, input: Record ): HTMLElement { const currentTaskEl = header.createSpan({ cls: 'claudian-tool-current' }); const currentTask = getCurrentTask(input); if (currentTask) { currentTaskEl.setText(currentTask.activeForm); } return currentTaskEl; } function createTodoToggleHandler( currentTaskEl: HTMLElement | null, statusEl: HTMLElement | null, onExpandChange?: (expanded: boolean) => void ): (expanded: boolean) => void { return (expanded: boolean) => { if (onExpandChange) onExpandChange(expanded); if (currentTaskEl) { currentTaskEl.style.display = expanded ? 'none' : ''; } if (statusEl) { statusEl.style.display = expanded ? 'none' : ''; } }; } function renderToolContent( content: HTMLElement, toolCall: ToolCallInfo, initialText?: string ): void { if (toolCall.name === TOOL_TODO_WRITE) { content.addClass('claudian-tool-content-todo'); renderTodoWriteResult(content, toolCall.input); } else if (toolCall.name === TOOL_ASK_USER_QUESTION) { content.addClass('claudian-tool-content-ask'); if (initialText) { renderAskUserQuestionFallback(content, toolCall, 'Waiting for answer...'); } else if (!renderAskUserQuestionResult(content, toolCall)) { renderAskUserQuestionFallback(content, toolCall); } } else if (initialText) { contentFallback(content, initialText); } else { renderExpandedContent(content, toolCall.name, toolCall.result); } } export function renderToolCall( parentEl: HTMLElement, toolCall: ToolCallInfo, toolCallElements: Map ): HTMLElement { const { toolEl, header, statusEl, content, currentTaskEl } = createToolElementStructure(parentEl, toolCall); toolEl.dataset.toolId = toolCall.id; toolCallElements.set(toolCall.id, toolEl); statusEl.addClass(`status-${toolCall.status}`); statusEl.setAttribute('aria-label', `Status: ${toolCall.status}`); renderToolContent(content, toolCall, 'Running...'); const state = { isExpanded: false }; toolCall.isExpanded = false; const todoStatusEl = toolCall.name === TOOL_TODO_WRITE ? statusEl : null; setupCollapsible(toolEl, header, content, state, { initiallyExpanded: false, onToggle: createTodoToggleHandler(currentTaskEl, todoStatusEl, (expanded) => { toolCall.isExpanded = expanded; }), baseAriaLabel: getToolLabel(toolCall.name, toolCall.input) }); return toolEl; } export function updateToolCallResult( toolId: string, toolCall: ToolCallInfo, toolCallElements: Map ) { const toolEl = toolCallElements.get(toolId); if (!toolEl) return; if (toolCall.name === TOOL_TODO_WRITE) { const statusEl = toolEl.querySelector('.claudian-tool-status') as HTMLElement; if (statusEl) { setTodoWriteStatus(statusEl, toolCall.input); } const content = toolEl.querySelector('.claudian-tool-content') as HTMLElement; if (content) { renderTodoWriteResult(content, toolCall.input); } const nameEl = toolEl.querySelector('.claudian-tool-name') as HTMLElement; if (nameEl) { nameEl.setText(getToolName(toolCall.name, toolCall.input)); } const currentTaskEl = toolEl.querySelector('.claudian-tool-current') as HTMLElement; if (currentTaskEl) { const currentTask = getCurrentTask(toolCall.input); currentTaskEl.setText(currentTask ? currentTask.activeForm : ''); } return; } const statusEl = toolEl.querySelector('.claudian-tool-status') as HTMLElement; if (statusEl) { setToolStatus(statusEl, toolCall.status); } if (toolCall.name === TOOL_ASK_USER_QUESTION) { const content = toolEl.querySelector('.claudian-tool-content') as HTMLElement; if (content) { content.addClass('claudian-tool-content-ask'); if (!renderAskUserQuestionResult(content, toolCall)) { renderAskUserQuestionFallback(content, toolCall); } } return; } const content = toolEl.querySelector('.claudian-tool-content') as HTMLElement; if (content) { content.empty(); renderExpandedContent(content, toolCall.name, toolCall.result); } } /** For stored (non-streaming) tool calls — collapsed by default. */ export function renderStoredToolCall( parentEl: HTMLElement, toolCall: ToolCallInfo ): HTMLElement { const { toolEl, header, statusEl, content, currentTaskEl } = createToolElementStructure(parentEl, toolCall); if (toolCall.name === TOOL_TODO_WRITE) { setTodoWriteStatus(statusEl, toolCall.input); } else { setToolStatus(statusEl, toolCall.status); } renderToolContent(content, toolCall); const state = { isExpanded: false }; const todoStatusEl = toolCall.name === TOOL_TODO_WRITE ? statusEl : null; setupCollapsible(toolEl, header, content, state, { initiallyExpanded: false, onToggle: createTodoToggleHandler(currentTaskEl, todoStatusEl), baseAriaLabel: getToolLabel(toolCall.name, toolCall.input) }); return toolEl; } ================================================ FILE: src/features/chat/rendering/WriteEditRenderer.ts ================================================ import { setIcon } from 'obsidian'; import { getToolIcon } from '../../../core/tools'; import type { ToolCallInfo, ToolDiffData } from '../../../core/types'; import type { DiffLine, DiffStats } from '../../../core/types/diff'; import { setupCollapsible } from './collapsible'; import { renderDiffContent } from './DiffRenderer'; import { fileNameOnly } from './ToolCallRenderer'; export interface WriteEditState { wrapperEl: HTMLElement; contentEl: HTMLElement; headerEl: HTMLElement; nameEl: HTMLElement; summaryEl: HTMLElement; statsEl: HTMLElement; statusEl: HTMLElement; toolCall: ToolCallInfo; isExpanded: boolean; diffLines?: DiffLine[]; } function shortenPath(filePath: string, maxLength = 40): string { if (!filePath) return 'file'; // Normalize path separators for cross-platform support const normalized = filePath.replace(/\\/g, '/'); if (normalized.length <= maxLength) return normalized; const parts = normalized.split('/'); if (parts.length <= 2) { return '...' + normalized.slice(-maxLength + 3); } // Show first dir + ... + filename const filename = parts[parts.length - 1]; const firstDir = parts[0]; const available = maxLength - firstDir.length - filename.length - 5; // 5 for ".../.../" if (available < 0) { return '...' + filename.slice(-maxLength + 3); } return `${firstDir}/.../${filename}`; } function renderDiffStats(statsEl: HTMLElement, stats: DiffStats): void { if (stats.added > 0) { const addedEl = statsEl.createSpan({ cls: 'added' }); addedEl.setText(`+${stats.added}`); } if (stats.removed > 0) { if (stats.added > 0) { statsEl.createSpan({ text: ' ' }); } const removedEl = statsEl.createSpan({ cls: 'removed' }); removedEl.setText(`-${stats.removed}`); } } export function createWriteEditBlock( parentEl: HTMLElement, toolCall: ToolCallInfo ): WriteEditState { const filePath = (toolCall.input.file_path as string) || 'file'; const toolName = toolCall.name; // 'Write' or 'Edit' const wrapperEl = parentEl.createDiv({ cls: 'claudian-write-edit-block' }); wrapperEl.dataset.toolId = toolCall.id; // Header (clickable to collapse/expand) const headerEl = wrapperEl.createDiv({ cls: 'claudian-write-edit-header' }); headerEl.setAttribute('tabindex', '0'); headerEl.setAttribute('role', 'button'); headerEl.setAttribute('aria-label', `${toolName}: ${shortenPath(filePath)} - click to expand`); // File icon const iconEl = headerEl.createDiv({ cls: 'claudian-write-edit-icon' }); iconEl.setAttribute('aria-hidden', 'true'); setIcon(iconEl, getToolIcon(toolName)); const nameEl = headerEl.createDiv({ cls: 'claudian-write-edit-name' }); nameEl.setText(toolName); const summaryEl = headerEl.createDiv({ cls: 'claudian-write-edit-summary' }); summaryEl.setText(fileNameOnly(filePath) || 'file'); // Populated when diff is computed const statsEl = headerEl.createDiv({ cls: 'claudian-write-edit-stats' }); const statusEl = headerEl.createDiv({ cls: 'claudian-write-edit-status status-running' }); statusEl.setAttribute('aria-label', 'Status: running'); // Content area (collapsed by default) const contentEl = wrapperEl.createDiv({ cls: 'claudian-write-edit-content' }); // Initial loading state const loadingRow = contentEl.createDiv({ cls: 'claudian-write-edit-diff-row' }); const loadingEl = loadingRow.createDiv({ cls: 'claudian-write-edit-loading' }); loadingEl.setText('Writing...'); // Create state object const state: WriteEditState = { wrapperEl, contentEl, headerEl, nameEl, summaryEl, statsEl, statusEl, toolCall, isExpanded: false, }; // Setup collapsible behavior (handles click, keyboard, ARIA, CSS) setupCollapsible(wrapperEl, headerEl, contentEl, state); return state; } export function updateWriteEditWithDiff(state: WriteEditState, diffData: ToolDiffData): void { state.statsEl.empty(); state.contentEl.empty(); const { diffLines, stats } = diffData; state.diffLines = diffLines; // Update stats renderDiffStats(state.statsEl, stats); // Render diff content const row = state.contentEl.createDiv({ cls: 'claudian-write-edit-diff-row' }); const diffEl = row.createDiv({ cls: 'claudian-write-edit-diff' }); renderDiffContent(diffEl, diffLines); } export function finalizeWriteEditBlock(state: WriteEditState, isError: boolean): void { // Update status icon - only show icon on error state.statusEl.className = 'claudian-write-edit-status'; state.statusEl.empty(); if (isError) { state.statusEl.addClass('status-error'); setIcon(state.statusEl, 'x'); state.statusEl.setAttribute('aria-label', 'Status: error'); // Show error in content if no diff was shown if (!state.diffLines) { state.contentEl.empty(); const row = state.contentEl.createDiv({ cls: 'claudian-write-edit-diff-row' }); const errorEl = row.createDiv({ cls: 'claudian-write-edit-error' }); errorEl.setText(state.toolCall.result || 'Error'); } } else if (!state.diffLines) { // Success but no diff data - clear the "Writing..." loading text and show DONE state.contentEl.empty(); const row = state.contentEl.createDiv({ cls: 'claudian-write-edit-diff-row' }); const doneEl = row.createDiv({ cls: 'claudian-write-edit-done-text' }); doneEl.setText('DONE'); } // Update wrapper class if (isError) { state.wrapperEl.addClass('error'); } else { state.wrapperEl.addClass('done'); } } export function renderStoredWriteEdit(parentEl: HTMLElement, toolCall: ToolCallInfo): HTMLElement { const filePath = (toolCall.input.file_path as string) || 'file'; const toolName = toolCall.name; const isError = toolCall.status === 'error' || toolCall.status === 'blocked'; const wrapperEl = parentEl.createDiv({ cls: 'claudian-write-edit-block' }); if (isError) { wrapperEl.addClass('error'); } else if (toolCall.status === 'completed') { wrapperEl.addClass('done'); } wrapperEl.dataset.toolId = toolCall.id; // Header const headerEl = wrapperEl.createDiv({ cls: 'claudian-write-edit-header' }); headerEl.setAttribute('tabindex', '0'); headerEl.setAttribute('role', 'button'); // File icon const iconEl = headerEl.createDiv({ cls: 'claudian-write-edit-icon' }); iconEl.setAttribute('aria-hidden', 'true'); setIcon(iconEl, getToolIcon(toolName)); const nameEl = headerEl.createDiv({ cls: 'claudian-write-edit-name' }); nameEl.setText(toolName); const summaryEl = headerEl.createDiv({ cls: 'claudian-write-edit-summary' }); summaryEl.setText(fileNameOnly(filePath) || 'file'); const statsEl = headerEl.createDiv({ cls: 'claudian-write-edit-stats' }); if (toolCall.diffData) { renderDiffStats(statsEl, toolCall.diffData.stats); } // Status indicator - only show icon on error const statusEl = headerEl.createDiv({ cls: 'claudian-write-edit-status' }); if (isError) { statusEl.addClass('status-error'); setIcon(statusEl, 'x'); } // Content const contentEl = wrapperEl.createDiv({ cls: 'claudian-write-edit-content' }); // Render diff if available const row = contentEl.createDiv({ cls: 'claudian-write-edit-diff-row' }); if (toolCall.diffData && toolCall.diffData.diffLines.length > 0) { const diffEl = row.createDiv({ cls: 'claudian-write-edit-diff' }); renderDiffContent(diffEl, toolCall.diffData.diffLines); } else if (isError && toolCall.result) { const errorEl = row.createDiv({ cls: 'claudian-write-edit-error' }); errorEl.setText(toolCall.result); } else { const doneEl = row.createDiv({ cls: 'claudian-write-edit-done-text' }); doneEl.setText(isError ? 'ERROR' : 'DONE'); } // Setup collapsible behavior (handles click, keyboard, ARIA, CSS) const state = { isExpanded: false }; setupCollapsible(wrapperEl, headerEl, contentEl, state); return wrapperEl; } ================================================ FILE: src/features/chat/rendering/collapsible.ts ================================================ export interface CollapsibleState { isExpanded: boolean; } export interface CollapsibleOptions { /** Initial expanded state (default: false) */ initiallyExpanded?: boolean; /** Callback when state changes */ onToggle?: (isExpanded: boolean) => void; /** Base label for aria-label (will append "click to expand/collapse") */ baseAriaLabel?: string; } /** * Setup collapsible behavior on a header/content pair. * * Handles: * - Click to toggle * - Enter/Space keyboard navigation * - aria-expanded attribute * - CSS 'expanded' class on wrapper * - content display style * * @param wrapperEl - The wrapper element to add/remove 'expanded' class * @param headerEl - The clickable header element * @param contentEl - The content element to show/hide * @param state - State object to track isExpanded (mutated by this function) * @param options - Optional configuration */ export function setupCollapsible( wrapperEl: HTMLElement, headerEl: HTMLElement, contentEl: HTMLElement, state: CollapsibleState, options: CollapsibleOptions = {} ): void { const { initiallyExpanded = false, onToggle, baseAriaLabel } = options; // Helper to update aria-label based on expanded state const updateAriaLabel = (isExpanded: boolean) => { if (baseAriaLabel) { const action = isExpanded ? 'click to collapse' : 'click to expand'; headerEl.setAttribute('aria-label', `${baseAriaLabel} - ${action}`); } }; // Set initial state state.isExpanded = initiallyExpanded; if (initiallyExpanded) { wrapperEl.addClass('expanded'); contentEl.style.display = 'block'; headerEl.setAttribute('aria-expanded', 'true'); } else { contentEl.style.display = 'none'; headerEl.setAttribute('aria-expanded', 'false'); } updateAriaLabel(initiallyExpanded); // Toggle handler const toggleExpand = () => { state.isExpanded = !state.isExpanded; if (state.isExpanded) { wrapperEl.addClass('expanded'); contentEl.style.display = 'block'; headerEl.setAttribute('aria-expanded', 'true'); } else { wrapperEl.removeClass('expanded'); contentEl.style.display = 'none'; headerEl.setAttribute('aria-expanded', 'false'); } updateAriaLabel(state.isExpanded); onToggle?.(state.isExpanded); }; // Click handler headerEl.addEventListener('click', toggleExpand); // Keyboard handler (Enter/Space) headerEl.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleExpand(); } }); } /** * Collapse a collapsible element and sync state. * Use this when programmatically collapsing (e.g., on finalize). */ export function collapseElement( wrapperEl: HTMLElement, headerEl: HTMLElement, contentEl: HTMLElement, state: CollapsibleState ): void { state.isExpanded = false; wrapperEl.removeClass('expanded'); contentEl.style.display = 'none'; headerEl.setAttribute('aria-expanded', 'false'); } ================================================ FILE: src/features/chat/rendering/index.ts ================================================ export { MessageRenderer } from './MessageRenderer'; export { addSubagentToolCall, type AsyncSubagentState, createAsyncSubagentBlock, createSubagentBlock, finalizeAsyncSubagent, finalizeSubagentBlock, markAsyncSubagentOrphaned, renderStoredAsyncSubagent, renderStoredSubagent, type SubagentState, updateAsyncSubagentRunning, updateSubagentToolResult, } from './SubagentRenderer'; export { appendThinkingContent, cleanupThinkingBlock, createThinkingBlock, finalizeThinkingBlock, type RenderContentFn, renderStoredThinkingBlock, type ThinkingBlockState, } from './ThinkingBlockRenderer'; export { extractLastTodosFromMessages, parseTodoInput, type TodoItem, } from './TodoListRenderer'; export { getToolLabel, getToolName, getToolSummary, isBlockedToolResult, renderStoredToolCall, renderToolCall, setToolIcon, updateToolCallResult, } from './ToolCallRenderer'; export { createWriteEditBlock, finalizeWriteEditBlock, renderStoredWriteEdit, updateWriteEditWithDiff, type WriteEditState, } from './WriteEditRenderer'; ================================================ FILE: src/features/chat/rendering/todoUtils.ts ================================================ import { setIcon } from 'obsidian'; import type { TodoItem } from '../../../core/tools'; export function getTodoStatusIcon(status: TodoItem['status']): string { return status === 'completed' ? 'check' : 'dot'; } export function getTodoDisplayText(todo: TodoItem): string { return todo.status === 'in_progress' ? todo.activeForm : todo.content; } export function renderTodoItems( container: HTMLElement, todos: TodoItem[] ): void { container.empty(); for (const todo of todos) { const item = container.createDiv({ cls: `claudian-todo-item claudian-todo-${todo.status}` }); const icon = item.createSpan({ cls: 'claudian-todo-status-icon' }); icon.setAttribute('aria-hidden', 'true'); setIcon(icon, getTodoStatusIcon(todo.status)); const text = item.createSpan({ cls: 'claudian-todo-text' }); text.setText(getTodoDisplayText(todo)); } } ================================================ FILE: src/features/chat/rewind.ts ================================================ import type { ChatMessage } from '../../core/types'; export interface RewindContext { prevAssistantUuid: string | undefined; hasResponse: boolean; } /** * Scans around a user message to find the previous assistant UUID (rewind target) * and whether a response with a UUID follows it (proving the SDK processed it). */ export function findRewindContext(messages: ChatMessage[], userIndex: number): RewindContext { let prevAssistantUuid: string | undefined; for (let i = userIndex - 1; i >= 0; i--) { if (messages[i].role === 'assistant' && messages[i].sdkAssistantUuid) { prevAssistantUuid = messages[i].sdkAssistantUuid; break; } } let hasResponse = false; for (let i = userIndex + 1; i < messages.length; i++) { if (messages[i].role === 'user') break; if (messages[i].role === 'assistant' && messages[i].sdkAssistantUuid) { hasResponse = true; break; } } return { prevAssistantUuid, hasResponse }; } ================================================ FILE: src/features/chat/services/BangBashService.ts ================================================ import { exec } from 'child_process'; export interface BangBashResult { command: string; stdout: string; stderr: string; exitCode: number; error?: string; } const TIMEOUT_MS = 30_000; const MAX_BUFFER = 1024 * 1024; // 1MB export class BangBashService { private cwd: string; private enhancedPath: string; constructor(cwd: string, enhancedPath: string) { this.cwd = cwd; this.enhancedPath = enhancedPath; } execute(command: string): Promise { return new Promise((resolve) => { exec(command, { cwd: this.cwd, env: { ...process.env, PATH: this.enhancedPath }, timeout: TIMEOUT_MS, maxBuffer: MAX_BUFFER, shell: process.platform === 'win32' ? 'cmd.exe' : '/bin/bash', }, (error, stdout, stderr) => { if (error && 'killed' in error && error.killed) { // Node.js types declare code as number, but maxBuffer errors set it to a string at runtime const isMaxBuffer = 'code' in error && (error.code as unknown) === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER'; resolve({ command, stdout: stdout ?? '', stderr: stderr ?? '', exitCode: 124, error: isMaxBuffer ? 'Output exceeded maximum buffer size (1MB)' : `Command timed out after ${TIMEOUT_MS / 1000}s`, }); return; } resolve({ command, stdout: stdout ?? '', stderr: stderr ?? '', exitCode: typeof error?.code === 'number' ? error.code : error ? 1 : 0, }); }); }); } } ================================================ FILE: src/features/chat/services/InstructionRefineService.ts ================================================ import type { Options } from '@anthropic-ai/claude-agent-sdk'; import { query as agentQuery } from '@anthropic-ai/claude-agent-sdk'; import { buildRefineSystemPrompt } from '../../../core/prompts/instructionRefine'; import { type InstructionRefineResult, isAdaptiveThinkingModel, THINKING_BUDGETS } from '../../../core/types'; import type ClaudianPlugin from '../../../main'; import { getEnhancedPath, getMissingNodeError, parseEnvironmentVariables } from '../../../utils/env'; import { getVaultPath } from '../../../utils/path'; export type RefineProgressCallback = (update: InstructionRefineResult) => void; export class InstructionRefineService { private plugin: ClaudianPlugin; private abortController: AbortController | null = null; private sessionId: string | null = null; private existingInstructions: string = ''; constructor(plugin: ClaudianPlugin) { this.plugin = plugin; } /** Resets conversation state for a new refinement session. */ resetConversation(): void { this.sessionId = null; } /** Refines a raw instruction from user input. */ async refineInstruction( rawInstruction: string, existingInstructions: string, onProgress?: RefineProgressCallback ): Promise { this.sessionId = null; this.existingInstructions = existingInstructions; const prompt = `Please refine this instruction: "${rawInstruction}"`; return this.sendMessage(prompt, onProgress); } /** Continues conversation with a follow-up message (for clarifications). */ async continueConversation( message: string, onProgress?: RefineProgressCallback ): Promise { if (!this.sessionId) { return { success: false, error: 'No active conversation to continue' }; } return this.sendMessage(message, onProgress); } /** Cancels any ongoing query. */ cancel(): void { if (this.abortController) { this.abortController.abort(); this.abortController = null; } } private async sendMessage( prompt: string, onProgress?: RefineProgressCallback ): Promise { const vaultPath = getVaultPath(this.plugin.app); if (!vaultPath) { return { success: false, error: 'Could not determine vault path' }; } const resolvedClaudePath = this.plugin.getResolvedClaudeCliPath(); if (!resolvedClaudePath) { return { success: false, error: 'Claude CLI not found. Please install Claude Code CLI.' }; } this.abortController = new AbortController(); // Parse custom environment variables const customEnv = parseEnvironmentVariables(this.plugin.getActiveEnvironmentVariables()); const enhancedPath = getEnhancedPath(customEnv.PATH, resolvedClaudePath); const missingNodeError = getMissingNodeError(resolvedClaudePath, enhancedPath); if (missingNodeError) { return { success: false, error: missingNodeError }; } const options: Options = { cwd: vaultPath, systemPrompt: buildRefineSystemPrompt(this.existingInstructions), model: this.plugin.settings.model, abortController: this.abortController, pathToClaudeCodeExecutable: resolvedClaudePath, env: { ...process.env, ...customEnv, PATH: enhancedPath, }, tools: [], // No tools needed for instruction refinement permissionMode: 'bypassPermissions', allowDangerouslySkipPermissions: true, settingSources: this.plugin.settings.loadUserClaudeSettings ? ['user', 'project'] : ['project'], }; if (this.sessionId) { options.resume = this.sessionId; } if (isAdaptiveThinkingModel(this.plugin.settings.model)) { options.thinking = { type: 'adaptive' }; options.effort = this.plugin.settings.effortLevel; } else { const budgetConfig = THINKING_BUDGETS.find(b => b.value === this.plugin.settings.thinkingBudget); if (budgetConfig && budgetConfig.tokens > 0) { options.maxThinkingTokens = budgetConfig.tokens; } } try { const response = agentQuery({ prompt, options }); let responseText = ''; for await (const message of response) { if (this.abortController?.signal.aborted) { await response.interrupt(); return { success: false, error: 'Cancelled' }; } if (message.type === 'system' && message.subtype === 'init' && message.session_id) { this.sessionId = message.session_id; } const text = this.extractTextFromMessage(message); if (text) { responseText += text; // Stream progress updates if (onProgress) { const partialResult = this.parseResponse(responseText); onProgress(partialResult); } } } return this.parseResponse(responseText); } catch (error) { const msg = error instanceof Error ? error.message : 'Unknown error'; return { success: false, error: msg }; } finally { this.abortController = null; } } /** Parses response text for tag. */ private parseResponse(responseText: string): InstructionRefineResult { const instructionMatch = responseText.match(/([\s\S]*?)<\/instruction>/); if (instructionMatch) { return { success: true, refinedInstruction: instructionMatch[1].trim() }; } // No instruction tag - treat as clarification question const trimmed = responseText.trim(); if (trimmed) { return { success: true, clarification: trimmed }; } return { success: false, error: 'Empty response' }; } /** Extracts text content from SDK message. */ private extractTextFromMessage(message: { type: string; message?: { content?: Array<{ type: string; text?: string }> } }): string { if (message.type !== 'assistant' || !message.message?.content) { return ''; } return message.message.content .filter((block): block is { type: 'text'; text: string } => block.type === 'text' && !!block.text) .map(block => block.text) .join(''); } } ================================================ FILE: src/features/chat/services/SubagentManager.ts ================================================ import { existsSync, readFileSync, realpathSync } from 'fs'; import { tmpdir } from 'os'; import { isAbsolute, sep } from 'path'; import { TOOL_TASK } from '../../../core/tools/toolNames'; import type { SubagentInfo, SubagentMode, ToolCallInfo, } from '../../../core/types'; import { extractAgentIdFromToolUseResult, extractXmlTag, resolveToolUseResultStatus, } from '../../../utils/sdkSession'; import { extractFinalResultFromSubagentJsonl } from '../../../utils/subagentJsonl'; import { addSubagentToolCall, type AsyncSubagentState, createAsyncSubagentBlock, createSubagentBlock, finalizeAsyncSubagent, finalizeSubagentBlock, markAsyncSubagentOrphaned, type SubagentState, updateAsyncSubagentRunning, updateSubagentToolResult, } from '../rendering'; import type { PendingToolCall } from '../state/types'; export type SubagentStateChangeCallback = (subagent: SubagentInfo) => void; export type HandleTaskResult = | { action: 'buffered' } | { action: 'created_sync'; subagentState: SubagentState } | { action: 'created_async'; info: SubagentInfo; domState: AsyncSubagentState } | { action: 'label_updated' }; export type RenderPendingResult = | { mode: 'sync'; subagentState: SubagentState } | { mode: 'async'; info: SubagentInfo; domState: AsyncSubagentState }; export class SubagentManager { private static readonly TRUSTED_OUTPUT_EXT = '.output'; private static readonly TRUSTED_TMP_ROOTS = SubagentManager.resolveTrustedTmpRoots(); private syncSubagents: Map = new Map(); private pendingTasks: Map = new Map(); private _spawnedThisStream = 0; private activeAsyncSubagents: Map = new Map(); private pendingAsyncSubagents: Map = new Map(); private taskIdToAgentId: Map = new Map(); private outputToolIdToAgentId: Map = new Map(); private asyncDomStates: Map = new Map(); private onStateChange: SubagentStateChangeCallback; constructor(onStateChange: SubagentStateChangeCallback) { this.onStateChange = onStateChange; } public setCallback(callback: SubagentStateChangeCallback): void { this.onStateChange = callback; } // ============================================ // Unified Subagent Entry Point // ============================================ /** * Handles an Agent tool_use chunk with minimal buffering to determine sync vs async. * Returns a typed result so StreamController can update messages accordingly. */ public handleTaskToolUse( taskToolId: string, taskInput: Record, currentContentEl: HTMLElement | null ): HandleTaskResult { // Already rendered as sync → update label (no parentEl needed) const existingSyncState = this.syncSubagents.get(taskToolId); if (existingSyncState) { this.updateSubagentLabel(existingSyncState.wrapperEl, existingSyncState.info, taskInput); return { action: 'label_updated' }; } // Already rendered as async → update label (no parentEl needed) const existingAsyncState = this.asyncDomStates.get(taskToolId); if (existingAsyncState) { this.updateSubagentLabel(existingAsyncState.wrapperEl, existingAsyncState.info, taskInput); // Sync to canonical SubagentInfo so status transitions don't revert updates const canonical = this.getByTaskId(taskToolId); if (canonical && canonical !== existingAsyncState.info) { if (taskInput.description) canonical.description = taskInput.description as string; if (taskInput.prompt) canonical.prompt = taskInput.prompt as string; } return { action: 'label_updated' }; } // Already buffered → merge input and try to render const pending = this.pendingTasks.get(taskToolId); if (pending) { const newInput = taskInput || {}; if (Object.keys(newInput).length > 0) { pending.toolCall.input = { ...pending.toolCall.input, ...newInput }; } if (currentContentEl) { pending.parentEl = currentContentEl; } // Do not lock mode before run_in_background is explicitly known. // Sync fallback is handled when child chunks/tool_result confirm sync. if (this.resolveTaskMode(pending.toolCall.input)) { const result = this.renderPendingTask(taskToolId, currentContentEl); if (result) { return result.mode === 'sync' ? { action: 'created_sync', subagentState: result.subagentState } : { action: 'created_async', info: result.info, domState: result.domState }; } } return { action: 'buffered' }; } // New Task without a content element — buffer for later rendering if (!currentContentEl) { const toolCall: ToolCallInfo = { id: taskToolId, name: TOOL_TASK, input: taskInput || {}, status: 'running', isExpanded: false, }; this.pendingTasks.set(taskToolId, { toolCall, parentEl: null }); return { action: 'buffered' }; } const mode = this.resolveTaskMode(taskInput); if (!mode) { const toolCall: ToolCallInfo = { id: taskToolId, name: TOOL_TASK, input: taskInput || {}, status: 'running', isExpanded: false, }; this.pendingTasks.set(taskToolId, { toolCall, parentEl: currentContentEl }); return { action: 'buffered' }; } this._spawnedThisStream++; if (mode === 'async') { return this.createAsyncTask(taskToolId, taskInput, currentContentEl); } return this.createSyncTask(taskToolId, taskInput, currentContentEl); } // ============================================ // Pending Task Resolution // ============================================ public hasPendingTask(toolId: string): boolean { return this.pendingTasks.has(toolId); } /** * Renders a buffered pending task. Called when a child chunk or tool_result * confirms the task is sync, or when run_in_background becomes known. * Uses the optional parentEl override, falling back to the stored parentEl. */ public renderPendingTask( toolId: string, parentElOverride?: HTMLElement | null ): RenderPendingResult | null { const pending = this.pendingTasks.get(toolId); if (!pending) return null; const input = pending.toolCall.input; const targetEl = parentElOverride ?? pending.parentEl; if (!targetEl) return null; this.pendingTasks.delete(toolId); try { if (input.run_in_background === true) { const result = this.createAsyncTask(pending.toolCall.id, input, targetEl); if (result.action === 'created_async') { this._spawnedThisStream++; return { mode: 'async', info: result.info, domState: result.domState }; } } else { const result = this.createSyncTask(pending.toolCall.id, input, targetEl); if (result.action === 'created_sync') { this._spawnedThisStream++; return { mode: 'sync', subagentState: result.subagentState }; } } } catch { // Non-fatal: task appears incomplete but doesn't crash the stream } return null; } /** * Resolves a pending Task when its own tool_result arrives. * If mode is still unknown, infer async from task result shape (agent_id/agentId), * otherwise fall back to sync so it never remains pending indefinitely. */ public renderPendingTaskFromTaskResult( toolId: string, taskResult: string, isError: boolean, parentElOverride?: HTMLElement | null, taskToolUseResult?: unknown ): RenderPendingResult | null { const pending = this.pendingTasks.get(toolId); if (!pending) return null; const input = pending.toolCall.input; const targetEl = parentElOverride ?? pending.parentEl; if (!targetEl) return null; const explicitMode = this.resolveTaskMode(input); const inferredMode = explicitMode ?? this.inferModeFromTaskResult(taskResult, isError, taskToolUseResult); this.pendingTasks.delete(toolId); try { if (inferredMode === 'async') { const result = this.createAsyncTask(pending.toolCall.id, input, targetEl); if (result.action === 'created_async') { this._spawnedThisStream++; return { mode: 'async', info: result.info, domState: result.domState }; } } else { const result = this.createSyncTask(pending.toolCall.id, input, targetEl); if (result.action === 'created_sync') { this._spawnedThisStream++; return { mode: 'sync', subagentState: result.subagentState }; } } } catch { // Non-fatal: task appears incomplete but doesn't crash the stream } return null; } // ============================================ // Sync Subagent Operations // ============================================ public getSyncSubagent(toolId: string): SubagentState | undefined { return this.syncSubagents.get(toolId); } public addSyncToolCall(parentToolUseId: string, toolCall: ToolCallInfo): void { const subagentState = this.syncSubagents.get(parentToolUseId); if (!subagentState) return; addSubagentToolCall(subagentState, toolCall); } public updateSyncToolResult( parentToolUseId: string, toolId: string, toolCall: ToolCallInfo ): void { const subagentState = this.syncSubagents.get(parentToolUseId); if (!subagentState) return; updateSubagentToolResult(subagentState, toolId, toolCall); } public finalizeSyncSubagent( toolId: string, result: string, isError: boolean, toolUseResult?: unknown ): SubagentInfo | null { const subagentState = this.syncSubagents.get(toolId); if (!subagentState) return null; const extractedResult = this.extractAgentResult(result, '', toolUseResult); finalizeSubagentBlock(subagentState, extractedResult, isError); this.syncSubagents.delete(toolId); return subagentState.info; } // ============================================ // Async Subagent Lifecycle // ============================================ public handleTaskToolResult( taskToolId: string, result: string, isError?: boolean, toolUseResult?: unknown ): void { const subagent = this.pendingAsyncSubagents.get(taskToolId); if (!subagent) return; if (isError) { this.transitionToError(subagent, taskToolId, result || 'Task failed to start'); return; } const agentId = this.extractAgentIdFromTaskToolUseResult(toolUseResult) ?? this.parseAgentId(result); if (!agentId) { const truncatedResult = result.length > 100 ? result.substring(0, 100) + '...' : result; this.transitionToError(subagent, taskToolId, `Failed to parse agent_id. Result: ${truncatedResult}`); return; } subagent.asyncStatus = 'running'; subagent.agentId = agentId; subagent.startedAt = Date.now(); this.pendingAsyncSubagents.delete(taskToolId); this.activeAsyncSubagents.set(agentId, subagent); this.taskIdToAgentId.set(taskToolId, agentId); this.updateAsyncDomState(subagent); this.onStateChange(subagent); } public handleAgentOutputToolUse(toolCall: ToolCallInfo): void { const agentId = this.extractAgentIdFromInput(toolCall.input); if (!agentId) return; const subagent = this.activeAsyncSubagents.get(agentId); if (!subagent) return; subagent.outputToolId = toolCall.id; this.outputToolIdToAgentId.set(toolCall.id, agentId); } public handleAgentOutputToolResult( toolId: string, result: string, isError: boolean, toolUseResult?: unknown ): SubagentInfo | undefined { let agentId = this.outputToolIdToAgentId.get(toolId); let subagent = agentId ? this.activeAsyncSubagents.get(agentId) : undefined; if (!subagent) { const inferredAgentId = this.inferAgentIdFromResult(result); if (inferredAgentId) { agentId = inferredAgentId; subagent = this.activeAsyncSubagents.get(inferredAgentId); } } if (!subagent) return undefined; if (agentId) { subagent.agentId = subagent.agentId || agentId; this.outputToolIdToAgentId.set(toolId, agentId); } if (subagent.asyncStatus !== 'running') { return undefined; } const stillRunning = this.isStillRunningResult(result, isError); if (stillRunning) { this.outputToolIdToAgentId.delete(toolId); return subagent; } const extractedResult = this.extractAgentResult(result, agentId ?? '', toolUseResult); // The chunk's is_error flag can be unreliable for async subagent results // (SDK may set is_error on the content block even when the agent succeeded). // Prefer the structured toolUseResult to determine actual error status. const resolvedStatus = resolveToolUseResultStatus( toolUseResult, isError ? 'error' : 'completed' ); const finalStatus = resolvedStatus === 'error' ? 'error' : 'completed'; subagent.asyncStatus = finalStatus; subagent.status = finalStatus; subagent.result = extractedResult; subagent.completedAt = Date.now(); if (agentId) this.activeAsyncSubagents.delete(agentId); this.outputToolIdToAgentId.delete(toolId); this.updateAsyncDomState(subagent); this.onStateChange(subagent); return subagent; } public isPendingAsyncTask(taskToolId: string): boolean { return this.pendingAsyncSubagents.has(taskToolId); } public isLinkedAgentOutputTool(toolId: string): boolean { return this.outputToolIdToAgentId.has(toolId); } public getByTaskId(taskToolId: string): SubagentInfo | undefined { const pending = this.pendingAsyncSubagents.get(taskToolId); if (pending) return pending; const agentId = this.taskIdToAgentId.get(taskToolId); if (agentId) { return this.activeAsyncSubagents.get(agentId); } return undefined; } /** * Re-renders an async subagent after data-only updates (for example, * hydrating tool calls from SDK sidecar files) without changing lifecycle state. */ public refreshAsyncSubagent(subagent: SubagentInfo): void { this.updateAsyncDomState(subagent); this.onStateChange(subagent); } // ============================================ // Hook State // ============================================ public hasRunningSubagents(): boolean { // pendingAsyncSubagents: awaiting agent_id; activeAsyncSubagents: only holds running entries return this.pendingAsyncSubagents.size > 0 || this.activeAsyncSubagents.size > 0; } // ============================================ // Lifecycle // ============================================ public get subagentsSpawnedThisStream(): number { return this._spawnedThisStream; } public resetSpawnedCount(): void { this._spawnedThisStream = 0; } public resetStreamingState(): void { this.syncSubagents.clear(); this.pendingTasks.clear(); } public orphanAllActive(): SubagentInfo[] { const orphaned: SubagentInfo[] = []; for (const subagent of this.pendingAsyncSubagents.values()) { this.markOrphaned(subagent); orphaned.push(subagent); } for (const subagent of this.activeAsyncSubagents.values()) { if (subagent.asyncStatus === 'running') { this.markOrphaned(subagent); orphaned.push(subagent); } } this.pendingAsyncSubagents.clear(); this.activeAsyncSubagents.clear(); this.taskIdToAgentId.clear(); this.outputToolIdToAgentId.clear(); return orphaned; } public clear(): void { this.syncSubagents.clear(); this.pendingTasks.clear(); this.pendingAsyncSubagents.clear(); this.activeAsyncSubagents.clear(); this.taskIdToAgentId.clear(); this.outputToolIdToAgentId.clear(); this.asyncDomStates.clear(); } // ============================================ // Private: State Transitions // ============================================ private markOrphaned(subagent: SubagentInfo): void { subagent.asyncStatus = 'orphaned'; subagent.status = 'error'; subagent.result = 'Conversation ended before task completed'; subagent.completedAt = Date.now(); this.updateAsyncDomState(subagent); this.onStateChange(subagent); } private transitionToError(subagent: SubagentInfo, taskToolId: string, errorResult: string): void { subagent.asyncStatus = 'error'; subagent.status = 'error'; subagent.result = errorResult; subagent.completedAt = Date.now(); this.pendingAsyncSubagents.delete(taskToolId); this.updateAsyncDomState(subagent); this.onStateChange(subagent); } // ============================================ // Private: Task Creation // ============================================ private createSyncTask( taskToolId: string, taskInput: Record, parentEl: HTMLElement ): HandleTaskResult { const subagentState = createSubagentBlock(parentEl, taskToolId, taskInput); this.syncSubagents.set(taskToolId, subagentState); return { action: 'created_sync', subagentState }; } private createAsyncTask( taskToolId: string, taskInput: Record, parentEl: HTMLElement ): HandleTaskResult { const description = (taskInput.description as string) || 'Background task'; const prompt = (taskInput.prompt as string) || ''; const info: SubagentInfo = { id: taskToolId, description, prompt, mode: 'async' as SubagentMode, isExpanded: false, status: 'running', toolCalls: [], asyncStatus: 'pending', }; this.pendingAsyncSubagents.set(taskToolId, info); const domState = createAsyncSubagentBlock(parentEl, taskToolId, taskInput); this.asyncDomStates.set(taskToolId, domState); return { action: 'created_async', info, domState }; } // ============================================ // Private: Label Update // ============================================ private updateSubagentLabel( wrapperEl: HTMLElement, info: SubagentInfo, newInput: Record ): void { if (!newInput || Object.keys(newInput).length === 0) return; const description = (newInput.description as string) || ''; if (description) { info.description = description; const labelEl = wrapperEl.querySelector('.claudian-subagent-label') as HTMLElement | null; if (labelEl) { const truncated = description.length > 40 ? description.substring(0, 40) + '...' : description; labelEl.setText(truncated); } } const prompt = (newInput.prompt as string) || ''; if (prompt) { info.prompt = prompt; const promptEl = wrapperEl.querySelector('.claudian-subagent-prompt-text') as HTMLElement | null; if (promptEl) { promptEl.setText(prompt); } } } private resolveTaskMode(taskInput: Record): 'sync' | 'async' | null { if (!Object.prototype.hasOwnProperty.call(taskInput, 'run_in_background')) { return null; } if (taskInput.run_in_background === true) { return 'async'; } if (taskInput.run_in_background === false) { return 'sync'; } return null; } private inferModeFromTaskResult( taskResult: string, isError: boolean, taskToolUseResult?: unknown ): 'sync' | 'async' { if (isError) { return 'sync'; } if (this.hasAsyncMarkerInToolUseResult(taskToolUseResult)) { return 'async'; } // Use strict async markers only; avoid broad ID heuristics. return this.parseAgentIdStrict(taskResult) ? 'async' : 'sync'; } private parseAgentIdStrict(result: string): string | null { const fromRaw = this.extractAgentIdFromString(result); if (fromRaw) return fromRaw; const payload = this.unwrapTextPayload(result); const fromPayload = this.extractAgentIdFromString(payload); if (fromPayload) return fromPayload; try { const parsed = JSON.parse(result); if (Array.isArray(parsed)) { for (const block of parsed) { if (block && typeof block === 'object' && typeof (block as Record).text === 'string') { const fromText = this.extractAgentIdFromString((block as Record).text as string); if (fromText) return fromText; } } } const agentId = parsed.agent_id || parsed.agentId || parsed?.data?.agent_id; if (typeof agentId === 'string' && agentId.length > 0) { return agentId; } } catch { // Not JSON } return null; } private extractAgentIdFromString(value: string): string | null { const regexPatterns = [ /"agent_id"\s*:\s*"([^"]+)"/, /"agentId"\s*:\s*"([^"]+)"/, /agent_id[=:]\s*"?([a-zA-Z0-9_-]+)"?/i, /agentId[=:]\s*"?([a-zA-Z0-9_-]+)"?/i, ]; for (const pattern of regexPatterns) { const match = value.match(pattern); if (match && match[1]) { return match[1]; } } return null; } private hasAsyncMarkerInToolUseResult(taskToolUseResult?: unknown): boolean { if (!taskToolUseResult || typeof taskToolUseResult !== 'object') { return false; } const record = taskToolUseResult as Record; if (record.isAsync === true) { return true; } const directAgentId = record.agentId ?? record.agent_id; if (typeof directAgentId === 'string' && directAgentId.length > 0) { return true; } const data = record.data; if (data && typeof data === 'object') { const nestedRecord = data as Record; const nestedAgentId = nestedRecord.agent_id ?? nestedRecord.agentId; if (typeof nestedAgentId === 'string' && nestedAgentId.length > 0) { return true; } } if (typeof record.status === 'string' && record.status.toLowerCase() === 'async_launched') { return true; } if (typeof record.outputFile === 'string' && record.outputFile.length > 0) { return true; } if (Array.isArray(record.content)) { for (const block of record.content) { if (block && typeof block === 'object') { const text = (block as Record).text; if (typeof text === 'string' && this.extractAgentIdFromString(text)) { return true; } } else if (typeof block === 'string' && this.extractAgentIdFromString(block)) { return true; } } } if (typeof record.content === 'string' && this.extractAgentIdFromString(record.content)) { return true; } return false; } // ============================================ // Private: Async DOM State Updates // ============================================ private updateAsyncDomState(subagent: SubagentInfo): void { // Find DOM state by task ID first, then by agentId let asyncState = this.asyncDomStates.get(subagent.id); if (!asyncState) { for (const s of this.asyncDomStates.values()) { if (s.info.agentId === subagent.agentId) { asyncState = s; break; } } if (!asyncState) return; } asyncState.info = subagent; switch (subagent.asyncStatus) { case 'running': updateAsyncSubagentRunning(asyncState, subagent.agentId || ''); break; case 'completed': case 'error': finalizeAsyncSubagent(asyncState, subagent.result || '', subagent.asyncStatus === 'error'); break; case 'orphaned': markAsyncSubagentOrphaned(asyncState); break; } } // ============================================ // Private: Async Parsing Logic // ============================================ private isStillRunningResult(result: string, isError: boolean): boolean { const trimmed = result?.trim() || ''; const payload = this.unwrapTextPayload(trimmed); if (isError) return false; if (!trimmed) return false; try { const parsed = JSON.parse(payload); const status = parsed.retrieval_status || parsed.status; const hasAgents = parsed.agents && Object.keys(parsed.agents).length > 0; if (status === 'not_ready' || status === 'running' || status === 'pending') { return true; } if (hasAgents) { const agentStatuses = Object.values(parsed.agents as Record) .map((a) => (a && typeof a === 'object' && 'status' in a && typeof (a as Record).status === 'string') ? ((a as Record).status as string).toLowerCase() : ''); const anyRunning = agentStatuses.some(s => s === 'running' || s === 'pending' || s === 'not_ready' ); if (anyRunning) return true; return false; } if (status === 'success' || status === 'completed') { return false; } return false; } catch { // Not JSON } const lowerResult = payload.toLowerCase(); if (lowerResult.includes('not_ready') || lowerResult.includes('not ready')) { return true; } const xmlStatusMatch = lowerResult.match(/([^<]+)<\/status>/); if (xmlStatusMatch) { const status = xmlStatusMatch[1].trim(); if (status === 'running' || status === 'pending' || status === 'not_ready') { return true; } } return false; } private extractAgentResult(result: string, agentId: string, toolUseResult?: unknown): string { const structuredResult = this.extractResultFromToolUseResult(toolUseResult); if (structuredResult) { return structuredResult; } const payload = this.unwrapTextPayload(result); try { const parsed = JSON.parse(payload); const taskResult = this.extractResultFromTaskObject(parsed.task); if (taskResult) { return taskResult; } if (parsed.agents && agentId && parsed.agents[agentId]) { const agentData = parsed.agents[agentId]; const parsedResult = this.extractResultFromCandidateString(agentData?.result); if (parsedResult) { return parsedResult; } const parsedOutput = this.extractResultFromCandidateString(agentData?.output); if (parsedOutput) { return parsedOutput; } return JSON.stringify(agentData, null, 2); } if (parsed.agents) { const agentIds = Object.keys(parsed.agents); if (agentIds.length > 0) { const firstAgent = parsed.agents[agentIds[0]]; const parsedResult = this.extractResultFromCandidateString(firstAgent?.result); if (parsedResult) { return parsedResult; } const parsedOutput = this.extractResultFromCandidateString(firstAgent?.output); if (parsedOutput) { return parsedOutput; } return JSON.stringify(firstAgent, null, 2); } } const parsedResult = this.extractResultFromCandidateString(parsed.result); if (parsedResult) { return parsedResult; } const parsedOutput = this.extractResultFromCandidateString(parsed.output); if (parsedOutput) { return parsedOutput; } } catch { // Not JSON, return as-is } const taggedResult = this.extractResultFromTaggedPayload(payload); if (taggedResult) { return taggedResult; } return payload; } private extractResultFromToolUseResult(toolUseResult: unknown): string | null { if (!toolUseResult || typeof toolUseResult !== 'object') { return null; } const record = toolUseResult as Record; if (record.retrieval_status === 'error') { const errorMsg = typeof record.error === 'string' ? record.error : 'Task retrieval failed'; return `Error: ${errorMsg}`; } const result = this.extractResultFromTaskObject(record.task) ?? this.extractResultFromCandidateString(record.result) ?? this.extractResultFromCandidateString(record.output); if (result) return result; // SDK subagent format: { status, content: [{type:"text",text:"..."}], agentId, ... } if (Array.isArray(record.content)) { const firstText = (record.content as Array>) .find((b) => b && typeof b === 'object' && b.type === 'text' && typeof b.text === 'string'); if (firstText) { const text = (firstText.text as string).trim(); if (text.length > 0) return text; } } return null; } private extractResultFromTaskObject(task: unknown): string | null { if (!task || typeof task !== 'object') { return null; } const taskRecord = task as Record; return this.extractResultFromCandidateString(taskRecord.result) ?? this.extractResultFromCandidateString(taskRecord.output); } private extractResultFromCandidateString(candidate: unknown): string | null { if (typeof candidate !== 'string') { return null; } const trimmed = candidate.trim(); if (!trimmed) { return null; } const taggedResult = this.extractResultFromTaggedPayload(trimmed); if (taggedResult) { return taggedResult; } const jsonlResult = this.extractResultFromOutputJsonl(trimmed); if (jsonlResult) { return jsonlResult; } return trimmed; } private parseAgentId(result: string): string | null { const regexPatterns = [ /"agent_id"\s*:\s*"([^"]+)"/, /"agentId"\s*:\s*"([^"]+)"/, /agent_id[=:]\s*"?([a-zA-Z0-9_-]+)"?/i, /agentId[=:]\s*"?([a-zA-Z0-9_-]+)"?/i, /\b([a-f0-9]{8})\b/, ]; for (const pattern of regexPatterns) { const match = result.match(pattern); if (match && match[1]) { return match[1]; } } try { const parsed = JSON.parse(result); const agentId = parsed.agent_id || parsed.agentId; if (typeof agentId === 'string' && agentId.length > 0) { return agentId; } if (parsed.data?.agent_id) { return parsed.data.agent_id; } if (parsed.id && typeof parsed.id === 'string') { return parsed.id; } } catch { // Not JSON } return null; } private extractAgentIdFromTaskToolUseResult(toolUseResult: unknown): string | null { // Shared utility handles the common agentId/agent_id and data.agent_id paths const directId = extractAgentIdFromToolUseResult(toolUseResult); if (directId) return directId; // Streaming-specific fallback: scan content blocks for agent ID strings if (!toolUseResult || typeof toolUseResult !== 'object') return null; const record = toolUseResult as Record; if (Array.isArray(record.content)) { for (const block of record.content) { if (typeof block === 'string') { const extracted = this.extractAgentIdFromString(block); if (extracted) return extracted; continue; } if (!block || typeof block !== 'object') { continue; } const blockRecord = block as Record; if (typeof blockRecord.text === 'string') { const extracted = this.extractAgentIdFromString(blockRecord.text); if (extracted) return extracted; } } } else if (typeof record.content === 'string') { const extracted = this.extractAgentIdFromString(record.content); if (extracted) return extracted; } return null; } private inferAgentIdFromResult(result: string): string | null { try { const parsed = JSON.parse(result); if (parsed.agents && typeof parsed.agents === 'object') { const keys = Object.keys(parsed.agents); if (keys.length > 0) { return keys[0]; } } } catch { // Not JSON } return null; } private unwrapTextPayload(raw: string): string { try { const parsed = JSON.parse(raw); if (Array.isArray(parsed)) { const textBlock = parsed.find((b: any) => b && typeof b.text === 'string'); if (textBlock?.text) return textBlock.text as string; } else if (parsed && typeof parsed === 'object' && typeof parsed.text === 'string') { return parsed.text; } } catch { // Not JSON or not an envelope } return raw; } private extractResultFromTaggedPayload(payload: string): string | null { const directResult = extractXmlTag(payload, 'result'); if (directResult) return directResult; const outputContent = extractXmlTag(payload, 'output'); if (!outputContent) return null; const extractedFromJsonl = this.extractResultFromOutputJsonl(outputContent); if (extractedFromJsonl) return extractedFromJsonl; const nestedResult = extractXmlTag(outputContent, 'result'); if (nestedResult) return nestedResult; const trimmed = outputContent.trim(); return trimmed.length > 0 ? trimmed : null; } private extractResultFromOutputJsonl(outputContent: string): string | null { const inlineResult = extractFinalResultFromSubagentJsonl(outputContent); if (inlineResult) { return inlineResult; } const fullOutputPath = this.extractFullOutputPath(outputContent); if (!fullOutputPath) { return null; } const fullOutput = this.readFullOutputFile(fullOutputPath); if (!fullOutput) { return null; } return extractFinalResultFromSubagentJsonl(fullOutput); } private extractFullOutputPath(content: string): string | null { const truncatedPattern = /\[Truncated\.\s*Full output:\s*([^\]\n]+)\]/i; const match = content.match(truncatedPattern); if (!match || !match[1]) { return null; } const outputPath = match[1].trim(); return outputPath.length > 0 ? outputPath : null; } private readFullOutputFile(fullOutputPath: string): string | null { try { if (!this.isTrustedOutputPath(fullOutputPath)) { return null; } if (!existsSync(fullOutputPath)) { return null; } const fileContent = readFileSync(fullOutputPath, 'utf-8'); const trimmed = fileContent.trim(); return trimmed.length > 0 ? trimmed : null; } catch { return null; } } private extractAgentIdFromInput(input: Record): string | null { const agentId = (input.task_id as string) || (input.agentId as string) || (input.agent_id as string); return agentId || null; } private static resolveTrustedTmpRoots(): string[] { const roots = new Set(); const candidates = [tmpdir(), '/tmp', '/private/tmp']; for (const candidate of candidates) { try { roots.add(realpathSync(candidate)); } catch { // Ignore unavailable temp roots. } } return Array.from(roots); } private isTrustedOutputPath(fullOutputPath: string): boolean { if (!isAbsolute(fullOutputPath)) { return false; } if (!fullOutputPath.toLowerCase().endsWith(SubagentManager.TRUSTED_OUTPUT_EXT)) { return false; } let resolvedPath: string; try { resolvedPath = realpathSync(fullOutputPath); } catch { return false; } return SubagentManager.TRUSTED_TMP_ROOTS.some((root) => resolvedPath === root || resolvedPath.startsWith(`${root}${sep}`) ); } } ================================================ FILE: src/features/chat/services/TitleGenerationService.ts ================================================ import type { Options } from '@anthropic-ai/claude-agent-sdk'; import { query as agentQuery } from '@anthropic-ai/claude-agent-sdk'; import { TITLE_GENERATION_SYSTEM_PROMPT } from '../../../core/prompts/titleGeneration'; import type ClaudianPlugin from '../../../main'; import { getEnhancedPath, getMissingNodeError, parseEnvironmentVariables } from '../../../utils/env'; import { getVaultPath } from '../../../utils/path'; export type TitleGenerationResult = | { success: true; title: string } | { success: false; error: string }; export type TitleGenerationCallback = ( conversationId: string, result: TitleGenerationResult ) => Promise; export class TitleGenerationService { private plugin: ClaudianPlugin; private activeGenerations: Map = new Map(); constructor(plugin: ClaudianPlugin) { this.plugin = plugin; } /** * Generates a title for a conversation based on the first user message. * Non-blocking: calls callback when complete. */ async generateTitle( conversationId: string, userMessage: string, callback: TitleGenerationCallback ): Promise { const vaultPath = getVaultPath(this.plugin.app); if (!vaultPath) { await this.safeCallback(callback, conversationId, { success: false, error: 'Could not determine vault path', }); return; } const envVars = parseEnvironmentVariables( this.plugin.getActiveEnvironmentVariables() ); const resolvedClaudePath = this.plugin.getResolvedClaudeCliPath(); if (!resolvedClaudePath) { await this.safeCallback(callback, conversationId, { success: false, error: 'Claude CLI not found', }); return; } const enhancedPath = getEnhancedPath(envVars.PATH, resolvedClaudePath); const missingNodeError = getMissingNodeError(resolvedClaudePath, enhancedPath); if (missingNodeError) { await this.safeCallback(callback, conversationId, { success: false, error: missingNodeError, }); return; } // Get the appropriate model with fallback chain: // 1. User's titleGenerationModel setting (if set) // 2. ANTHROPIC_DEFAULT_HAIKU_MODEL env var // 3. claude-haiku-4-5 default const titleModel = this.plugin.settings.titleGenerationModel || envVars.ANTHROPIC_DEFAULT_HAIKU_MODEL || 'claude-haiku-4-5'; // Cancel any existing generation for this conversation const existingController = this.activeGenerations.get(conversationId); if (existingController) { existingController.abort(); } // Create a new local AbortController for this generation const abortController = new AbortController(); this.activeGenerations.set(conversationId, abortController); // Truncate message if too long (save tokens) const truncatedUser = this.truncateText(userMessage, 500); const prompt = `User's request: """ ${truncatedUser} """ Generate a title for this conversation:`; const options: Options = { cwd: vaultPath, systemPrompt: TITLE_GENERATION_SYSTEM_PROMPT, model: titleModel, abortController, pathToClaudeCodeExecutable: resolvedClaudePath, env: { ...process.env, ...envVars, PATH: enhancedPath, }, tools: [], // No tools needed for title generation permissionMode: 'bypassPermissions', allowDangerouslySkipPermissions: true, settingSources: this.plugin.settings.loadUserClaudeSettings ? ['user', 'project'] : ['project'], persistSession: false, // Don't save title generation queries to session history }; try { const response = agentQuery({ prompt, options }); let responseText = ''; for await (const message of response) { if (abortController.signal.aborted) { await this.safeCallback(callback, conversationId, { success: false, error: 'Cancelled', }); return; } const text = this.extractTextFromMessage(message); if (text) { responseText += text; } } const title = this.parseTitle(responseText); if (title) { await this.safeCallback(callback, conversationId, { success: true, title }); } else { await this.safeCallback(callback, conversationId, { success: false, error: 'Failed to parse title from response', }); } } catch (error) { const msg = error instanceof Error ? error.message : 'Unknown error'; await this.safeCallback(callback, conversationId, { success: false, error: msg }); } finally { // Clean up the controller for this conversation this.activeGenerations.delete(conversationId); } } /** Cancels all ongoing title generations. */ cancel(): void { for (const controller of this.activeGenerations.values()) { controller.abort(); } this.activeGenerations.clear(); } /** Truncates text to a maximum length with ellipsis. */ private truncateText(text: string, maxLength: number): string { if (text.length <= maxLength) return text; return text.substring(0, maxLength) + '...'; } /** Extracts text content from SDK message. */ private extractTextFromMessage( message: { type: string; message?: { content?: Array<{ type: string; text?: string }> } } ): string { if (message.type !== 'assistant' || !message.message?.content) { return ''; } return message.message.content .filter((block): block is { type: 'text'; text: string } => block.type === 'text' && !!block.text ) .map((block) => block.text) .join(''); } /** Parses and cleans the title from response. */ private parseTitle(responseText: string): string | null { const trimmed = responseText.trim(); if (!trimmed) return null; // Remove surrounding quotes if present let title = trimmed; if ( (title.startsWith('"') && title.endsWith('"')) || (title.startsWith("'") && title.endsWith("'")) ) { title = title.slice(1, -1); } // Remove trailing punctuation title = title.replace(/[.!?:;,]+$/, ''); // Truncate to max 50 characters if (title.length > 50) { title = title.substring(0, 47) + '...'; } return title || null; } /** Safely invokes callback with try-catch to prevent unhandled errors. */ private async safeCallback( callback: TitleGenerationCallback, conversationId: string, result: TitleGenerationResult ): Promise { try { await callback(conversationId, result); } catch { // Silently ignore callback errors } } } ================================================ FILE: src/features/chat/state/ChatState.ts ================================================ import type { UsageInfo } from '../../../core/types'; import type { ChatMessage, ChatStateCallbacks, ChatStateData, PendingToolCall, PermissionMode, QueuedMessage, ThinkingBlockState, TodoItem, WriteEditState, } from './types'; function createInitialState(): ChatStateData { return { messages: [], isStreaming: false, cancelRequested: false, streamGeneration: 0, isCreatingConversation: false, isSwitchingConversation: false, currentConversationId: null, queuedMessage: null, currentContentEl: null, currentTextEl: null, currentTextContent: '', currentThinkingState: null, thinkingEl: null, queueIndicatorEl: null, thinkingIndicatorTimeout: null, toolCallElements: new Map(), writeEditStates: new Map(), pendingTools: new Map(), usage: null, ignoreUsageUpdates: false, currentTodos: null, needsAttention: false, autoScrollEnabled: true, // Default; controllers will override based on settings responseStartTime: null, flavorTimerInterval: null, pendingNewSessionPlan: null, planFilePath: null, prePlanPermissionMode: null, }; } export class ChatState { private state: ChatStateData; private _callbacks: ChatStateCallbacks; constructor(callbacks: ChatStateCallbacks = {}) { this.state = createInitialState(); this._callbacks = callbacks; } get callbacks(): ChatStateCallbacks { return this._callbacks; } set callbacks(value: ChatStateCallbacks) { this._callbacks = value; } // ============================================ // Messages // ============================================ get messages(): ChatMessage[] { return [...this.state.messages]; } set messages(value: ChatMessage[]) { this.state.messages = value; this._callbacks.onMessagesChanged?.(); } addMessage(msg: ChatMessage): void { this.state.messages.push(msg); this._callbacks.onMessagesChanged?.(); } clearMessages(): void { this.state.messages = []; this._callbacks.onMessagesChanged?.(); } truncateAt(messageId: string): number { const idx = this.state.messages.findIndex(m => m.id === messageId); if (idx === -1) return 0; const removed = this.state.messages.length - idx; this.state.messages = this.state.messages.slice(0, idx); this._callbacks.onMessagesChanged?.(); return removed; } // ============================================ // Streaming Control // ============================================ get isStreaming(): boolean { return this.state.isStreaming; } set isStreaming(value: boolean) { this.state.isStreaming = value; this._callbacks.onStreamingStateChanged?.(value); } get cancelRequested(): boolean { return this.state.cancelRequested; } set cancelRequested(value: boolean) { this.state.cancelRequested = value; } get streamGeneration(): number { return this.state.streamGeneration; } bumpStreamGeneration(): number { this.state.streamGeneration += 1; return this.state.streamGeneration; } get isCreatingConversation(): boolean { return this.state.isCreatingConversation; } set isCreatingConversation(value: boolean) { this.state.isCreatingConversation = value; } get isSwitchingConversation(): boolean { return this.state.isSwitchingConversation; } set isSwitchingConversation(value: boolean) { this.state.isSwitchingConversation = value; } // ============================================ // Conversation // ============================================ get currentConversationId(): string | null { return this.state.currentConversationId; } set currentConversationId(value: string | null) { this.state.currentConversationId = value; this._callbacks.onConversationChanged?.(value); } // ============================================ // Queued Message // ============================================ get queuedMessage(): QueuedMessage | null { return this.state.queuedMessage; } set queuedMessage(value: QueuedMessage | null) { this.state.queuedMessage = value; } // ============================================ // Streaming DOM State // ============================================ get currentContentEl(): HTMLElement | null { return this.state.currentContentEl; } set currentContentEl(value: HTMLElement | null) { this.state.currentContentEl = value; } get currentTextEl(): HTMLElement | null { return this.state.currentTextEl; } set currentTextEl(value: HTMLElement | null) { this.state.currentTextEl = value; } get currentTextContent(): string { return this.state.currentTextContent; } set currentTextContent(value: string) { this.state.currentTextContent = value; } get currentThinkingState(): ThinkingBlockState | null { return this.state.currentThinkingState; } set currentThinkingState(value: ThinkingBlockState | null) { this.state.currentThinkingState = value; } get thinkingEl(): HTMLElement | null { return this.state.thinkingEl; } set thinkingEl(value: HTMLElement | null) { this.state.thinkingEl = value; } get queueIndicatorEl(): HTMLElement | null { return this.state.queueIndicatorEl; } set queueIndicatorEl(value: HTMLElement | null) { this.state.queueIndicatorEl = value; } get thinkingIndicatorTimeout(): ReturnType | null { return this.state.thinkingIndicatorTimeout; } set thinkingIndicatorTimeout(value: ReturnType | null) { this.state.thinkingIndicatorTimeout = value; } // ============================================ // Tool Tracking Maps (mutable references) // ============================================ get toolCallElements(): Map { return this.state.toolCallElements; } get writeEditStates(): Map { return this.state.writeEditStates; } get pendingTools(): Map { return this.state.pendingTools; } // ============================================ // Usage State // ============================================ get usage(): UsageInfo | null { return this.state.usage; } set usage(value: UsageInfo | null) { this.state.usage = value; this._callbacks.onUsageChanged?.(value); } get ignoreUsageUpdates(): boolean { return this.state.ignoreUsageUpdates; } set ignoreUsageUpdates(value: boolean) { this.state.ignoreUsageUpdates = value; } // ============================================ // Current Todos (for persistent bottom panel) // ============================================ get currentTodos(): TodoItem[] | null { return this.state.currentTodos ? [...this.state.currentTodos] : null; } set currentTodos(value: TodoItem[] | null) { // Normalize empty arrays to null for consistency const normalizedValue = (value && value.length > 0) ? value : null; this.state.currentTodos = normalizedValue; this._callbacks.onTodosChanged?.(normalizedValue); } // ============================================ // Attention State (approval pending, error, etc.) // ============================================ get needsAttention(): boolean { return this.state.needsAttention; } set needsAttention(value: boolean) { this.state.needsAttention = value; this._callbacks.onAttentionChanged?.(value); } // ============================================ // Auto-Scroll Control // ============================================ get autoScrollEnabled(): boolean { return this.state.autoScrollEnabled; } set autoScrollEnabled(value: boolean) { const changed = this.state.autoScrollEnabled !== value; this.state.autoScrollEnabled = value; if (changed) { this._callbacks.onAutoScrollChanged?.(value); } } // ============================================ // Response Timer State // ============================================ get responseStartTime(): number | null { return this.state.responseStartTime; } set responseStartTime(value: number | null) { this.state.responseStartTime = value; } get flavorTimerInterval(): ReturnType | null { return this.state.flavorTimerInterval; } set flavorTimerInterval(value: ReturnType | null) { this.state.flavorTimerInterval = value; } get pendingNewSessionPlan(): string | null { return this.state.pendingNewSessionPlan; } set pendingNewSessionPlan(value: string | null) { this.state.pendingNewSessionPlan = value; } get planFilePath(): string | null { return this.state.planFilePath; } set planFilePath(value: string | null) { this.state.planFilePath = value; } get prePlanPermissionMode(): PermissionMode | null { return this.state.prePlanPermissionMode; } set prePlanPermissionMode(value: PermissionMode | null) { this.state.prePlanPermissionMode = value; } // ============================================ // Reset Methods // ============================================ clearFlavorTimerInterval(): void { if (this.state.flavorTimerInterval) { clearInterval(this.state.flavorTimerInterval); this.state.flavorTimerInterval = null; } } resetStreamingState(): void { this.state.currentContentEl = null; this.state.currentTextEl = null; this.state.currentTextContent = ''; this.state.currentThinkingState = null; this.state.isStreaming = false; this.state.cancelRequested = false; // Clear thinking indicator timeout if (this.state.thinkingIndicatorTimeout) { clearTimeout(this.state.thinkingIndicatorTimeout); this.state.thinkingIndicatorTimeout = null; } // Clear response timer this.clearFlavorTimerInterval(); this.state.responseStartTime = null; } clearMaps(): void { this.state.toolCallElements.clear(); this.state.writeEditStates.clear(); this.state.pendingTools.clear(); } resetForNewConversation(): void { this.clearMessages(); this.resetStreamingState(); this.clearMaps(); this.state.queuedMessage = null; this.usage = null; this.currentTodos = null; this.autoScrollEnabled = true; } getPersistedMessages(): ChatMessage[] { // Return messages as-is - image data is single source of truth return this.state.messages; } } export { createInitialState }; ================================================ FILE: src/features/chat/state/index.ts ================================================ export { ChatState, createInitialState } from './ChatState'; export type { ChatMessage, ChatStateCallbacks, ChatStateData, EditorSelectionContext, ImageAttachment, QueryOptions, QueuedMessage, StoredSelection, SubagentInfo, ThinkingBlockState, ToolCallInfo, WriteEditState, } from './types'; ================================================ FILE: src/features/chat/state/types.ts ================================================ import type { EditorView } from '@codemirror/view'; import type { TodoItem } from '../../../core/tools'; import type { ChatMessage, ImageAttachment, PermissionMode, SubagentInfo, ToolCallInfo, UsageInfo, } from '../../../core/types'; import type { BrowserSelectionContext } from '../../../utils/browser'; import type { CanvasSelectionContext } from '../../../utils/canvas'; import type { EditorSelectionContext } from '../../../utils/editor'; import type { ThinkingBlockState, WriteEditState, } from '../rendering'; /** Queued message waiting to be sent after current streaming completes. */ export interface QueuedMessage { content: string; images?: ImageAttachment[]; editorContext: EditorSelectionContext | null; browserContext?: BrowserSelectionContext | null; canvasContext: CanvasSelectionContext | null; } /** Pending tool call waiting to be rendered (buffered until input is complete). */ export interface PendingToolCall { toolCall: ToolCallInfo; parentEl: HTMLElement | null; } /** Stored selection state from editor polling. */ export interface StoredSelection { notePath: string; selectedText: string; lineCount: number; startLine?: number; from?: number; to?: number; editorView?: EditorView; } /** Centralized chat state data. */ export interface ChatStateData { // Message state messages: ChatMessage[]; // Streaming control isStreaming: boolean; cancelRequested: boolean; streamGeneration: number; /** Guards against concurrent operations during conversation creation. */ isCreatingConversation: boolean; /** Guards against concurrent operations during conversation switching. */ isSwitchingConversation: boolean; // Conversation identity currentConversationId: string | null; // Queued message queuedMessage: QueuedMessage | null; // Active streaming DOM state currentContentEl: HTMLElement | null; currentTextEl: HTMLElement | null; currentTextContent: string; currentThinkingState: ThinkingBlockState | null; thinkingEl: HTMLElement | null; queueIndicatorEl: HTMLElement | null; /** Debounce timeout for showing thinking indicator after inactivity. */ thinkingIndicatorTimeout: ReturnType | null; // Tool tracking maps toolCallElements: Map; writeEditStates: Map; /** Pending tool calls buffered until input is complete (for non-streaming-style render). */ pendingTools: Map; // Context window usage usage: UsageInfo | null; // Flag to ignore usage updates (during session reset) ignoreUsageUpdates: boolean; // Current todo items for the persistent bottom panel currentTodos: TodoItem[] | null; // Attention state (approval pending, error, etc.) needsAttention: boolean; // Auto-scroll control during streaming autoScrollEnabled: boolean; // Response timer state responseStartTime: number | null; flavorTimerInterval: ReturnType | null; // Pending plan content for approve-new-session (auto-sends in new session after stream ends) pendingNewSessionPlan: string | null; // Plan file path captured from Write tool calls to ~/.claude/plans/ during plan mode planFilePath: string | null; // Saved permission mode before entering plan mode (for Shift+Tab toggle restore) prePlanPermissionMode: PermissionMode | null; } /** Callbacks for ChatState changes. */ export interface ChatStateCallbacks { onMessagesChanged?: () => void; onStreamingStateChanged?: (isStreaming: boolean) => void; onConversationChanged?: (id: string | null) => void; onUsageChanged?: (usage: UsageInfo | null) => void; onTodosChanged?: (todos: TodoItem[] | null) => void; onAttentionChanged?: (needsAttention: boolean) => void; onAutoScrollChanged?: (enabled: boolean) => void; } /** Options for query execution. */ export interface QueryOptions { allowedTools?: string[]; model?: string; mcpMentions?: Set; enabledMcpServers?: Set; forceColdStart?: boolean; externalContextPaths?: string[]; } // Re-export types that are used across the chat feature export type { ChatMessage, EditorSelectionContext, ImageAttachment, PermissionMode, SubagentInfo, ThinkingBlockState, TodoItem, ToolCallInfo, UsageInfo, WriteEditState, }; ================================================ FILE: src/features/chat/tabs/Tab.ts ================================================ import type { Component } from 'obsidian'; import { Notice } from 'obsidian'; import { ClaudianService } from '../../../core/agent'; import type { McpServerManager } from '../../../core/mcp'; import type { ChatMessage, ClaudeModel, Conversation, EffortLevel, PermissionMode, SlashCommand, StreamChunk, ThinkingBudget } from '../../../core/types'; import { DEFAULT_CLAUDE_MODELS, DEFAULT_EFFORT_LEVEL, DEFAULT_THINKING_BUDGET, getContextWindowSize, isAdaptiveThinkingModel } from '../../../core/types'; import { t } from '../../../i18n'; import type ClaudianPlugin from '../../../main'; import { SlashCommandDropdown } from '../../../shared/components/SlashCommandDropdown'; import { getEnhancedPath } from '../../../utils/env'; import { getVaultPath } from '../../../utils/path'; import { BrowserSelectionController, CanvasSelectionController, ConversationController, InputController, NavigationController, SelectionController, StreamController, } from '../controllers'; import { cleanupThinkingBlock, MessageRenderer } from '../rendering'; import { findRewindContext } from '../rewind'; import { BangBashService } from '../services/BangBashService'; import { InstructionRefineService } from '../services/InstructionRefineService'; import { SubagentManager } from '../services/SubagentManager'; import { TitleGenerationService } from '../services/TitleGenerationService'; import { ChatState } from '../state'; import { BangBashModeManager as BangBashModeManagerClass, createInputToolbar, FileContextManager, ImageContextManager, InstructionModeManager as InstructionModeManagerClass, NavigationSidebar, StatusPanel, } from '../ui'; import type { TabData, TabDOMElements, TabId } from './types'; import { generateTabId, TEXTAREA_MAX_HEIGHT_PERCENT, TEXTAREA_MIN_MAX_HEIGHT } from './types'; export interface TabCreateOptions { plugin: ClaudianPlugin; mcpManager: McpServerManager; containerEl: HTMLElement; conversation?: Conversation; tabId?: TabId; onStreamingChanged?: (isStreaming: boolean) => void; onTitleChanged?: (title: string) => void; onAttentionChanged?: (needsAttention: boolean) => void; onConversationIdChanged?: (conversationId: string | null) => void; } /** * Creates a new Tab instance with all required state. */ export function createTab(options: TabCreateOptions): TabData { const { containerEl, conversation, tabId, onStreamingChanged, onAttentionChanged, onConversationIdChanged, } = options; const id = tabId ?? generateTabId(); // Create per-tab content container (hidden by default) const contentEl = containerEl.createDiv({ cls: 'claudian-tab-content' }); contentEl.style.display = 'none'; // Create ChatState with callbacks const state = new ChatState({ onStreamingStateChanged: (isStreaming) => { onStreamingChanged?.(isStreaming); }, onAttentionChanged: (needsAttention) => { onAttentionChanged?.(needsAttention); }, onConversationChanged: (conversationId) => { onConversationIdChanged?.(conversationId); }, }); // Create subagent manager with no-op callback. // This placeholder is replaced in initializeTabControllers() with the actual // callback that updates the StreamController. We defer the real callback // because StreamController doesn't exist until controllers are initialized. const subagentManager = new SubagentManager(() => {}); // Create DOM structure const dom = buildTabDOM(contentEl); // Create initial TabData (service and controllers are lazy-initialized) const tab: TabData = { id, conversationId: conversation?.id ?? null, service: null, serviceInitialized: false, state, controllers: { selectionController: null, browserSelectionController: null, canvasSelectionController: null, conversationController: null, streamController: null, inputController: null, navigationController: null, }, services: { subagentManager, instructionRefineService: null, titleGenerationService: null, }, ui: { fileContextManager: null, imageContextManager: null, modelSelector: null, thinkingBudgetSelector: null, externalContextSelector: null, mcpServerSelector: null, permissionToggle: null, slashCommandDropdown: null, instructionModeManager: null, bangBashModeManager: null, contextUsageMeter: null, statusPanel: null, navigationSidebar: null, }, dom, renderer: null, }; return tab; } /** * Auto-resizes a textarea based on its content. * * Logic: * - At minimum wrapper height: let flexbox allocate space (textarea fills available) * - When content exceeds flex allocation: set min-height to force wrapper growth * - When content shrinks: remove min-height override to let wrapper shrink * - Max height is capped at 55% of view height (minimum 150px) */ function autoResizeTextarea(textarea: HTMLTextAreaElement): void { // Clear inline min-height to let flexbox compute natural allocation textarea.style.minHeight = ''; // Calculate max height: 55% of view height, minimum 150px const viewHeight = textarea.closest('.claudian-container')?.clientHeight ?? window.innerHeight; const maxHeight = Math.max(TEXTAREA_MIN_MAX_HEIGHT, viewHeight * TEXTAREA_MAX_HEIGHT_PERCENT); // Get flex-allocated height (what flexbox gives the textarea) const flexAllocatedHeight = textarea.offsetHeight; // Get content height (what the content actually needs), capped at max const contentHeight = Math.min(textarea.scrollHeight, maxHeight); // Only set min-height if content exceeds flex allocation // This forces the wrapper to grow while letting it shrink when content reduces if (contentHeight > flexAllocatedHeight) { textarea.style.minHeight = `${contentHeight}px`; } // Always set max-height to enforce the cap textarea.style.maxHeight = `${maxHeight}px`; } /** * Builds the DOM structure for a tab. */ function buildTabDOM(contentEl: HTMLElement): TabDOMElements { // Messages wrapper (for scroll-to-bottom button positioning) const messagesWrapperEl = contentEl.createDiv({ cls: 'claudian-messages-wrapper' }); // Messages area (inside wrapper) const messagesEl = messagesWrapperEl.createDiv({ cls: 'claudian-messages' }); // Welcome message placeholder const welcomeEl = messagesEl.createDiv({ cls: 'claudian-welcome' }); // Status panel container (fixed between messages and input) const statusPanelContainerEl = contentEl.createDiv({ cls: 'claudian-status-panel-container' }); // Input container const inputContainerEl = contentEl.createDiv({ cls: 'claudian-input-container' }); // Nav row (for tab badges and header icons, populated by ClaudianView) const navRowEl = inputContainerEl.createDiv({ cls: 'claudian-input-nav-row' }); const inputWrapper = inputContainerEl.createDiv({ cls: 'claudian-input-wrapper' }); // Context row inside input wrapper (file chips + selection indicator) const contextRowEl = inputWrapper.createDiv({ cls: 'claudian-context-row' }); // Input textarea const inputEl = inputWrapper.createEl('textarea', { cls: 'claudian-input', attr: { placeholder: 'How can I help you today?', rows: '3', dir: 'auto', }, }); return { contentEl, messagesEl, welcomeEl, statusPanelContainerEl, inputContainerEl, inputWrapper, inputEl, navRowEl, contextRowEl, selectionIndicatorEl: null, browserIndicatorEl: null, canvasIndicatorEl: null, eventCleanups: [], }; } /** * Initializes the tab's ClaudianService (lazy initialization). * Call this when the tab becomes active or when the first message is sent. * * Session ID resolution: * - If tab has conversationId (existing chat) → lookup conversation's sessionId → ensureReady with it * - If tab has no conversationId (new chat) → ensureReady without sessionId * * This ensures the single source of truth (tab.conversationId) determines session behavior. * * Ensures consistent state: if initialization fails, tab.service is null * and tab.serviceInitialized remains false for retry. */ export async function initializeTabService( tab: TabData, plugin: ClaudianPlugin, mcpManager: McpServerManager ): Promise { if (tab.serviceInitialized) { return; } let service: ClaudianService | null = null; let unsubscribeReadyState: (() => void) | null = null; try { // Create per-tab ClaudianService service = new ClaudianService(plugin, mcpManager); unsubscribeReadyState = service.onReadyStateChange((ready) => { tab.ui.modelSelector?.setReady(ready); }); tab.dom.eventCleanups.push(() => unsubscribeReadyState?.()); // Resolve session ID and external contexts from conversation if this is an existing chat // Single source of truth: tab.conversationId determines if we have a session to resume let sessionId: string | undefined; let externalContextPaths = plugin.settings.persistentExternalContextPaths || []; if (tab.conversationId) { const conversation = await plugin.getConversationById(tab.conversationId); if (conversation) { sessionId = service.applyForkState(conversation) ?? undefined; const hasMessages = conversation.messages.length > 0; externalContextPaths = hasMessages ? conversation.externalContextPaths || [] : (plugin.settings.persistentExternalContextPaths || []); } } // Ensure SDK process is ready // - Existing chat: with sessionId for resume // - New chat: without sessionId service.ensureReady({ sessionId, externalContextPaths, }).catch(() => { // Best-effort, ignore failures }); // Only set tab state after successful initialization tab.service = service; tab.serviceInitialized = true; } catch (error) { // Clean up partial state on failure unsubscribeReadyState?.(); service?.closePersistentQuery('initialization failed'); tab.service = null; tab.serviceInitialized = false; // Re-throw to let caller handle (e.g., show error to user) throw error; } } /** * Initializes file and image context managers for a tab. */ function initializeContextManagers(tab: TabData, plugin: ClaudianPlugin): void { const { dom } = tab; const app = plugin.app; // File context manager - chips in contextRowEl, dropdown in inputContainerEl tab.ui.fileContextManager = new FileContextManager( app, dom.contextRowEl, dom.inputEl, { getExcludedTags: () => plugin.settings.excludedTags, onChipsChanged: () => { tab.controllers.selectionController?.updateContextRowVisibility(); tab.controllers.browserSelectionController?.updateContextRowVisibility(); tab.controllers.canvasSelectionController?.updateContextRowVisibility(); autoResizeTextarea(dom.inputEl); tab.renderer?.scrollToBottomIfNeeded(); }, getExternalContexts: () => tab.ui.externalContextSelector?.getExternalContexts() || [], }, dom.inputContainerEl ); tab.ui.fileContextManager.setMcpManager(plugin.mcpManager); tab.ui.fileContextManager.setAgentService(plugin.agentManager); // Image context manager - drag/drop uses inputContainerEl, preview in contextRowEl tab.ui.imageContextManager = new ImageContextManager( dom.inputContainerEl, dom.inputEl, { onImagesChanged: () => { tab.controllers.selectionController?.updateContextRowVisibility(); tab.controllers.browserSelectionController?.updateContextRowVisibility(); tab.controllers.canvasSelectionController?.updateContextRowVisibility(); autoResizeTextarea(dom.inputEl); tab.renderer?.scrollToBottomIfNeeded(); }, }, dom.contextRowEl ); } /** * Initializes slash command dropdown for a tab. * @param getSdkCommands Callback to get SDK commands from any ready service (shared across tabs). * @param getHiddenCommands Callback to get current hidden commands from settings. */ function initializeSlashCommands( tab: TabData, getSdkCommands?: () => Promise, getHiddenCommands?: () => Set ): void { const { dom } = tab; tab.ui.slashCommandDropdown = new SlashCommandDropdown( dom.inputContainerEl, dom.inputEl, { onSelect: () => {}, onHide: () => {}, getSdkCommands, }, { hiddenCommands: getHiddenCommands?.() ?? new Set(), } ); } /** * Initializes instruction mode and todo panel for a tab. */ function initializeInstructionAndTodo(tab: TabData, plugin: ClaudianPlugin): void { const { dom } = tab; tab.services.instructionRefineService = new InstructionRefineService(plugin); tab.services.titleGenerationService = new TitleGenerationService(plugin); tab.ui.instructionModeManager = new InstructionModeManagerClass( dom.inputEl, { onSubmit: async (rawInstruction) => { await tab.controllers.inputController?.handleInstructionSubmit(rawInstruction); }, getInputWrapper: () => dom.inputWrapper, } ); // Bang bash mode (! command execution) if (plugin.settings.enableBangBash) { const vaultPath = getVaultPath(plugin.app); if (vaultPath) { const enhancedPath = getEnhancedPath(); const bashService = new BangBashService(vaultPath, enhancedPath); tab.ui.bangBashModeManager = new BangBashModeManagerClass( dom.inputEl, { onSubmit: async (command) => { const statusPanel = tab.ui.statusPanel; if (!statusPanel) return; const id = `bash-${Date.now()}`; statusPanel.addBashOutput({ id, command, status: 'running', output: '' }); const result = await bashService.execute(command); const output = [result.stdout, result.stderr, result.error].filter(Boolean).join('\n').trim(); const status = result.exitCode === 0 ? 'completed' : 'error'; statusPanel.updateBashOutput(id, { status, output, exitCode: result.exitCode }); }, getInputWrapper: () => dom.inputWrapper, } ); } } tab.ui.statusPanel = new StatusPanel(); tab.ui.statusPanel.mount(dom.statusPanelContainerEl); } /** * Creates and wires the input toolbar for a tab. */ function initializeInputToolbar(tab: TabData, plugin: ClaudianPlugin): void { const { dom } = tab; const inputToolbar = dom.inputWrapper.createDiv({ cls: 'claudian-input-toolbar' }); const toolbarComponents = createInputToolbar(inputToolbar, { getSettings: () => ({ model: plugin.settings.model, thinkingBudget: plugin.settings.thinkingBudget, effortLevel: plugin.settings.effortLevel, permissionMode: plugin.settings.permissionMode, enableOpus1M: plugin.settings.enableOpus1M, enableSonnet1M: plugin.settings.enableSonnet1M, }), getEnvironmentVariables: () => plugin.getActiveEnvironmentVariables(), onModelChange: async (model: ClaudeModel) => { plugin.settings.model = model; const isDefaultModel = DEFAULT_CLAUDE_MODELS.find((m) => m.value === model); if (isDefaultModel) { plugin.settings.thinkingBudget = DEFAULT_THINKING_BUDGET[model]; if (isAdaptiveThinkingModel(model)) { plugin.settings.effortLevel = DEFAULT_EFFORT_LEVEL[model] ?? 'high'; } plugin.settings.lastClaudeModel = model; } else { plugin.settings.lastCustomModel = model; } await plugin.saveSettings(); tab.ui.thinkingBudgetSelector?.updateDisplay(); tab.ui.modelSelector?.updateDisplay(); tab.ui.modelSelector?.renderOptions(); // Recalculate context usage percentage for the new model's context window const currentUsage = tab.state.usage; if (currentUsage) { const newContextWindow = getContextWindowSize(model, plugin.settings.customContextLimits); const newPercentage = Math.min(100, Math.max(0, Math.round((currentUsage.contextTokens / newContextWindow) * 100))); tab.state.usage = { ...currentUsage, model, contextWindow: newContextWindow, percentage: newPercentage, }; } }, onThinkingBudgetChange: async (budget: ThinkingBudget) => { plugin.settings.thinkingBudget = budget; await plugin.saveSettings(); }, onEffortLevelChange: async (effort: EffortLevel) => { plugin.settings.effortLevel = effort; await plugin.saveSettings(); }, onPermissionModeChange: async (mode) => { plugin.settings.permissionMode = mode; await plugin.saveSettings(); dom.inputWrapper.toggleClass('claudian-input-plan-mode', mode === 'plan'); }, }); tab.ui.modelSelector = toolbarComponents.modelSelector; tab.ui.thinkingBudgetSelector = toolbarComponents.thinkingBudgetSelector; tab.ui.contextUsageMeter = toolbarComponents.contextUsageMeter; tab.ui.externalContextSelector = toolbarComponents.externalContextSelector; tab.ui.mcpServerSelector = toolbarComponents.mcpServerSelector; tab.ui.permissionToggle = toolbarComponents.permissionToggle; tab.ui.mcpServerSelector.setMcpManager(plugin.mcpManager); // Sync @-mentions to UI selector tab.ui.fileContextManager?.setOnMcpMentionChange((servers) => { tab.ui.mcpServerSelector?.addMentionedServers(servers); }); // Wire external context changes tab.ui.externalContextSelector.setOnChange(() => { tab.ui.fileContextManager?.preScanExternalContexts(); }); // Initialize persistent paths tab.ui.externalContextSelector.setPersistentPaths( plugin.settings.persistentExternalContextPaths || [] ); // Wire persistence changes tab.ui.externalContextSelector.setOnPersistenceChange(async (paths) => { plugin.settings.persistentExternalContextPaths = paths; await plugin.saveSettings(); }); dom.inputWrapper.toggleClass('claudian-input-plan-mode', plugin.settings.permissionMode === 'plan'); } export interface InitializeTabUIOptions { getSdkCommands?: () => Promise; } /** * Initializes the tab's UI components. * Call this after the tab is created and before it becomes active. */ export function initializeTabUI( tab: TabData, plugin: ClaudianPlugin, options: InitializeTabUIOptions = {} ): void { const { dom, state } = tab; // Initialize context managers (file/image) initializeContextManagers(tab, plugin); // Selection indicator - add to contextRowEl dom.selectionIndicatorEl = dom.contextRowEl.createDiv({ cls: 'claudian-selection-indicator' }); dom.selectionIndicatorEl.style.display = 'none'; // Browser selection indicator dom.browserIndicatorEl = dom.contextRowEl.createDiv({ cls: 'claudian-browser-selection-indicator' }); dom.browserIndicatorEl.style.display = 'none'; // Canvas selection indicator dom.canvasIndicatorEl = dom.contextRowEl.createDiv({ cls: 'claudian-canvas-indicator' }); dom.canvasIndicatorEl.style.display = 'none'; // Initialize slash commands with shared SDK commands callback and hidden commands initializeSlashCommands( tab, options.getSdkCommands, () => new Set((plugin.settings.hiddenSlashCommands || []).map(c => c.toLowerCase())) ); // Initialize navigation sidebar if (dom.messagesEl.parentElement) { tab.ui.navigationSidebar = new NavigationSidebar( dom.messagesEl.parentElement, dom.messagesEl ); } // Initialize instruction mode and todo panel initializeInstructionAndTodo(tab, plugin); // Initialize input toolbar initializeInputToolbar(tab, plugin); // Update ChatState callbacks for UI updates state.callbacks = { ...state.callbacks, onUsageChanged: (usage) => tab.ui.contextUsageMeter?.update(usage), onTodosChanged: (todos) => tab.ui.statusPanel?.updateTodos(todos), onAutoScrollChanged: () => tab.ui.navigationSidebar?.updateVisibility(), }; // ResizeObserver to detect overflow changes (e.g., content growth) const resizeObserver = new ResizeObserver(() => { tab.ui.navigationSidebar?.updateVisibility(); }); resizeObserver.observe(dom.messagesEl); dom.eventCleanups.push(() => resizeObserver.disconnect()); } export interface ForkContext { messages: ChatMessage[]; sourceSessionId: string; resumeAt: string; sourceTitle?: string; /** 1-based index used for fork title suffix (counts only non-interrupt user messages). */ forkAtUserMessage?: number; currentNote?: string; } function deepCloneMessages(messages: ChatMessage[]): ChatMessage[] { const sc = (globalThis as unknown as { structuredClone?: (value: T) => T }).structuredClone; if (typeof sc === 'function') { return sc(messages); } return JSON.parse(JSON.stringify(messages)) as ChatMessage[]; } function countUserMessagesForForkTitle(messages: ChatMessage[]): number { // Keep fork numbering stable by excluding non-semantic user messages. return messages.filter(m => m.role === 'user' && !m.isInterrupt && !m.isRebuiltContext).length; } interface ForkSource { sourceSessionId: string; sourceTitle?: string; currentNote?: string; } /** * Resolves session ID and conversation metadata needed for forking. * Prefers the live service session ID; falls back to persisted conversation metadata. * Shows a notice and returns null when no session can be resolved. */ function resolveForkSource(tab: TabData, plugin: ClaudianPlugin): ForkSource | null { let sourceSessionId = tab.service?.getSessionId() ?? null; if (!sourceSessionId && tab.conversationId) { const conversation = plugin.getConversationSync(tab.conversationId); sourceSessionId = conversation?.sdkSessionId ?? conversation?.sessionId ?? conversation?.forkSource?.sessionId ?? null; } if (!sourceSessionId) { new Notice(t('chat.fork.failed', { error: t('chat.fork.errorNoSession') })); return null; } const sourceConversation = tab.conversationId ? plugin.getConversationSync(tab.conversationId) : undefined; return { sourceSessionId, sourceTitle: sourceConversation?.title, currentNote: sourceConversation?.currentNote, }; } async function handleForkRequest( tab: TabData, plugin: ClaudianPlugin, userMessageId: string, forkRequestCallback: (forkContext: ForkContext) => Promise, ): Promise { const { state } = tab; if (state.isStreaming) { new Notice(t('chat.fork.unavailableStreaming')); return; } const msgs = state.messages; const userIdx = msgs.findIndex(m => m.id === userMessageId); if (userIdx === -1) { new Notice(t('chat.fork.failed', { error: t('chat.fork.errorMessageNotFound') })); return; } if (!msgs[userIdx].sdkUserUuid) { new Notice(t('chat.fork.unavailableNoUuid')); return; } const rewindCtx = findRewindContext(msgs, userIdx); if (!rewindCtx.hasResponse || !rewindCtx.prevAssistantUuid) { new Notice(t('chat.fork.unavailableNoResponse')); return; } const source = resolveForkSource(tab, plugin); if (!source) return; await forkRequestCallback({ messages: deepCloneMessages(msgs.slice(0, userIdx)), sourceSessionId: source.sourceSessionId, resumeAt: rewindCtx.prevAssistantUuid, sourceTitle: source.sourceTitle, forkAtUserMessage: countUserMessagesForForkTitle(msgs.slice(0, userIdx + 1)), currentNote: source.currentNote, }); } async function handleForkAll( tab: TabData, plugin: ClaudianPlugin, forkRequestCallback: (forkContext: ForkContext) => Promise, ): Promise { const { state } = tab; if (state.isStreaming) { new Notice(t('chat.fork.unavailableStreaming')); return; } const msgs = state.messages; if (msgs.length === 0) { new Notice(t('chat.fork.commandNoMessages')); return; } let lastAssistantUuid: string | undefined; for (let i = msgs.length - 1; i >= 0; i--) { if (msgs[i].role === 'assistant' && msgs[i].sdkAssistantUuid) { lastAssistantUuid = msgs[i].sdkAssistantUuid; break; } } if (!lastAssistantUuid) { new Notice(t('chat.fork.commandNoAssistantUuid')); return; } const source = resolveForkSource(tab, plugin); if (!source) return; await forkRequestCallback({ messages: deepCloneMessages(msgs), sourceSessionId: source.sourceSessionId, resumeAt: lastAssistantUuid, sourceTitle: source.sourceTitle, forkAtUserMessage: countUserMessagesForForkTitle(msgs) + 1, currentNote: source.currentNote, }); } export function initializeTabControllers( tab: TabData, plugin: ClaudianPlugin, component: Component, mcpManager: McpServerManager, forkRequestCallback?: (forkContext: ForkContext) => Promise, openConversation?: (conversationId: string) => Promise, ): void { const { dom, state, services, ui } = tab; // Create renderer tab.renderer = new MessageRenderer( plugin, component, dom.messagesEl, (id) => tab.controllers.conversationController!.rewind(id), forkRequestCallback ? (id) => handleForkRequest(tab, plugin, id, forkRequestCallback) : undefined, ); // Selection controller tab.controllers.selectionController = new SelectionController( plugin.app, dom.selectionIndicatorEl!, dom.inputEl, dom.contextRowEl, () => autoResizeTextarea(dom.inputEl) ); // Browser selection controller tab.controllers.browserSelectionController = new BrowserSelectionController( plugin.app, dom.browserIndicatorEl!, dom.inputEl, dom.contextRowEl, () => autoResizeTextarea(dom.inputEl) ); // Canvas selection controller tab.controllers.canvasSelectionController = new CanvasSelectionController( plugin.app, dom.canvasIndicatorEl!, dom.inputEl, dom.contextRowEl, () => autoResizeTextarea(dom.inputEl) ); // Stream controller tab.controllers.streamController = new StreamController({ plugin, state, renderer: tab.renderer, subagentManager: services.subagentManager, getMessagesEl: () => dom.messagesEl, getFileContextManager: () => ui.fileContextManager, updateQueueIndicator: () => tab.controllers.inputController?.updateQueueIndicator(), getAgentService: () => tab.service, }); // Wire subagent callback now that StreamController exists // DOM updates for async subagents are handled by SubagentManager directly; // this callback handles message persistence. services.subagentManager.setCallback( (subagent) => { // Update messages (DOM already updated by manager) tab.controllers.streamController?.onAsyncSubagentStateChange(subagent); // During active stream, regular end-of-turn save captures latest state. if (!tab.state.isStreaming && tab.state.currentConversationId) { void tab.controllers.conversationController?.save(false).catch(() => { // Best-effort persistence; avoid surfacing background-save failures here. }); } } ); // Conversation controller tab.controllers.conversationController = new ConversationController( { plugin, state, renderer: tab.renderer, subagentManager: services.subagentManager, getHistoryDropdown: () => null, // Tab doesn't have its own history dropdown getWelcomeEl: () => dom.welcomeEl, setWelcomeEl: (el) => { dom.welcomeEl = el; }, getMessagesEl: () => dom.messagesEl, getInputEl: () => dom.inputEl, getFileContextManager: () => ui.fileContextManager, getImageContextManager: () => ui.imageContextManager, getMcpServerSelector: () => ui.mcpServerSelector, getExternalContextSelector: () => ui.externalContextSelector, clearQueuedMessage: () => tab.controllers.inputController?.clearQueuedMessage(), getTitleGenerationService: () => services.titleGenerationService, getStatusPanel: () => ui.statusPanel, getAgentService: () => tab.service, // Use tab's service instead of plugin's }, {} ); // Input controller - needs the tab's service tab.controllers.inputController = new InputController({ plugin, state, renderer: tab.renderer, streamController: tab.controllers.streamController, selectionController: tab.controllers.selectionController, browserSelectionController: tab.controllers.browserSelectionController, canvasSelectionController: tab.controllers.canvasSelectionController, conversationController: tab.controllers.conversationController, getInputEl: () => dom.inputEl, getInputContainerEl: () => dom.inputContainerEl, getWelcomeEl: () => dom.welcomeEl, getMessagesEl: () => dom.messagesEl, getFileContextManager: () => ui.fileContextManager, getImageContextManager: () => ui.imageContextManager, getMcpServerSelector: () => ui.mcpServerSelector, getExternalContextSelector: () => ui.externalContextSelector, getInstructionModeManager: () => ui.instructionModeManager, getInstructionRefineService: () => services.instructionRefineService, getTitleGenerationService: () => services.titleGenerationService, getStatusPanel: () => ui.statusPanel, generateId: generateMessageId, resetInputHeight: () => { // Per-tab input height is managed by CSS, no dynamic adjustment needed }, // Override to use tab's service instead of plugin.agentService getAgentService: () => tab.service, getSubagentManager: () => services.subagentManager, // Lazy initialization: ensure service is ready before first query // initializeTabService() handles session ID resolution from tab.conversationId ensureServiceInitialized: async () => { if (tab.serviceInitialized) { return true; } try { await initializeTabService(tab, plugin, mcpManager); setupServiceCallbacks(tab, plugin); return true; } catch { return false; } }, openConversation, onForkAll: forkRequestCallback ? () => handleForkAll(tab, plugin, forkRequestCallback) : undefined, }); // Navigation controller tab.controllers.navigationController = new NavigationController({ getMessagesEl: () => dom.messagesEl, getInputEl: () => dom.inputEl, getSettings: () => plugin.settings.keyboardNavigation, isStreaming: () => state.isStreaming, shouldSkipEscapeHandling: () => { if (ui.instructionModeManager?.isActive()) return true; if (ui.bangBashModeManager?.isActive()) return true; if (tab.controllers.inputController?.isResumeDropdownVisible()) return true; if (ui.slashCommandDropdown?.isVisible()) return true; if (ui.fileContextManager?.isMentionDropdownVisible()) return true; return false; }, }); tab.controllers.navigationController.initialize(); } /** * Wires up input event handlers for a tab. * Call this after controllers are initialized. * Stores cleanup functions in dom.eventCleanups for proper memory management. */ export function wireTabInputEvents(tab: TabData, plugin: ClaudianPlugin): void { const { dom, ui, state, controllers } = tab; let wasBangBashActive = ui.bangBashModeManager?.isActive() ?? false; const syncBangBashSuppression = (): void => { const isActive = ui.bangBashModeManager?.isActive() ?? false; if (isActive === wasBangBashActive) return; wasBangBashActive = isActive; ui.slashCommandDropdown?.setEnabled(!isActive); if (isActive) { ui.fileContextManager?.hideMentionDropdown(); } }; // Input keydown handler const keydownHandler = (e: KeyboardEvent) => { if (ui.bangBashModeManager?.isActive()) { ui.bangBashModeManager.handleKeydown(e); syncBangBashSuppression(); return; } // Check for # trigger first (empty input + # keystroke) if (ui.instructionModeManager?.handleTriggerKey(e)) { return; } // Check for ! trigger (empty input + ! keystroke) if (ui.bangBashModeManager?.handleTriggerKey(e)) { syncBangBashSuppression(); return; } if (ui.instructionModeManager?.handleKeydown(e)) { return; } if (controllers.inputController?.handleResumeKeydown(e)) { return; } if (ui.slashCommandDropdown?.handleKeydown(e)) { return; } if (ui.fileContextManager?.handleMentionKeydown(e)) { return; } // Check !e.isComposing for IME support (Chinese, Japanese, Korean, etc.) if (e.key === 'Escape' && !e.isComposing && state.isStreaming) { e.preventDefault(); controllers.inputController?.cancelStreaming(); return; } // Enter: Send message if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); void controllers.inputController?.sendMessage(); } }; dom.inputEl.addEventListener('keydown', keydownHandler); dom.eventCleanups.push(() => dom.inputEl.removeEventListener('keydown', keydownHandler)); // Input change handler (includes auto-resize) const inputHandler = () => { if (!ui.bangBashModeManager?.isActive()) { ui.fileContextManager?.handleInputChange(); } ui.instructionModeManager?.handleInputChange(); ui.bangBashModeManager?.handleInputChange(); syncBangBashSuppression(); // Auto-resize textarea based on content autoResizeTextarea(dom.inputEl); }; dom.inputEl.addEventListener('input', inputHandler); dom.eventCleanups.push(() => dom.inputEl.removeEventListener('input', inputHandler)); // Input focus handler const focusHandler = () => { controllers.selectionController?.showHighlight(); }; dom.inputEl.addEventListener('focus', focusHandler); dom.eventCleanups.push(() => dom.inputEl.removeEventListener('focus', focusHandler)); // Scroll listener for auto-scroll control (tracks position always, not just during streaming) const SCROLL_THRESHOLD = 20; // pixels from bottom to consider "at bottom" const RE_ENABLE_DELAY = 150; // ms to wait before re-enabling auto-scroll let reEnableTimeout: ReturnType | null = null; const isAutoScrollAllowed = (): boolean => plugin.settings.enableAutoScroll ?? true; const scrollHandler = () => { if (!isAutoScrollAllowed()) { if (reEnableTimeout) { clearTimeout(reEnableTimeout); reEnableTimeout = null; } state.autoScrollEnabled = false; return; } const { scrollTop, scrollHeight, clientHeight } = dom.messagesEl; const isAtBottom = scrollHeight - scrollTop - clientHeight <= SCROLL_THRESHOLD; if (!isAtBottom) { // Immediately disable when user scrolls up if (reEnableTimeout) { clearTimeout(reEnableTimeout); reEnableTimeout = null; } state.autoScrollEnabled = false; } else if (!state.autoScrollEnabled) { // Debounce re-enabling to avoid bounce during scroll animation if (!reEnableTimeout) { reEnableTimeout = setTimeout(() => { reEnableTimeout = null; // Re-verify position before enabling (content may have changed) const { scrollTop, scrollHeight, clientHeight } = dom.messagesEl; if (scrollHeight - scrollTop - clientHeight <= SCROLL_THRESHOLD) { state.autoScrollEnabled = true; } }, RE_ENABLE_DELAY); } } }; dom.messagesEl.addEventListener('scroll', scrollHandler, { passive: true }); dom.eventCleanups.push(() => { dom.messagesEl.removeEventListener('scroll', scrollHandler); if (reEnableTimeout) clearTimeout(reEnableTimeout); }); } /** * Activates a tab (shows it and starts services). */ export function activateTab(tab: TabData): void { tab.dom.contentEl.style.display = 'flex'; tab.controllers.selectionController?.start(); tab.controllers.browserSelectionController?.start(); tab.controllers.canvasSelectionController?.start(); // Refresh navigation sidebar visibility (dimensions now available after display) tab.ui.navigationSidebar?.updateVisibility(); } /** * Deactivates a tab (hides it and stops services). */ export function deactivateTab(tab: TabData): void { tab.dom.contentEl.style.display = 'none'; tab.controllers.selectionController?.stop(); tab.controllers.browserSelectionController?.stop(); tab.controllers.canvasSelectionController?.stop(); } /** * Cleans up a tab and releases all resources. * Made async to ensure proper cleanup ordering. */ export async function destroyTab(tab: TabData): Promise { // Stop polling tab.controllers.selectionController?.stop(); tab.controllers.selectionController?.clear(); tab.controllers.browserSelectionController?.stop(); tab.controllers.browserSelectionController?.clear(); tab.controllers.canvasSelectionController?.stop(); tab.controllers.canvasSelectionController?.clear(); // Cleanup navigation controller tab.controllers.navigationController?.dispose(); // Cleanup thinking state cleanupThinkingBlock(tab.state.currentThinkingState); tab.state.currentThinkingState = null; // Cleanup UI components tab.controllers.inputController?.destroyResumeDropdown(); tab.ui.fileContextManager?.destroy(); tab.ui.slashCommandDropdown?.destroy(); tab.ui.slashCommandDropdown = null; tab.ui.instructionModeManager?.destroy(); tab.ui.instructionModeManager = null; tab.ui.bangBashModeManager?.destroy(); tab.ui.bangBashModeManager = null; tab.services.instructionRefineService?.cancel(); tab.services.instructionRefineService = null; tab.services.titleGenerationService?.cancel(); tab.services.titleGenerationService = null; tab.ui.statusPanel?.destroy(); tab.ui.statusPanel = null; tab.ui.navigationSidebar?.destroy(); tab.ui.navigationSidebar = null; // Cleanup subagents tab.services.subagentManager.orphanAllActive(); tab.services.subagentManager.clear(); // Remove event listeners to prevent memory leaks for (const cleanup of tab.dom.eventCleanups) { cleanup(); } tab.dom.eventCleanups.length = 0; // Close the tab's service // Note: closePersistentQuery is synchronous but we make destroyTab async // for future-proofing and proper cleanup ordering tab.service?.setAutoTurnCallback?.(null); tab.service?.closePersistentQuery('tab closed'); tab.service = null; // Remove DOM element tab.dom.contentEl.remove(); } /** * Gets the display title for a tab. * Uses synchronous access since we only need the title, not messages. */ export function getTabTitle(tab: TabData, plugin: ClaudianPlugin): string { if (tab.conversationId) { const conversation = plugin.getConversationSync(tab.conversationId); if (conversation?.title) { return conversation.title; } } return 'New Chat'; } /** Shared between Tab.ts and TabManager.ts to avoid duplication. */ export function setupServiceCallbacks(tab: TabData, plugin: ClaudianPlugin): void { if (tab.service && tab.controllers.inputController) { tab.service.setApprovalCallback( async (toolName, input, description, options) => await tab.controllers.inputController?.handleApprovalRequest(toolName, input, description, options) ?? 'cancel' ); tab.service.setApprovalDismisser( () => tab.controllers.inputController?.dismissPendingApproval() ); tab.service.setAskUserQuestionCallback( async (input, signal) => await tab.controllers.inputController?.handleAskUserQuestion(input, signal) ?? null ); tab.service.setExitPlanModeCallback( async (input, signal) => { const decision = await tab.controllers.inputController?.handleExitPlanMode(input, signal) ?? null; // Revert only on approve; feedback and cancel keep plan mode active. if (decision !== null && decision.type !== 'feedback') { // Only restore permission mode if still in plan mode — user may have toggled out via Shift+Tab if (plugin.settings.permissionMode === 'plan') { const restoreMode = tab.state.prePlanPermissionMode ?? 'normal'; tab.state.prePlanPermissionMode = null; updatePlanModeUI(tab, plugin, restoreMode); } if (decision.type === 'approve-new-session') { tab.state.pendingNewSessionPlan = decision.planContent; tab.state.cancelRequested = true; } } return decision; } ); tab.service.setSubagentHookProvider( () => ({ hasRunning: tab.services.subagentManager.hasRunningSubagents(), }) ); tab.service.setAutoTurnCallback((chunks: StreamChunk[]) => { renderAutoTriggeredTurn(tab, chunks); }); tab.service.setPermissionModeSyncCallback((sdkMode) => { let mode: PermissionMode; if (sdkMode === 'bypassPermissions') mode = 'yolo'; else if (sdkMode === 'plan') mode = 'plan'; else mode = 'normal'; if (plugin.settings.permissionMode !== mode) { // Save pre-plan mode when entering plan (for Shift+Tab toggle restore) if (mode === 'plan' && tab.state.prePlanPermissionMode === null) { tab.state.prePlanPermissionMode = plugin.settings.permissionMode; } updatePlanModeUI(tab, plugin, mode); } }); } } function generateMessageId(): string { return `msg-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; } /** * Renders an auto-triggered turn (e.g., agent response to task-notification) * that arrives after the main handler has completed. */ function renderAutoTriggeredTurn(tab: TabData, chunks: StreamChunk[]): void { if (!tab.dom.contentEl.isConnected) { return; } const hasToolActivity = chunks.some( chunk => chunk.type === 'tool_use' || chunk.type === 'tool_result' ); let textContent = ''; let sdkAssistantUuid: string | undefined; for (const chunk of chunks) { if (chunk.type === 'text') { textContent += chunk.content; } else if (chunk.type === 'sdk_assistant_uuid') { sdkAssistantUuid = chunk.uuid; } } if (!textContent.trim() && !hasToolActivity) return; const content = textContent.trim() || '(background task completed)'; const assistantMsg: ChatMessage = { id: sdkAssistantUuid ?? generateMessageId(), role: 'assistant', content, timestamp: Date.now(), contentBlocks: [{ type: 'text', content }], ...(sdkAssistantUuid && { sdkAssistantUuid }), }; tab.state.addMessage(assistantMsg); tab.renderer?.renderStoredMessage(assistantMsg); tab.renderer?.scrollToBottom(); } export function updatePlanModeUI(tab: TabData, plugin: ClaudianPlugin, mode: PermissionMode): void { plugin.settings.permissionMode = mode; void plugin.saveSettings(); tab.ui.permissionToggle?.updateDisplay(); tab.dom.inputWrapper.toggleClass('claudian-input-plan-mode', mode === 'plan'); } ================================================ FILE: src/features/chat/tabs/TabBar.ts ================================================ import type { TabBarItem, TabId } from './types'; /** Callbacks for TabBar interactions. */ export interface TabBarCallbacks { /** Called when a tab badge is clicked. */ onTabClick: (tabId: TabId) => void; /** Called when the close button is clicked on a tab. */ onTabClose: (tabId: TabId) => void; /** Called when the new tab button is clicked. */ onNewTab: () => void; } /** * TabBar renders minimal numbered badge navigation. */ export class TabBar { private containerEl: HTMLElement; private callbacks: TabBarCallbacks; constructor(containerEl: HTMLElement, callbacks: TabBarCallbacks) { this.containerEl = containerEl; this.callbacks = callbacks; this.build(); } /** Builds the tab bar UI. */ private build(): void { this.containerEl.addClass('claudian-tab-badges'); } /** * Updates the tab bar with new tab data. * @param items Tab items to render. */ update(items: TabBarItem[]): void { // Clear existing badges this.containerEl.empty(); // Render badges for (const item of items) { this.renderBadge(item); } } /** Renders a single tab badge. */ private renderBadge(item: TabBarItem): void { // Determine state class (priority: active > attention > streaming > idle) let stateClass = 'claudian-tab-badge-idle'; if (item.isActive) { stateClass = 'claudian-tab-badge-active'; } else if (item.needsAttention) { stateClass = 'claudian-tab-badge-attention'; } else if (item.isStreaming) { stateClass = 'claudian-tab-badge-streaming'; } const badgeEl = this.containerEl.createDiv({ cls: `claudian-tab-badge ${stateClass}`, text: String(item.index), }); // Tooltip with full title badgeEl.setAttribute('aria-label', item.title); badgeEl.setAttribute('title', item.title); // Click handler to switch tab badgeEl.addEventListener('click', () => { this.callbacks.onTabClick(item.id); }); // Right-click to close (if allowed) if (item.canClose) { badgeEl.addEventListener('contextmenu', (e) => { e.preventDefault(); this.callbacks.onTabClose(item.id); }); } } /** Destroys the tab bar. */ destroy(): void { this.containerEl.empty(); this.containerEl.removeClass('claudian-tab-badges'); } } ================================================ FILE: src/features/chat/tabs/TabManager.ts ================================================ import { Notice } from 'obsidian'; import type { ClaudianService } from '../../../core/agent'; import type { McpServerManager } from '../../../core/mcp'; import type { SlashCommand } from '../../../core/types'; import { t } from '../../../i18n'; import type ClaudianPlugin from '../../../main'; import { chooseForkTarget } from '../../../shared/modals/ForkTargetModal'; import { activateTab, createTab, deactivateTab, destroyTab, type ForkContext, getTabTitle, initializeTabControllers, initializeTabService, initializeTabUI, setupServiceCallbacks, wireTabInputEvents, } from './Tab'; import { DEFAULT_MAX_TABS, MAX_TABS, MIN_TABS, type PersistedTabManagerState, type PersistedTabState, type TabBarItem, type TabData, type TabId, type TabManagerCallbacks, type TabManagerInterface, type TabManagerViewHost, } from './types'; /** * TabManager coordinates multiple chat tabs. */ export class TabManager implements TabManagerInterface { private plugin: ClaudianPlugin; private mcpManager: McpServerManager; private containerEl: HTMLElement; private view: TabManagerViewHost; private tabs: Map = new Map(); private activeTabId: TabId | null = null; private callbacks: TabManagerCallbacks; /** Guard to prevent concurrent tab switches. */ private isSwitchingTab = false; /** * Gets the current max tabs limit from settings. * Clamps to MIN_TABS and MAX_TABS bounds. */ private getMaxTabs(): number { const settingsValue = this.plugin.settings.maxTabs ?? DEFAULT_MAX_TABS; return Math.max(MIN_TABS, Math.min(MAX_TABS, settingsValue)); } constructor( plugin: ClaudianPlugin, mcpManager: McpServerManager, containerEl: HTMLElement, view: TabManagerViewHost, callbacks: TabManagerCallbacks = {} ) { this.plugin = plugin; this.mcpManager = mcpManager; this.containerEl = containerEl; this.view = view; this.callbacks = callbacks; } // ============================================ // Tab Lifecycle // ============================================ /** * Creates a new tab. * @param conversationId Optional conversation to load into the tab. * @param tabId Optional tab ID (for restoration). * @returns The created tab, or null if max tabs reached. */ async createTab(conversationId?: string | null, tabId?: TabId): Promise { const maxTabs = this.getMaxTabs(); if (this.tabs.size >= maxTabs) { return null; } const conversation = conversationId ? await this.plugin.getConversationById(conversationId) : undefined; const tab = createTab({ plugin: this.plugin, mcpManager: this.mcpManager, containerEl: this.containerEl, conversation: conversation ?? undefined, tabId, onStreamingChanged: (isStreaming) => { this.callbacks.onTabStreamingChanged?.(tab.id, isStreaming); }, onTitleChanged: (title) => { this.callbacks.onTabTitleChanged?.(tab.id, title); }, onAttentionChanged: (needsAttention) => { this.callbacks.onTabAttentionChanged?.(tab.id, needsAttention); }, onConversationIdChanged: (conversationId) => { // Sync tab.conversationId when conversation is lazily created tab.conversationId = conversationId; this.callbacks.onTabConversationChanged?.(tab.id, conversationId); }, }); // Initialize UI components with shared SDK commands callback initializeTabUI(tab, this.plugin, { getSdkCommands: () => this.getSdkCommands(), }); // Initialize controllers (pass mcpManager for lazy service initialization) initializeTabControllers( tab, this.plugin, this.view, this.mcpManager, (forkContext) => this.handleForkRequest(forkContext), (conversationId) => this.openConversation(conversationId), ); // Wire input event handlers wireTabInputEvents(tab, this.plugin); this.tabs.set(tab.id, tab); this.callbacks.onTabCreated?.(tab); // Auto-switch to the newly created tab await this.switchToTab(tab.id); return tab; } /** * Switches to a different tab. * @param tabId The tab to switch to. */ async switchToTab(tabId: TabId): Promise { const tab = this.tabs.get(tabId); if (!tab) { return; } // Guard against concurrent tab switches if (this.isSwitchingTab) { return; } this.isSwitchingTab = true; const previousTabId = this.activeTabId; try { // Deactivate current tab if (previousTabId && previousTabId !== tabId) { const currentTab = this.tabs.get(previousTabId); if (currentTab) { deactivateTab(currentTab); } } // Activate new tab this.activeTabId = tabId; activateTab(tab); // Service initialization is now truly lazy - happens on first query via // ensureServiceInitialized() in InputController.sendMessage() // Load conversation if not already loaded if (tab.conversationId && tab.state.messages.length === 0) { await tab.controllers.conversationController?.switchTo(tab.conversationId); } else if (tab.conversationId && tab.state.messages.length > 0 && tab.service) { // Tab already has messages loaded - sync service session to conversation // This handles the case where user switches between tabs with different sessions const conversation = await this.plugin.getConversationById(tab.conversationId); if (conversation) { const hasMessages = conversation.messages.length > 0; const externalContextPaths = hasMessages ? conversation.externalContextPaths || [] : (this.plugin.settings.persistentExternalContextPaths || []); const resolvedSessionId = tab.service.applyForkState(conversation); tab.service.setSessionId(resolvedSessionId, externalContextPaths); } } else if (!tab.conversationId && tab.state.messages.length === 0) { // New tab with no conversation - initialize welcome greeting tab.controllers.conversationController?.initializeWelcome(); } this.callbacks.onTabSwitched?.(previousTabId, tabId); } finally { this.isSwitchingTab = false; } } /** * Closes a tab. * @param tabId The tab to close. * @param force If true, close even if streaming. * @returns True if the tab was closed. */ async closeTab(tabId: TabId, force = false): Promise { const tab = this.tabs.get(tabId); if (!tab) { return false; } // Don't close if streaming unless forced if (tab.state.isStreaming && !force) { return false; } // If this is the last tab and it's already empty (no conversation), // don't close it - it's already a fresh session with a warm service. // Closing and recreating would waste the pre-warmed connection. if (this.tabs.size === 1 && !tab.conversationId && tab.state.messages.length === 0) { return false; } // Save conversation before closing await tab.controllers.conversationController?.save(); // Capture tab order BEFORE deletion for fallback calculation const tabIdsBefore = Array.from(this.tabs.keys()); const closingIndex = tabIdsBefore.indexOf(tabId); // Destroy tab resources (async for proper cleanup) await destroyTab(tab); this.tabs.delete(tabId); this.callbacks.onTabClosed?.(tabId); // If we closed the active tab, switch to another if (this.activeTabId === tabId) { this.activeTabId = null; if (this.tabs.size > 0) { // Fallback strategy: prefer previous tab, except for first tab (go to next) const fallbackTabId = closingIndex === 0 ? tabIdsBefore[1] // First tab: go to next : tabIdsBefore[closingIndex - 1]; // Others: go to previous if (fallbackTabId && this.tabs.has(fallbackTabId)) { await this.switchToTab(fallbackTabId); // If this is now the only tab and it's not warm, pre-warm immediately // User expects the active tab to be ready for chat if (this.tabs.size === 1) { await this.initializeActiveTabService(); } } } else { // Create a new empty tab and pre-warm immediately // This is the only tab, so it should be ready for chat await this.createTab(); await this.initializeActiveTabService(); } } return true; } // ============================================ // Tab Queries // ============================================ /** Gets the currently active tab. */ getActiveTab(): TabData | null { return this.activeTabId ? this.tabs.get(this.activeTabId) ?? null : null; } /** Gets the active tab ID. */ getActiveTabId(): TabId | null { return this.activeTabId; } /** Gets a tab by ID. */ getTab(tabId: TabId): TabData | null { return this.tabs.get(tabId) ?? null; } /** Gets all tabs. */ getAllTabs(): TabData[] { return Array.from(this.tabs.values()); } /** Gets the number of tabs. */ getTabCount(): number { return this.tabs.size; } /** Checks if more tabs can be created. */ canCreateTab(): boolean { return this.tabs.size < this.getMaxTabs(); } // ============================================ // Tab Bar Data // ============================================ /** Gets data for rendering the tab bar. */ getTabBarItems(): TabBarItem[] { const items: TabBarItem[] = []; let index = 1; for (const tab of this.tabs.values()) { items.push({ id: tab.id, index: index++, title: getTabTitle(tab, this.plugin), isActive: tab.id === this.activeTabId, isStreaming: tab.state.isStreaming, needsAttention: tab.state.needsAttention, canClose: this.tabs.size > 1 || !tab.state.isStreaming, }); } return items; } // ============================================ // Conversation Management // ============================================ /** * Opens a conversation in a new tab or existing tab. * @param conversationId The conversation to open. * @param preferNewTab If true, prefer opening in a new tab. */ async openConversation(conversationId: string, preferNewTab = false): Promise { // Check if conversation is already open in this view's tabs for (const tab of this.tabs.values()) { if (tab.conversationId === conversationId) { await this.switchToTab(tab.id); return; } } // Check if conversation is open in another view (split workspace scenario) // Compare view references directly (more robust than leaf comparison) const crossViewResult = this.plugin.findConversationAcrossViews(conversationId); const isSameView = crossViewResult?.view === this.view; if (crossViewResult && !isSameView) { // Focus the other view and switch to its tab instead of opening duplicate this.plugin.app.workspace.revealLeaf(crossViewResult.view.leaf); await crossViewResult.view.getTabManager()?.switchToTab(crossViewResult.tabId); return; } // Open in current tab or new tab if (preferNewTab && this.canCreateTab()) { await this.createTab(conversationId); } else { // Open in current tab // Note: Don't set tab.conversationId here - the onConversationIdChanged callback // will sync it after successful switch. Setting it before switchTo() would cause // incorrect tab metadata if switchTo() returns early (streaming/switching/creating). const activeTab = this.getActiveTab(); if (activeTab) { await activeTab.controllers.conversationController?.switchTo(conversationId); } } } /** * Creates a new conversation in the active tab. */ async createNewConversation(): Promise { const activeTab = this.getActiveTab(); if (activeTab) { await activeTab.controllers.conversationController?.createNew(); // Sync tab.conversationId with the newly created conversation activeTab.conversationId = activeTab.state.currentConversationId; } } // ============================================ // Fork // ============================================ private async handleForkRequest(context: ForkContext): Promise { const target = await chooseForkTarget(this.plugin.app); if (!target) return; if (target === 'new-tab') { const tab = await this.forkToNewTab(context); if (!tab) { const maxTabs = this.getMaxTabs(); new Notice(t('chat.fork.maxTabsReached', { count: String(maxTabs) })); return; } new Notice(t('chat.fork.notice')); } else { const success = await this.forkInCurrentTab(context); if (!success) { new Notice(t('chat.fork.failed', { error: t('chat.fork.errorNoActiveTab') })); return; } new Notice(t('chat.fork.noticeCurrentTab')); } } async forkToNewTab(context: ForkContext): Promise { const maxTabs = this.getMaxTabs(); if (this.tabs.size >= maxTabs) { return null; } const conversationId = await this.createForkConversation(context); try { return await this.createTab(conversationId); } catch (error) { await this.plugin.deleteConversation(conversationId).catch(() => {}); throw error; } } async forkInCurrentTab(context: ForkContext): Promise { const activeTab = this.getActiveTab(); if (!activeTab?.controllers.conversationController) return false; const conversationId = await this.createForkConversation(context); try { await activeTab.controllers.conversationController.switchTo(conversationId); } catch (error) { await this.plugin.deleteConversation(conversationId).catch(() => {}); throw error; } return true; } private async createForkConversation(context: ForkContext): Promise { const conversation = await this.plugin.createConversation(); const title = context.sourceTitle ? this.buildForkTitle(context.sourceTitle, context.forkAtUserMessage) : undefined; await this.plugin.updateConversation(conversation.id, { messages: context.messages, forkSource: { sessionId: context.sourceSessionId, resumeAt: context.resumeAt }, // Prevent immediate SDK message load from merging duplicates with the copied messages. // This is in-memory only (not persisted in metadata). sdkMessagesLoaded: true, ...(title && { title }), ...(context.currentNote && { currentNote: context.currentNote }), }); return conversation.id; } private buildForkTitle(sourceTitle: string, forkAtUserMessage?: number): string { const MAX_TITLE_LENGTH = 50; const forkSuffix = forkAtUserMessage ? ` (#${forkAtUserMessage})` : ''; const forkPrefix = 'Fork: '; const maxSourceLength = MAX_TITLE_LENGTH - forkPrefix.length - forkSuffix.length; const truncatedSource = sourceTitle.length > maxSourceLength ? sourceTitle.slice(0, maxSourceLength - 1) + '…' : sourceTitle; let title = forkPrefix + truncatedSource + forkSuffix; const existingTitles = new Set(this.plugin.getConversationList().map(c => c.title)); if (existingTitles.has(title)) { let n = 2; while (existingTitles.has(`${title} ${n}`)) n++; title = `${title} ${n}`; } return title; } // ============================================ // Persistence // ============================================ /** Gets the state to persist. */ getPersistedState(): PersistedTabManagerState { const openTabs: PersistedTabState[] = []; for (const tab of this.tabs.values()) { openTabs.push({ tabId: tab.id, conversationId: tab.conversationId, }); } return { openTabs, activeTabId: this.activeTabId, }; } /** Restores state from persisted data. */ async restoreState(state: PersistedTabManagerState): Promise { // Create tabs from persisted state with error handling for (const tabState of state.openTabs) { try { await this.createTab(tabState.conversationId, tabState.tabId); } catch { // Continue restoring other tabs } } // Switch to the previously active tab if (state.activeTabId && this.tabs.has(state.activeTabId)) { try { await this.switchToTab(state.activeTabId); } catch { // Ignore switch errors } } // If no tabs were restored, create a default one if (this.tabs.size === 0) { await this.createTab(); } // Pre-initialize the active tab's service so it's ready immediately // Other tabs stay lazy until first query await this.initializeActiveTabService(); } /** * Initializes the active tab's service if not already done. * Called after restore to ensure the visible tab is ready immediately. */ private async initializeActiveTabService(): Promise { const activeTab = this.getActiveTab(); if (!activeTab || activeTab.serviceInitialized) { return; } try { // initializeTabService() handles session ID resolution from tab.conversationId await initializeTabService(activeTab, this.plugin, this.mcpManager); setupServiceCallbacks(activeTab, this.plugin); } catch { // Non-fatal - service will be initialized on first query } } // ============================================ // SDK Commands (Shared) // ============================================ /** * Gets SDK supported commands from any ready service. * The command list is the same for all tabs, so we just need one ready service. * @returns Array of SDK commands, or empty array if no service is ready. */ async getSdkCommands(): Promise { // Find any tab with a ready service for (const tab of this.tabs.values()) { if (tab.service?.isReady()) { return tab.service.getSupportedCommands(); } } return []; } // ============================================ // Broadcast // ============================================ /** * Broadcasts a function call to all tabs' ClaudianService instances. * Used by settings managers to apply configuration changes to all tabs. * @param fn Function to call on each service. */ async broadcastToAllTabs(fn: (service: ClaudianService) => Promise): Promise { const promises: Promise[] = []; for (const tab of this.tabs.values()) { if (tab.service && tab.serviceInitialized) { promises.push( fn(tab.service).catch(() => { // Silently ignore broadcast errors }) ); } } await Promise.all(promises); } // ============================================ // Cleanup // ============================================ /** Destroys all tabs and cleans up resources. */ async destroy(): Promise { // Save all conversations for (const tab of this.tabs.values()) { await tab.controllers.conversationController?.save(); } // Destroy all tabs (async for proper cleanup) for (const tab of this.tabs.values()) { await destroyTab(tab); } this.tabs.clear(); this.activeTabId = null; } } ================================================ FILE: src/features/chat/tabs/index.ts ================================================ export * from './Tab'; export * from './TabBar'; export * from './TabManager'; export * from './types'; ================================================ FILE: src/features/chat/tabs/types.ts ================================================ import type { Component, WorkspaceLeaf } from 'obsidian'; import type { ClaudianService } from '../../../core/agent'; import type { SlashCommandDropdown } from '../../../shared/components/SlashCommandDropdown'; import type { BrowserSelectionController, CanvasSelectionController, ConversationController, InputController, NavigationController, SelectionController, StreamController, } from '../controllers'; import type { MessageRenderer } from '../rendering'; import type { InstructionRefineService } from '../services/InstructionRefineService'; import type { SubagentManager } from '../services/SubagentManager'; import type { TitleGenerationService } from '../services/TitleGenerationService'; import type { ChatState } from '../state'; import type { BangBashModeManager, ContextUsageMeter, ExternalContextSelector, FileContextManager, ImageContextManager, InstructionModeManager, McpServerSelector, ModelSelector, PermissionToggle, StatusPanel, ThinkingBudgetSelector, } from '../ui'; import type { NavigationSidebar } from '../ui'; /** * Default number of tabs allowed. * * Set to 3 to balance usability with resource usage: * - Each tab has its own ClaudianService and persistent query * - More tabs = more memory and potential SDK processes * - 3 tabs allows multi-tasking without excessive overhead */ export const DEFAULT_MAX_TABS = 3; /** * Minimum number of tabs allowed (settings floor). */ export const MIN_TABS = 3; /** * Maximum number of tabs allowed (settings ceiling). * Users can configure up to this many tabs via settings. */ export const MAX_TABS = 10; /** * Minimum max-height for textarea in pixels. * Used by autoResizeTextarea to ensure minimum usable space. */ export const TEXTAREA_MIN_MAX_HEIGHT = 150; /** * Percentage of view height for max textarea height. * Textarea can grow up to this portion of the view. */ export const TEXTAREA_MAX_HEIGHT_PERCENT = 0.55; /** * Minimal interface for the ClaudianView methods used by TabManager and Tab. * Extends Component for Obsidian integration (event handling, cleanup). * Avoids circular dependency by not importing ClaudianView directly. */ export interface TabManagerViewHost extends Component { /** Reference to the workspace leaf for revealing the view. */ leaf: WorkspaceLeaf; /** Gets the tab manager instance (used for cross-view coordination). */ getTabManager(): TabManagerInterface | null; } /** * Minimal interface for TabManager methods used by external code. * Used to break circular dependencies. */ export interface TabManagerInterface { /** Switches to a specific tab. */ switchToTab(tabId: TabId): Promise; /** Gets all tabs. */ getAllTabs(): TabData[]; } /** Tab identifier type. */ export type TabId = string; /** Generates a unique tab ID. */ export function generateTabId(): TabId { return `tab-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; } /** * Controllers managed per-tab. * Each tab has its own set of controllers for independent operation. */ export interface TabControllers { selectionController: SelectionController | null; browserSelectionController: BrowserSelectionController | null; canvasSelectionController: CanvasSelectionController | null; conversationController: ConversationController | null; streamController: StreamController | null; inputController: InputController | null; navigationController: NavigationController | null; } /** * Services managed per-tab. */ export interface TabServices { subagentManager: SubagentManager; instructionRefineService: InstructionRefineService | null; titleGenerationService: TitleGenerationService | null; } /** * UI components managed per-tab. */ export interface TabUIComponents { fileContextManager: FileContextManager | null; imageContextManager: ImageContextManager | null; modelSelector: ModelSelector | null; thinkingBudgetSelector: ThinkingBudgetSelector | null; externalContextSelector: ExternalContextSelector | null; mcpServerSelector: McpServerSelector | null; permissionToggle: PermissionToggle | null; slashCommandDropdown: SlashCommandDropdown | null; instructionModeManager: InstructionModeManager | null; bangBashModeManager: BangBashModeManager | null; contextUsageMeter: ContextUsageMeter | null; statusPanel: StatusPanel | null; navigationSidebar: NavigationSidebar | null; } /** * DOM elements managed per-tab. */ export interface TabDOMElements { contentEl: HTMLElement; messagesEl: HTMLElement; welcomeEl: HTMLElement | null; /** Container for status panel (fixed between messages and input). */ statusPanelContainerEl: HTMLElement; inputContainerEl: HTMLElement; inputWrapper: HTMLElement; inputEl: HTMLTextAreaElement; /** Nav row for tab badges and header icons (above input wrapper). */ navRowEl: HTMLElement; /** Context row for file chips and selection indicator (inside input wrapper). */ contextRowEl: HTMLElement; selectionIndicatorEl: HTMLElement | null; browserIndicatorEl: HTMLElement | null; canvasIndicatorEl: HTMLElement | null; /** Cleanup functions for event listeners (prevents memory leaks). */ eventCleanups: Array<() => void>; } /** * Represents a single tab in the multi-tab system. * Each tab is an independent chat session with its own agent service. */ export interface TabData { /** Unique tab identifier. */ id: TabId; /** Conversation ID bound to this tab (null for new/empty tabs). */ conversationId: string | null; /** Per-tab ClaudianService instance for independent streaming. */ service: ClaudianService | null; /** Whether the service has been initialized (lazy start). */ serviceInitialized: boolean; /** Per-tab chat state. */ state: ChatState; /** Per-tab controllers. */ controllers: TabControllers; /** Per-tab services. */ services: TabServices; /** Per-tab UI components. */ ui: TabUIComponents; /** Per-tab DOM elements. */ dom: TabDOMElements; /** Per-tab renderer. */ renderer: MessageRenderer | null; } /** * Persisted tab state for restoration on plugin reload. */ export interface PersistedTabState { tabId: TabId; conversationId: string | null; } /** * Tab manager state persisted to data.json. */ export interface PersistedTabManagerState { openTabs: PersistedTabState[]; activeTabId: TabId | null; } /** * Callbacks for tab state changes. */ export interface TabManagerCallbacks { /** Called when a tab is created. */ onTabCreated?: (tab: TabData) => void; /** Called when switching to a different tab. */ onTabSwitched?: (fromTabId: TabId | null, toTabId: TabId) => void; /** Called when a tab is closed. */ onTabClosed?: (tabId: TabId) => void; /** Called when tab streaming state changes. */ onTabStreamingChanged?: (tabId: TabId, isStreaming: boolean) => void; /** Called when tab title changes. */ onTabTitleChanged?: (tabId: TabId, title: string) => void; /** Called when tab attention state changes (approval pending, etc.). */ onTabAttentionChanged?: (tabId: TabId, needsAttention: boolean) => void; /** Called when a tab's conversation changes (loaded different conversation in same tab). */ onTabConversationChanged?: (tabId: TabId, conversationId: string | null) => void; } /** * Tab bar item representation for rendering. */ export interface TabBarItem { id: TabId; /** 1-based index for display. */ index: number; title: string; isActive: boolean; isStreaming: boolean; needsAttention: boolean; canClose: boolean; } ================================================ FILE: src/features/chat/ui/BangBashModeManager.ts ================================================ import { Notice } from 'obsidian'; import { t } from '../../../i18n'; export interface BangBashModeCallbacks { onSubmit: (command: string) => Promise; getInputWrapper: () => HTMLElement | null; resetInputHeight?: () => void; } export interface BangBashModeState { active: boolean; rawCommand: string; } export class BangBashModeManager { private inputEl: HTMLTextAreaElement; private callbacks: BangBashModeCallbacks; private state: BangBashModeState = { active: false, rawCommand: '' }; private isSubmitting = false; private originalPlaceholder: string = ''; constructor( inputEl: HTMLTextAreaElement, callbacks: BangBashModeCallbacks ) { this.inputEl = inputEl; this.callbacks = callbacks; this.originalPlaceholder = inputEl.placeholder; } handleTriggerKey(e: KeyboardEvent): boolean { if (!this.state.active && this.inputEl.value === '' && e.key === '!') { if (this.enterMode()) { e.preventDefault(); return true; } } return false; } handleInputChange(): void { if (!this.state.active) return; this.state.rawCommand = this.inputEl.value; } private enterMode(): boolean { const wrapper = this.callbacks.getInputWrapper(); if (!wrapper) return false; wrapper.addClass('claudian-input-bang-bash-mode'); this.state = { active: true, rawCommand: '' }; this.inputEl.placeholder = t('chat.bangBash.placeholder'); return true; } private exitMode(): void { const wrapper = this.callbacks.getInputWrapper(); if (wrapper) { wrapper.removeClass('claudian-input-bang-bash-mode'); } this.state = { active: false, rawCommand: '' }; this.inputEl.placeholder = this.originalPlaceholder; } handleKeydown(e: KeyboardEvent): boolean { if (!this.state.active) return false; if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); if (this.state.rawCommand.trim()) { this.submit(); } return true; } if (e.key === 'Escape' && !e.isComposing) { e.preventDefault(); this.clear(); return true; } return false; } isActive(): boolean { return this.state.active; } getRawCommand(): string { return this.state.rawCommand; } private async submit(): Promise { if (this.isSubmitting) return; const rawCommand = this.state.rawCommand.trim(); if (!rawCommand) return; this.isSubmitting = true; try { this.clear(); await this.callbacks.onSubmit(rawCommand); } catch (e) { new Notice(`Command failed: ${e instanceof Error ? e.message : String(e)}`); } finally { this.isSubmitting = false; } } clear(): void { this.inputEl.value = ''; this.exitMode(); this.callbacks.resetInputHeight?.(); } destroy(): void { this.exitMode(); } } ================================================ FILE: src/features/chat/ui/FileContext.ts ================================================ import type { App, EventRef } from 'obsidian'; import { Notice, TFile } from 'obsidian'; import type { AgentManager } from '../../../core/agents'; import type { McpServerManager } from '../../../core/mcp'; import { MentionDropdownController } from '../../../shared/mention/MentionDropdownController'; import { VaultMentionDataProvider } from '../../../shared/mention/VaultMentionDataProvider'; import { createExternalContextLookupGetter, isMentionStart, resolveExternalMentionAtIndex, } from '../../../utils/contextMentionResolver'; import { buildExternalContextDisplayEntries } from '../../../utils/externalContext'; import { externalContextScanner } from '../../../utils/externalContextScanner'; import { getVaultPath, normalizePathForVault as normalizePathForVaultUtil } from '../../../utils/path'; import { FileContextState } from './file-context/state/FileContextState'; import { FileChipsView } from './file-context/view/FileChipsView'; export interface FileContextCallbacks { getExcludedTags: () => string[]; onChipsChanged?: () => void; getExternalContexts?: () => string[]; /** Called when an agent is selected from the @ mention dropdown. */ onAgentMentionSelect?: (agentId: string) => void; } export class FileContextManager { private app: App; private callbacks: FileContextCallbacks; private chipsContainerEl: HTMLElement; private dropdownContainerEl: HTMLElement; private inputEl: HTMLTextAreaElement; private state: FileContextState; private mentionDataProvider: VaultMentionDataProvider; private chipsView: FileChipsView; private mentionDropdown: MentionDropdownController; private deleteEventRef: EventRef | null = null; private renameEventRef: EventRef | null = null; // Current note (shown as chip) private currentNotePath: string | null = null; // MCP server support private onMcpMentionChange: ((servers: Set) => void) | null = null; constructor( app: App, chipsContainerEl: HTMLElement, inputEl: HTMLTextAreaElement, callbacks: FileContextCallbacks, dropdownContainerEl?: HTMLElement ) { this.app = app; this.chipsContainerEl = chipsContainerEl; this.dropdownContainerEl = dropdownContainerEl ?? chipsContainerEl; this.inputEl = inputEl; this.callbacks = callbacks; this.state = new FileContextState(); this.mentionDataProvider = new VaultMentionDataProvider(this.app); this.mentionDataProvider.initializeInBackground(); this.chipsView = new FileChipsView(this.chipsContainerEl, { onRemoveAttachment: (filePath) => { if (filePath === this.currentNotePath) { this.currentNotePath = null; this.state.detachFile(filePath); this.refreshCurrentNoteChip(); } }, onOpenFile: async (filePath) => { const file = this.app.vault.getAbstractFileByPath(filePath); if (!(file instanceof TFile)) { new Notice(`Could not open file: ${filePath}`); return; } try { await this.app.workspace.getLeaf().openFile(file); } catch (error) { new Notice(`Failed to open file: ${error instanceof Error ? error.message : String(error)}`); } }, }); this.mentionDropdown = new MentionDropdownController( this.dropdownContainerEl, this.inputEl, { onAttachFile: (filePath) => this.state.attachFile(filePath), onMcpMentionChange: (servers) => this.onMcpMentionChange?.(servers), onAgentMentionSelect: (agentId) => this.callbacks.onAgentMentionSelect?.(agentId), getMentionedMcpServers: () => this.state.getMentionedMcpServers(), setMentionedMcpServers: (mentions) => this.state.setMentionedMcpServers(mentions), addMentionedMcpServer: (name) => this.state.addMentionedMcpServer(name), getExternalContexts: () => this.callbacks.getExternalContexts?.() || [], getCachedVaultFolders: () => this.mentionDataProvider.getCachedVaultFolders(), getCachedVaultFiles: () => this.mentionDataProvider.getCachedVaultFiles(), normalizePathForVault: (rawPath) => this.normalizePathForVault(rawPath), } ); this.deleteEventRef = this.app.vault.on('delete', (file) => { if (file instanceof TFile) this.handleFileDeleted(file.path); }); this.renameEventRef = this.app.vault.on('rename', (file, oldPath) => { if (file instanceof TFile) this.handleFileRenamed(oldPath, file.path); }); } /** Returns the current note path (shown as chip). */ getCurrentNotePath(): string | null { return this.currentNotePath; } getAttachedFiles(): Set { return this.state.getAttachedFiles(); } /** Checks whether current note should be sent for this session. */ shouldSendCurrentNote(notePath?: string | null): boolean { const resolvedPath = notePath ?? this.currentNotePath; return !!resolvedPath && !this.state.hasSentCurrentNote(); } /** Marks current note as sent (call after sending a message). */ markCurrentNoteSent() { this.state.markCurrentNoteSent(); } isSessionStarted(): boolean { return this.state.isSessionStarted(); } startSession() { this.state.startSession(); } /** Resets state for a new conversation. */ resetForNewConversation() { this.currentNotePath = null; this.state.resetForNewConversation(); this.refreshCurrentNoteChip(); } /** Resets state for loading an existing conversation. */ resetForLoadedConversation(hasMessages: boolean) { this.currentNotePath = null; this.state.resetForLoadedConversation(hasMessages); this.refreshCurrentNoteChip(); } /** Sets current note (for restoring persisted state). */ setCurrentNote(notePath: string | null) { this.currentNotePath = notePath; if (notePath) { this.state.attachFile(notePath); } this.refreshCurrentNoteChip(); } /** Auto-attaches the currently focused file (for new sessions). */ autoAttachActiveFile() { const activeFile = this.app.workspace.getActiveFile(); if (activeFile && !this.hasExcludedTag(activeFile)) { const normalizedPath = this.normalizePathForVault(activeFile.path); if (normalizedPath) { this.currentNotePath = normalizedPath; this.state.attachFile(normalizedPath); this.refreshCurrentNoteChip(); } } } /** Handles file open event. */ handleFileOpen(file: TFile) { const normalizedPath = this.normalizePathForVault(file.path); if (!normalizedPath) return; if (!this.state.isSessionStarted()) { this.state.clearAttachments(); if (!this.hasExcludedTag(file)) { this.currentNotePath = normalizedPath; this.state.attachFile(normalizedPath); } else { this.currentNotePath = null; } this.refreshCurrentNoteChip(); } } markFileCacheDirty() { this.mentionDataProvider.markFilesDirty(); } markFolderCacheDirty() { this.mentionDataProvider.markFoldersDirty(); } /** Handles input changes to detect @ mentions. */ handleInputChange() { this.mentionDropdown.handleInputChange(); } /** Handles keyboard navigation in mention dropdown. Returns true if handled. */ handleMentionKeydown(e: KeyboardEvent): boolean { return this.mentionDropdown.handleKeydown(e); } isMentionDropdownVisible(): boolean { return this.mentionDropdown.isVisible(); } hideMentionDropdown() { this.mentionDropdown.hide(); } containsElement(el: Node): boolean { return this.mentionDropdown.containsElement(el); } transformContextMentions(text: string): string { const externalContexts = this.callbacks.getExternalContexts?.() || []; if (externalContexts.length === 0 || !text.includes('@')) return text; const contextEntries = buildExternalContextDisplayEntries(externalContexts) .sort((a, b) => b.displayNameLower.length - a.displayNameLower.length); const getContextLookup = createExternalContextLookupGetter( contextRoot => externalContextScanner.scanPaths([contextRoot]) ); let replaced = false; let cursor = 0; const chunks: string[] = []; for (let index = 0; index < text.length; index++) { if (!isMentionStart(text, index)) continue; const resolved = resolveExternalMentionAtIndex(text, index, contextEntries, getContextLookup); if (!resolved) continue; chunks.push(text.slice(cursor, index)); chunks.push(`${resolved.resolvedPath}${resolved.trailingPunctuation}`); cursor = resolved.endIndex; index = resolved.endIndex - 1; replaced = true; } if (!replaced) return text; chunks.push(text.slice(cursor)); return chunks.join(''); } /** Cleans up event listeners (call on view close). */ destroy() { if (this.deleteEventRef) this.app.vault.offref(this.deleteEventRef); if (this.renameEventRef) this.app.vault.offref(this.renameEventRef); this.mentionDropdown.destroy(); this.chipsView.destroy(); } /** Normalizes a file path to be vault-relative with forward slashes. */ normalizePathForVault(rawPath: string | undefined | null): string | null { const vaultPath = getVaultPath(this.app); return normalizePathForVaultUtil(rawPath, vaultPath); } private refreshCurrentNoteChip(): void { this.chipsView.renderCurrentNote(this.currentNotePath); this.callbacks.onChipsChanged?.(); } private handleFileRenamed(oldPath: string, newPath: string) { const normalizedOld = this.normalizePathForVault(oldPath); const normalizedNew = this.normalizePathForVault(newPath); if (!normalizedOld) return; let needsUpdate = false; // Update current note path if renamed if (this.currentNotePath === normalizedOld) { this.currentNotePath = normalizedNew; needsUpdate = true; } // Update attached files if (this.state.getAttachedFiles().has(normalizedOld)) { this.state.detachFile(normalizedOld); if (normalizedNew) { this.state.attachFile(normalizedNew); } needsUpdate = true; } if (needsUpdate) { this.refreshCurrentNoteChip(); } } private handleFileDeleted(deletedPath: string): void { const normalized = this.normalizePathForVault(deletedPath); if (!normalized) return; let needsUpdate = false; // Clear current note if deleted if (this.currentNotePath === normalized) { this.currentNotePath = null; needsUpdate = true; } // Remove from attached files if (this.state.getAttachedFiles().has(normalized)) { this.state.detachFile(normalized); needsUpdate = true; } if (needsUpdate) { this.refreshCurrentNoteChip(); } } // ======================================== // MCP Server Support // ======================================== setMcpManager(manager: McpServerManager | null): void { this.mentionDropdown.setMcpManager(manager); } setAgentService(agentManager: AgentManager | null): void { // AgentManager structurally satisfies AgentMentionProvider this.mentionDropdown.setAgentService(agentManager); } setOnMcpMentionChange(callback: (servers: Set) => void): void { this.onMcpMentionChange = callback; } /** * Pre-scans external context paths in the background to warm the cache. * Should be called when external context paths are added/changed. */ preScanExternalContexts(): void { this.mentionDropdown.preScanExternalContexts(); } getMentionedMcpServers(): Set { return this.state.getMentionedMcpServers(); } clearMcpMentions(): void { this.state.clearMcpMentions(); } updateMcpMentionsFromText(text: string): void { this.mentionDropdown.updateMcpMentionsFromText(text); } private hasExcludedTag(file: TFile): boolean { const excludedTags = this.callbacks.getExcludedTags(); if (excludedTags.length === 0) return false; const cache = this.app.metadataCache.getFileCache(file); if (!cache) return false; const fileTags: string[] = []; if (cache.frontmatter?.tags) { const fmTags = cache.frontmatter.tags; if (Array.isArray(fmTags)) { fileTags.push(...fmTags.map((t: string) => t.replace(/^#/, ''))); } else if (typeof fmTags === 'string') { fileTags.push(fmTags.replace(/^#/, '')); } } if (cache.tags) { fileTags.push(...cache.tags.map(t => t.tag.replace(/^#/, ''))); } return fileTags.some(tag => excludedTags.includes(tag)); } } ================================================ FILE: src/features/chat/ui/ImageContext.ts ================================================ import { Notice } from 'obsidian'; import * as path from 'path'; import type { ImageAttachment, ImageMediaType } from '../../../core/types'; const MAX_IMAGE_SIZE = 5 * 1024 * 1024; const IMAGE_EXTENSIONS: Record = { '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.gif': 'image/gif', '.webp': 'image/webp', }; export interface ImageContextCallbacks { onImagesChanged: () => void; } export class ImageContextManager { private callbacks: ImageContextCallbacks; private containerEl: HTMLElement; private previewContainerEl: HTMLElement; private imagePreviewEl: HTMLElement; private inputEl: HTMLTextAreaElement; private dropOverlay: HTMLElement | null = null; private attachedImages: Map = new Map(); constructor( containerEl: HTMLElement, inputEl: HTMLTextAreaElement, callbacks: ImageContextCallbacks, previewContainerEl?: HTMLElement ) { this.containerEl = containerEl; this.previewContainerEl = previewContainerEl ?? containerEl; this.inputEl = inputEl; this.callbacks = callbacks; // Create image preview in previewContainerEl, before file indicator if present const fileIndicator = this.previewContainerEl.querySelector('.claudian-file-indicator'); this.imagePreviewEl = this.previewContainerEl.createDiv({ cls: 'claudian-image-preview' }); if (fileIndicator && fileIndicator.parentElement === this.previewContainerEl) { this.previewContainerEl.insertBefore(this.imagePreviewEl, fileIndicator); } this.setupDragAndDrop(); this.setupPasteHandler(); } getAttachedImages(): ImageAttachment[] { return Array.from(this.attachedImages.values()); } hasImages(): boolean { return this.attachedImages.size > 0; } clearImages() { this.attachedImages.clear(); this.updateImagePreview(); this.callbacks.onImagesChanged(); } /** Sets images directly (used for queued messages). */ setImages(images: ImageAttachment[]) { this.attachedImages.clear(); for (const image of images) { this.attachedImages.set(image.id, image); } this.updateImagePreview(); this.callbacks.onImagesChanged(); } private setupDragAndDrop() { const inputWrapper = this.containerEl.querySelector('.claudian-input-wrapper') as HTMLElement; if (!inputWrapper) return; this.dropOverlay = inputWrapper.createDiv({ cls: 'claudian-drop-overlay' }); const dropContent = this.dropOverlay.createDiv({ cls: 'claudian-drop-content' }); const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.setAttribute('viewBox', '0 0 24 24'); svg.setAttribute('width', '32'); svg.setAttribute('height', '32'); svg.setAttribute('fill', 'none'); svg.setAttribute('stroke', 'currentColor'); svg.setAttribute('stroke-width', '2'); const pathEl = document.createElementNS('http://www.w3.org/2000/svg', 'path'); pathEl.setAttribute('d', 'M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4'); const polyline = document.createElementNS('http://www.w3.org/2000/svg', 'polyline'); polyline.setAttribute('points', '17 8 12 3 7 8'); const line = document.createElementNS('http://www.w3.org/2000/svg', 'line'); line.setAttribute('x1', '12'); line.setAttribute('y1', '3'); line.setAttribute('x2', '12'); line.setAttribute('y2', '15'); svg.appendChild(pathEl); svg.appendChild(polyline); svg.appendChild(line); dropContent.appendChild(svg); dropContent.createSpan({ text: 'Drop image here' }); const dropZone = inputWrapper; dropZone.addEventListener('dragenter', (e) => this.handleDragEnter(e as DragEvent)); dropZone.addEventListener('dragover', (e) => this.handleDragOver(e as DragEvent)); dropZone.addEventListener('dragleave', (e) => this.handleDragLeave(e as DragEvent)); dropZone.addEventListener('drop', (e) => this.handleDrop(e as DragEvent)); } private handleDragEnter(e: DragEvent) { e.preventDefault(); e.stopPropagation(); if (e.dataTransfer?.types.includes('Files')) { this.dropOverlay?.addClass('visible'); } } private handleDragOver(e: DragEvent) { e.preventDefault(); e.stopPropagation(); } private handleDragLeave(e: DragEvent) { e.preventDefault(); e.stopPropagation(); const inputWrapper = this.containerEl.querySelector('.claudian-input-wrapper'); if (!inputWrapper) { this.dropOverlay?.removeClass('visible'); return; } const rect = inputWrapper.getBoundingClientRect(); if ( e.clientX <= rect.left || e.clientX >= rect.right || e.clientY <= rect.top || e.clientY >= rect.bottom ) { this.dropOverlay?.removeClass('visible'); } } private async handleDrop(e: DragEvent) { e.preventDefault(); e.stopPropagation(); this.dropOverlay?.removeClass('visible'); const files = e.dataTransfer?.files; if (!files) return; for (let i = 0; i < files.length; i++) { const file = files[i]; if (this.isImageFile(file)) { await this.addImageFromFile(file, 'drop'); } } } private setupPasteHandler() { this.inputEl.addEventListener('paste', async (e) => { const items = e.clipboardData?.items; if (!items) return; for (let i = 0; i < items.length; i++) { const item = items[i]; if (item.type.startsWith('image/')) { e.preventDefault(); const file = item.getAsFile(); if (file) { await this.addImageFromFile(file, 'paste'); } return; } } }); } private isImageFile(file: File): boolean { return file.type.startsWith('image/') && this.getMediaType(file.name) !== null; } private getMediaType(filename: string): ImageMediaType | null { const ext = path.extname(filename).toLowerCase(); return IMAGE_EXTENSIONS[ext] || null; } private async addImageFromFile(file: File, source: 'paste' | 'drop'): Promise { if (file.size > MAX_IMAGE_SIZE) { this.notifyImageError(`Image exceeds ${this.formatSize(MAX_IMAGE_SIZE)} limit.`); return false; } const mediaType = this.getMediaType(file.name) || (file.type as ImageMediaType); if (!mediaType) { this.notifyImageError('Unsupported image type.'); return false; } try { const base64 = await this.fileToBase64(file); const attachment: ImageAttachment = { id: this.generateId(), name: file.name || `image-${Date.now()}.${mediaType.split('/')[1]}`, mediaType, data: base64, size: file.size, source, }; this.attachedImages.set(attachment.id, attachment); this.updateImagePreview(); this.callbacks.onImagesChanged(); return true; } catch (error) { this.notifyImageError('Failed to attach image.', error); return false; } } private async fileToBase64(file: File): Promise { const arrayBuffer = await file.arrayBuffer(); const buffer = Buffer.from(arrayBuffer); return buffer.toString('base64'); } // ============================================ // Private: Image Preview // ============================================ private updateImagePreview() { this.imagePreviewEl.empty(); if (this.attachedImages.size === 0) { this.imagePreviewEl.style.display = 'none'; return; } this.imagePreviewEl.style.display = 'flex'; for (const [id, image] of this.attachedImages) { this.renderImagePreview(id, image); } } private renderImagePreview(id: string, image: ImageAttachment) { const previewEl = this.imagePreviewEl.createDiv({ cls: 'claudian-image-chip' }); const thumbEl = previewEl.createDiv({ cls: 'claudian-image-thumb' }); thumbEl.createEl('img', { attr: { src: `data:${image.mediaType};base64,${image.data}`, alt: image.name, }, }); const infoEl = previewEl.createDiv({ cls: 'claudian-image-info' }); const nameEl = infoEl.createSpan({ cls: 'claudian-image-name' }); nameEl.setText(this.truncateName(image.name, 20)); nameEl.setAttribute('title', image.name); const sizeEl = infoEl.createSpan({ cls: 'claudian-image-size' }); sizeEl.setText(this.formatSize(image.size)); const removeEl = previewEl.createSpan({ cls: 'claudian-image-remove' }); removeEl.setText('\u00D7'); removeEl.setAttribute('aria-label', 'Remove image'); removeEl.addEventListener('click', (e) => { e.stopPropagation(); this.attachedImages.delete(id); this.updateImagePreview(); this.callbacks.onImagesChanged(); }); thumbEl.addEventListener('click', () => { this.showFullImage(image); }); } private showFullImage(image: ImageAttachment) { const overlay = document.body.createDiv({ cls: 'claudian-image-modal-overlay' }); const modal = overlay.createDiv({ cls: 'claudian-image-modal' }); modal.createEl('img', { attr: { src: `data:${image.mediaType};base64,${image.data}`, alt: image.name, }, }); const closeBtn = modal.createDiv({ cls: 'claudian-image-modal-close' }); closeBtn.setText('\u00D7'); const handleEsc = (e: KeyboardEvent) => { if (e.key === 'Escape') { close(); } }; const close = () => { document.removeEventListener('keydown', handleEsc); overlay.remove(); }; closeBtn.addEventListener('click', close); overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); document.addEventListener('keydown', handleEsc); } private generateId(): string { return `img-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`; } private truncateName(name: string, maxLen: number): string { if (name.length <= maxLen) return name; const ext = path.extname(name); const base = name.slice(0, name.length - ext.length); const truncatedBase = base.slice(0, maxLen - ext.length - 3); return `${truncatedBase}...${ext}`; } private formatSize(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } private notifyImageError(message: string, error?: unknown) { let userMessage = message; if (error instanceof Error) { if (error.message.includes('ENOENT') || error.message.includes('no such file')) { userMessage = `${message} (File not found)`; } else if (error.message.includes('EACCES') || error.message.includes('permission denied')) { userMessage = `${message} (Permission denied)`; } } new Notice(userMessage); } } ================================================ FILE: src/features/chat/ui/InputToolbar.ts ================================================ import { Notice, setIcon } from 'obsidian'; import * as path from 'path'; import type { McpServerManager } from '../../../core/mcp'; import type { ClaudeModel, ClaudianMcpServer, EffortLevel, PermissionMode, ThinkingBudget, UsageInfo } from '../../../core/types'; import { DEFAULT_CLAUDE_MODELS, EFFORT_LEVELS, filterVisibleModelOptions, isAdaptiveThinkingModel, THINKING_BUDGETS } from '../../../core/types'; import { CHECK_ICON_SVG, MCP_ICON_SVG } from '../../../shared/icons'; import { getModelsFromEnvironment, parseEnvironmentVariables } from '../../../utils/env'; import { filterValidPaths, findConflictingPath, isDuplicatePath, isValidDirectoryPath, validateDirectoryPath } from '../../../utils/externalContext'; import { expandHomePath, normalizePathForFilesystem } from '../../../utils/path'; export interface ToolbarSettings { model: ClaudeModel; thinkingBudget: ThinkingBudget; effortLevel: EffortLevel; permissionMode: PermissionMode; enableOpus1M: boolean; enableSonnet1M: boolean; } export interface ToolbarCallbacks { onModelChange: (model: ClaudeModel) => Promise; onThinkingBudgetChange: (budget: ThinkingBudget) => Promise; onEffortLevelChange: (effort: EffortLevel) => Promise; onPermissionModeChange: (mode: PermissionMode) => Promise; getSettings: () => ToolbarSettings; getEnvironmentVariables?: () => string; } export class ModelSelector { private container: HTMLElement; private buttonEl: HTMLElement | null = null; private dropdownEl: HTMLElement | null = null; private callbacks: ToolbarCallbacks; private isReady = false; constructor(parentEl: HTMLElement, callbacks: ToolbarCallbacks) { this.callbacks = callbacks; this.container = parentEl.createDiv({ cls: 'claudian-model-selector' }); this.render(); } private getAvailableModels() { const models = [...DEFAULT_CLAUDE_MODELS]; if (this.callbacks.getEnvironmentVariables) { const envVarsStr = this.callbacks.getEnvironmentVariables(); const envVars = parseEnvironmentVariables(envVarsStr); const customModels = getModelsFromEnvironment(envVars); if (customModels.length > 0) { return customModels; } } const settings = this.callbacks.getSettings(); return filterVisibleModelOptions(models, settings.enableOpus1M, settings.enableSonnet1M); } private render() { this.container.empty(); this.buttonEl = this.container.createDiv({ cls: 'claudian-model-btn' }); this.setReady(this.isReady); this.updateDisplay(); this.dropdownEl = this.container.createDiv({ cls: 'claudian-model-dropdown' }); this.renderOptions(); } updateDisplay() { if (!this.buttonEl) return; const currentModel = this.callbacks.getSettings().model; const models = this.getAvailableModels(); const modelInfo = models.find(m => m.value === currentModel); const displayModel = modelInfo || models[0]; this.buttonEl.empty(); const labelEl = this.buttonEl.createSpan({ cls: 'claudian-model-label' }); labelEl.setText(displayModel?.label || 'Unknown'); } setReady(ready: boolean) { this.isReady = ready; this.buttonEl?.toggleClass('ready', ready); } renderOptions() { if (!this.dropdownEl) return; this.dropdownEl.empty(); const currentModel = this.callbacks.getSettings().model; const models = this.getAvailableModels(); for (const model of [...models].reverse()) { const option = this.dropdownEl.createDiv({ cls: 'claudian-model-option' }); if (model.value === currentModel) { option.addClass('selected'); } option.createSpan({ text: model.label }); if (model.description) { option.setAttribute('title', model.description); } option.addEventListener('click', async (e) => { e.stopPropagation(); await this.callbacks.onModelChange(model.value); this.updateDisplay(); this.renderOptions(); }); } } } export class ThinkingBudgetSelector { private container: HTMLElement; private effortEl: HTMLElement | null = null; private effortGearsEl: HTMLElement | null = null; private budgetEl: HTMLElement | null = null; private budgetGearsEl: HTMLElement | null = null; private callbacks: ToolbarCallbacks; constructor(parentEl: HTMLElement, callbacks: ToolbarCallbacks) { this.callbacks = callbacks; this.container = parentEl.createDiv({ cls: 'claudian-thinking-selector' }); this.render(); } private render() { this.container.empty(); // Effort selector (for adaptive thinking models) this.effortEl = this.container.createDiv({ cls: 'claudian-thinking-effort' }); const effortLabel = this.effortEl.createSpan({ cls: 'claudian-thinking-label-text' }); effortLabel.setText('Effort:'); this.effortGearsEl = this.effortEl.createDiv({ cls: 'claudian-thinking-gears' }); // Legacy budget selector (for custom models) this.budgetEl = this.container.createDiv({ cls: 'claudian-thinking-budget' }); const budgetLabel = this.budgetEl.createSpan({ cls: 'claudian-thinking-label-text' }); budgetLabel.setText('Thinking:'); this.budgetGearsEl = this.budgetEl.createDiv({ cls: 'claudian-thinking-gears' }); this.updateDisplay(); } private renderEffortGears() { if (!this.effortGearsEl) return; this.effortGearsEl.empty(); const currentEffort = this.callbacks.getSettings().effortLevel; const currentInfo = EFFORT_LEVELS.find(e => e.value === currentEffort); const currentEl = this.effortGearsEl.createDiv({ cls: 'claudian-thinking-current' }); currentEl.setText(currentInfo?.label || 'High'); const optionsEl = this.effortGearsEl.createDiv({ cls: 'claudian-thinking-options' }); for (const effort of [...EFFORT_LEVELS].reverse()) { const gearEl = optionsEl.createDiv({ cls: 'claudian-thinking-gear' }); gearEl.setText(effort.label); if (effort.value === currentEffort) { gearEl.addClass('selected'); } gearEl.addEventListener('click', async (e) => { e.stopPropagation(); await this.callbacks.onEffortLevelChange(effort.value); this.updateDisplay(); }); } } private renderBudgetGears() { if (!this.budgetGearsEl) return; this.budgetGearsEl.empty(); const currentBudget = this.callbacks.getSettings().thinkingBudget; const currentBudgetInfo = THINKING_BUDGETS.find(b => b.value === currentBudget); const currentEl = this.budgetGearsEl.createDiv({ cls: 'claudian-thinking-current' }); currentEl.setText(currentBudgetInfo?.label || 'Off'); const optionsEl = this.budgetGearsEl.createDiv({ cls: 'claudian-thinking-options' }); for (const budget of [...THINKING_BUDGETS].reverse()) { const gearEl = optionsEl.createDiv({ cls: 'claudian-thinking-gear' }); gearEl.setText(budget.label); gearEl.setAttribute('title', budget.tokens > 0 ? `${budget.tokens.toLocaleString()} tokens` : 'Disabled'); if (budget.value === currentBudget) { gearEl.addClass('selected'); } gearEl.addEventListener('click', async (e) => { e.stopPropagation(); await this.callbacks.onThinkingBudgetChange(budget.value); this.updateDisplay(); }); } } updateDisplay() { const model = this.callbacks.getSettings().model; const adaptive = isAdaptiveThinkingModel(model); if (this.effortEl) { this.effortEl.style.display = adaptive ? '' : 'none'; } if (this.budgetEl) { this.budgetEl.style.display = adaptive ? 'none' : ''; } if (adaptive) { this.renderEffortGears(); } else { this.renderBudgetGears(); } } } export class PermissionToggle { private container: HTMLElement; private toggleEl: HTMLElement | null = null; private labelEl: HTMLElement | null = null; private callbacks: ToolbarCallbacks; constructor(parentEl: HTMLElement, callbacks: ToolbarCallbacks) { this.callbacks = callbacks; this.container = parentEl.createDiv({ cls: 'claudian-permission-toggle' }); this.render(); } private render() { this.container.empty(); this.labelEl = this.container.createSpan({ cls: 'claudian-permission-label' }); this.toggleEl = this.container.createDiv({ cls: 'claudian-toggle-switch' }); this.updateDisplay(); this.toggleEl.addEventListener('click', () => this.toggle()); } updateDisplay() { if (!this.toggleEl || !this.labelEl) return; const mode = this.callbacks.getSettings().permissionMode; if (mode === 'plan') { this.toggleEl.style.display = 'none'; this.labelEl.setText('PLAN'); this.labelEl.addClass('plan-active'); } else { this.toggleEl.style.display = ''; this.labelEl.removeClass('plan-active'); if (mode === 'yolo') { this.toggleEl.addClass('active'); this.labelEl.setText('YOLO'); } else { this.toggleEl.removeClass('active'); this.labelEl.setText('Safe'); } } } private async toggle() { const current = this.callbacks.getSettings().permissionMode; const newMode: PermissionMode = current === 'yolo' ? 'normal' : 'yolo'; await this.callbacks.onPermissionModeChange(newMode); this.updateDisplay(); } } export type AddExternalContextResult = | { success: true; normalizedPath: string } | { success: false; error: string }; export class ExternalContextSelector { private container: HTMLElement; private iconEl: HTMLElement | null = null; private badgeEl: HTMLElement | null = null; private dropdownEl: HTMLElement | null = null; private callbacks: ToolbarCallbacks; /** * Current external context paths. May contain: * - Persistent paths only (new sessions via clearExternalContexts) * - Restored session paths (loaded sessions via setExternalContexts) * - Mixed paths during active sessions */ private externalContextPaths: string[] = []; /** Paths that persist across all sessions (stored in settings). */ private persistentPaths: Set = new Set(); private onChangeCallback: ((paths: string[]) => void) | null = null; private onPersistenceChangeCallback: ((paths: string[]) => void) | null = null; constructor(parentEl: HTMLElement, callbacks: ToolbarCallbacks) { this.callbacks = callbacks; this.container = parentEl.createDiv({ cls: 'claudian-external-context-selector' }); this.render(); } setOnChange(callback: (paths: string[]) => void): void { this.onChangeCallback = callback; } setOnPersistenceChange(callback: (paths: string[]) => void): void { this.onPersistenceChangeCallback = callback; } getExternalContexts(): string[] { return [...this.externalContextPaths]; } getPersistentPaths(): string[] { return [...this.persistentPaths]; } setPersistentPaths(paths: string[]): void { // Validate paths - remove non-existent directories const validPaths = filterValidPaths(paths); const invalidPaths = paths.filter(p => !validPaths.includes(p)); this.persistentPaths = new Set(validPaths); // Merge persistent paths into external context paths this.mergePersistentPaths(); this.updateDisplay(); this.renderDropdown(); // If invalid paths were removed, notify user and save updated list if (invalidPaths.length > 0) { const pathNames = invalidPaths.map(p => this.shortenPath(p)).join(', '); new Notice(`Removed ${invalidPaths.length} invalid external context path(s): ${pathNames}`, 5000); this.onPersistenceChangeCallback?.([...this.persistentPaths]); } } togglePersistence(path: string): void { if (this.persistentPaths.has(path)) { this.persistentPaths.delete(path); } else { // Validate path still exists before persisting if (!isValidDirectoryPath(path)) { new Notice(`Cannot persist "${this.shortenPath(path)}" - directory no longer exists`, 4000); return; } this.persistentPaths.add(path); } this.onPersistenceChangeCallback?.([...this.persistentPaths]); this.renderDropdown(); } private mergePersistentPaths(): void { const pathSet = new Set(this.externalContextPaths); for (const path of this.persistentPaths) { pathSet.add(path); } this.externalContextPaths = [...pathSet]; } /** * Restore exact external context paths from a saved conversation. * Does NOT merge with persistent paths - preserves the session's historical state. * Use clearExternalContexts() for new sessions to start with current persistent paths. */ setExternalContexts(paths: string[]): void { this.externalContextPaths = [...paths]; this.updateDisplay(); this.renderDropdown(); } /** * Remove a path from external contexts (and persistent paths if applicable). * Exposed for testing the remove button behavior. */ removePath(pathStr: string): void { this.externalContextPaths = this.externalContextPaths.filter(p => p !== pathStr); // Also remove from persistent paths if it was persistent if (this.persistentPaths.has(pathStr)) { this.persistentPaths.delete(pathStr); this.onPersistenceChangeCallback?.([...this.persistentPaths]); } this.onChangeCallback?.(this.externalContextPaths); this.updateDisplay(); this.renderDropdown(); } /** * Add an external context path programmatically (e.g., from /add-dir command). * Validates the path and handles duplicates/conflicts. * @param pathInput - Path string (supports ~/ expansion) * @returns Result with success status and normalized path, or error message on failure */ addExternalContext(pathInput: string): AddExternalContextResult { const trimmed = pathInput?.trim(); if (!trimmed) { return { success: false, error: 'No path provided. Usage: /add-dir /absolute/path' }; } // Strip surrounding quotes if present (e.g., "/path/with spaces") let cleanPath = trimmed; if ((cleanPath.startsWith('"') && cleanPath.endsWith('"')) || (cleanPath.startsWith("'") && cleanPath.endsWith("'"))) { cleanPath = cleanPath.slice(1, -1); } // Expand home directory and normalize path const expandedPath = expandHomePath(cleanPath); const normalizedPath = normalizePathForFilesystem(expandedPath); if (!path.isAbsolute(normalizedPath)) { return { success: false, error: 'Path must be absolute. Usage: /add-dir /absolute/path' }; } // Validate path exists and is a directory with specific error messages const validation = validateDirectoryPath(normalizedPath); if (!validation.valid) { return { success: false, error: `${validation.error}: ${pathInput}` }; } // Check for duplicate (normalized comparison for cross-platform support) if (isDuplicatePath(normalizedPath, this.externalContextPaths)) { return { success: false, error: 'This folder is already added as an external context.' }; } // Check for nested/overlapping paths const conflict = findConflictingPath(normalizedPath, this.externalContextPaths); if (conflict) { return { success: false, error: this.formatConflictMessage(normalizedPath, conflict) }; } // Add the path this.externalContextPaths = [...this.externalContextPaths, normalizedPath]; this.onChangeCallback?.(this.externalContextPaths); this.updateDisplay(); this.renderDropdown(); return { success: true, normalizedPath }; } /** * Clear session-only external context paths (call on new conversation). * Uses persistent paths from settings if provided, otherwise falls back to local cache. * Validates paths before using them (silently filters invalid during session init). */ clearExternalContexts(persistentPathsFromSettings?: string[]): void { // Use settings value if provided (most up-to-date), otherwise use local cache if (persistentPathsFromSettings) { // Validate paths - silently filter during session initialization (not user action) const validPaths = filterValidPaths(persistentPathsFromSettings); this.persistentPaths = new Set(validPaths); } this.externalContextPaths = [...this.persistentPaths]; this.updateDisplay(); this.renderDropdown(); } private render() { this.container.empty(); const iconWrapper = this.container.createDiv({ cls: 'claudian-external-context-icon-wrapper' }); this.iconEl = iconWrapper.createDiv({ cls: 'claudian-external-context-icon' }); setIcon(this.iconEl, 'folder'); this.badgeEl = iconWrapper.createDiv({ cls: 'claudian-external-context-badge' }); this.updateDisplay(); // Click to open native folder picker iconWrapper.addEventListener('click', (e) => { e.stopPropagation(); this.openFolderPicker(); }); this.dropdownEl = this.container.createDiv({ cls: 'claudian-external-context-dropdown' }); this.renderDropdown(); } private async openFolderPicker() { try { // Access Electron's dialog through remote // eslint-disable-next-line @typescript-eslint/no-require-imports const { remote } = require('electron'); const result = await remote.dialog.showOpenDialog({ properties: ['openDirectory'], title: 'Select External Context', }); if (!result.canceled && result.filePaths.length > 0) { const selectedPath = result.filePaths[0]; // Check for duplicate (normalized comparison for cross-platform support) if (isDuplicatePath(selectedPath, this.externalContextPaths)) { new Notice('This folder is already added as an external context.', 3000); return; } // Check for nested/overlapping paths const conflict = findConflictingPath(selectedPath, this.externalContextPaths); if (conflict) { new Notice(this.formatConflictMessage(selectedPath, conflict), 5000); return; } this.externalContextPaths = [...this.externalContextPaths, selectedPath]; this.onChangeCallback?.(this.externalContextPaths); this.updateDisplay(); this.renderDropdown(); } } catch { new Notice('Unable to open folder picker.', 5000); } } /** Formats a conflict error message for display. */ private formatConflictMessage(newPath: string, conflict: { path: string; type: 'parent' | 'child' }): string { const shortNew = this.shortenPath(newPath); const shortExisting = this.shortenPath(conflict.path); return conflict.type === 'parent' ? `Cannot add "${shortNew}" - it's inside existing path "${shortExisting}"` : `Cannot add "${shortNew}" - it contains existing path "${shortExisting}"`; } private renderDropdown() { if (!this.dropdownEl) return; this.dropdownEl.empty(); // Header const headerEl = this.dropdownEl.createDiv({ cls: 'claudian-external-context-header' }); headerEl.setText('External Contexts'); // Path list const listEl = this.dropdownEl.createDiv({ cls: 'claudian-external-context-list' }); if (this.externalContextPaths.length === 0) { const emptyEl = listEl.createDiv({ cls: 'claudian-external-context-empty' }); emptyEl.setText('Click folder icon to add'); } else { for (const pathStr of this.externalContextPaths) { const itemEl = listEl.createDiv({ cls: 'claudian-external-context-item' }); const pathTextEl = itemEl.createSpan({ cls: 'claudian-external-context-text' }); // Show shortened path for display const displayPath = this.shortenPath(pathStr); pathTextEl.setText(displayPath); pathTextEl.setAttribute('title', pathStr); // Lock toggle button const isPersistent = this.persistentPaths.has(pathStr); const lockBtn = itemEl.createSpan({ cls: 'claudian-external-context-lock' }); if (isPersistent) { lockBtn.addClass('locked'); } setIcon(lockBtn, isPersistent ? 'lock' : 'unlock'); lockBtn.setAttribute('title', isPersistent ? 'Persistent (click to make session-only)' : 'Session-only (click to persist)'); lockBtn.addEventListener('click', (e) => { e.stopPropagation(); this.togglePersistence(pathStr); }); const removeBtn = itemEl.createSpan({ cls: 'claudian-external-context-remove' }); setIcon(removeBtn, 'x'); removeBtn.setAttribute('title', 'Remove path'); removeBtn.addEventListener('click', (e) => { e.stopPropagation(); this.removePath(pathStr); }); } } } /** Shorten path for display (replace home dir with ~) */ private shortenPath(fullPath: string): string { try { // eslint-disable-next-line @typescript-eslint/no-require-imports const os = require('os'); const homeDir = os.homedir(); const normalize = (value: string) => value.replace(/\\/g, '/'); const normalizedFull = normalize(fullPath); const normalizedHome = normalize(homeDir); const compareFull = process.platform === 'win32' ? normalizedFull.toLowerCase() : normalizedFull; const compareHome = process.platform === 'win32' ? normalizedHome.toLowerCase() : normalizedHome; if (compareFull.startsWith(compareHome)) { // Use normalized path length and normalize the result for consistent display const remainder = normalizedFull.slice(normalizedHome.length); return '~' + remainder; } } catch { // Fall through to return full path } return fullPath; } updateDisplay() { if (!this.iconEl || !this.badgeEl) return; const count = this.externalContextPaths.length; if (count > 0) { this.iconEl.addClass('active'); this.iconEl.setAttribute('title', `${count} external context${count > 1 ? 's' : ''} (click to add more)`); // Show badge only when more than 1 path if (count > 1) { this.badgeEl.setText(String(count)); this.badgeEl.addClass('visible'); } else { this.badgeEl.removeClass('visible'); } } else { this.iconEl.removeClass('active'); this.iconEl.setAttribute('title', 'Add external contexts (click)'); this.badgeEl.removeClass('visible'); } } } export class McpServerSelector { private container: HTMLElement; private iconEl: HTMLElement | null = null; private badgeEl: HTMLElement | null = null; private dropdownEl: HTMLElement | null = null; private mcpManager: McpServerManager | null = null; private enabledServers: Set = new Set(); private onChangeCallback: ((enabled: Set) => void) | null = null; constructor(parentEl: HTMLElement) { this.container = parentEl.createDiv({ cls: 'claudian-mcp-selector' }); this.render(); } setMcpManager(manager: McpServerManager | null): void { this.mcpManager = manager; this.pruneEnabledServers(); this.updateDisplay(); this.renderDropdown(); } setOnChange(callback: (enabled: Set) => void): void { this.onChangeCallback = callback; } getEnabledServers(): Set { return new Set(this.enabledServers); } addMentionedServers(names: Set): void { let changed = false; for (const name of names) { if (!this.enabledServers.has(name)) { this.enabledServers.add(name); changed = true; } } if (changed) { this.updateDisplay(); this.renderDropdown(); } } clearEnabled(): void { this.enabledServers.clear(); this.updateDisplay(); this.renderDropdown(); } setEnabledServers(names: string[]): void { this.enabledServers = new Set(names); this.pruneEnabledServers(); this.updateDisplay(); this.renderDropdown(); } private pruneEnabledServers(): void { if (!this.mcpManager) return; const activeNames = new Set(this.mcpManager.getServers().filter((s) => s.enabled).map((s) => s.name)); let changed = false; for (const name of this.enabledServers) { if (!activeNames.has(name)) { this.enabledServers.delete(name); changed = true; } } if (changed) { this.onChangeCallback?.(this.enabledServers); } } private render() { this.container.empty(); const iconWrapper = this.container.createDiv({ cls: 'claudian-mcp-selector-icon-wrapper' }); this.iconEl = iconWrapper.createDiv({ cls: 'claudian-mcp-selector-icon' }); this.iconEl.innerHTML = MCP_ICON_SVG; this.badgeEl = iconWrapper.createDiv({ cls: 'claudian-mcp-selector-badge' }); this.updateDisplay(); this.dropdownEl = this.container.createDiv({ cls: 'claudian-mcp-selector-dropdown' }); this.renderDropdown(); // Re-render dropdown content on hover (CSS handles visibility) this.container.addEventListener('mouseenter', () => { this.renderDropdown(); }); } private renderDropdown() { if (!this.dropdownEl) return; this.pruneEnabledServers(); this.dropdownEl.empty(); // Header const headerEl = this.dropdownEl.createDiv({ cls: 'claudian-mcp-selector-header' }); headerEl.setText('MCP Servers'); // Server list const listEl = this.dropdownEl.createDiv({ cls: 'claudian-mcp-selector-list' }); const allServers = this.mcpManager?.getServers() || []; const servers = allServers.filter(s => s.enabled); if (servers.length === 0) { const emptyEl = listEl.createDiv({ cls: 'claudian-mcp-selector-empty' }); emptyEl.setText(allServers.length === 0 ? 'No MCP servers configured' : 'All MCP servers disabled'); return; } for (const server of servers) { this.renderServerItem(listEl, server); } } private renderServerItem(listEl: HTMLElement, server: ClaudianMcpServer) { const itemEl = listEl.createDiv({ cls: 'claudian-mcp-selector-item' }); itemEl.dataset.serverName = server.name; const isEnabled = this.enabledServers.has(server.name); if (isEnabled) { itemEl.addClass('enabled'); } // Checkbox const checkEl = itemEl.createDiv({ cls: 'claudian-mcp-selector-check' }); if (isEnabled) { checkEl.innerHTML = CHECK_ICON_SVG; } // Info const infoEl = itemEl.createDiv({ cls: 'claudian-mcp-selector-item-info' }); const nameEl = infoEl.createSpan({ cls: 'claudian-mcp-selector-item-name' }); nameEl.setText(server.name); // Badges if (server.contextSaving) { const csEl = infoEl.createSpan({ cls: 'claudian-mcp-selector-cs-badge' }); csEl.setText('@'); csEl.setAttribute('title', 'Context-saving: can also enable via @' + server.name); } // Click to toggle (use mousedown for more reliable capture) itemEl.addEventListener('mousedown', (e) => { e.preventDefault(); e.stopPropagation(); this.toggleServer(server.name, itemEl); }); } private toggleServer(name: string, itemEl: HTMLElement) { if (this.enabledServers.has(name)) { this.enabledServers.delete(name); } else { this.enabledServers.add(name); } // Update item visually in-place (immediate feedback) const isEnabled = this.enabledServers.has(name); const checkEl = itemEl.querySelector('.claudian-mcp-selector-check') as HTMLElement | null; if (isEnabled) { itemEl.addClass('enabled'); if (checkEl) checkEl.innerHTML = CHECK_ICON_SVG; } else { itemEl.removeClass('enabled'); if (checkEl) checkEl.innerHTML = ''; } this.updateDisplay(); this.onChangeCallback?.(this.enabledServers); } updateDisplay() { this.pruneEnabledServers(); if (!this.iconEl || !this.badgeEl) return; const count = this.enabledServers.size; const hasServers = (this.mcpManager?.getServers().length || 0) > 0; // Show/hide container based on whether there are servers if (!hasServers) { this.container.style.display = 'none'; return; } this.container.style.display = ''; if (count > 0) { this.iconEl.addClass('active'); this.iconEl.setAttribute('title', `${count} MCP server${count > 1 ? 's' : ''} enabled (click to manage)`); // Show badge only when more than 1 if (count > 1) { this.badgeEl.setText(String(count)); this.badgeEl.addClass('visible'); } else { this.badgeEl.removeClass('visible'); } } else { this.iconEl.removeClass('active'); this.iconEl.setAttribute('title', 'MCP servers (click to enable)'); this.badgeEl.removeClass('visible'); } } } export class ContextUsageMeter { private container: HTMLElement; private fillPath: SVGPathElement | null = null; private percentEl: HTMLElement | null = null; private circumference: number = 0; constructor(parentEl: HTMLElement) { this.container = parentEl.createDiv({ cls: 'claudian-context-meter' }); this.render(); // Initially hidden this.container.style.display = 'none'; } private render() { const size = 16; const strokeWidth = 2; const radius = (size - strokeWidth) / 2; const cx = size / 2; const cy = size / 2; // 240° arc: from 150° to 390° (upper-left through bottom to upper-right) const startAngle = 150; const endAngle = 390; const arcDegrees = endAngle - startAngle; const arcRadians = (arcDegrees * Math.PI) / 180; this.circumference = radius * arcRadians; const startRad = (startAngle * Math.PI) / 180; const endRad = (endAngle * Math.PI) / 180; const x1 = cx + radius * Math.cos(startRad); const y1 = cy + radius * Math.sin(startRad); const x2 = cx + radius * Math.cos(endRad); const y2 = cy + radius * Math.sin(endRad); const gaugeEl = this.container.createDiv({ cls: 'claudian-context-meter-gauge' }); gaugeEl.innerHTML = ` `; this.fillPath = gaugeEl.querySelector('.claudian-meter-fill'); this.percentEl = this.container.createSpan({ cls: 'claudian-context-meter-percent' }); } update(usage: UsageInfo | null): void { if (!usage || usage.contextTokens <= 0) { this.container.style.display = 'none'; return; } this.container.style.display = 'flex'; const fillLength = (usage.percentage / 100) * this.circumference; if (this.fillPath) { this.fillPath.style.strokeDashoffset = String(this.circumference - fillLength); } if (this.percentEl) { this.percentEl.setText(`${usage.percentage}%`); } // Toggle warning class for > 80% if (usage.percentage > 80) { this.container.addClass('warning'); } else { this.container.removeClass('warning'); } // Set tooltip with detailed usage let tooltip = `${this.formatTokens(usage.contextTokens)} / ${this.formatTokens(usage.contextWindow)}`; if (usage.percentage > 80) { tooltip += ' (Approaching limit, run `/compact` to continue)'; } this.container.setAttribute('data-tooltip', tooltip); } private formatTokens(tokens: number): string { if (tokens >= 1000) { return `${Math.round(tokens / 1000)}k`; } return String(tokens); } } export function createInputToolbar( parentEl: HTMLElement, callbacks: ToolbarCallbacks ): { modelSelector: ModelSelector; thinkingBudgetSelector: ThinkingBudgetSelector; contextUsageMeter: ContextUsageMeter | null; externalContextSelector: ExternalContextSelector; mcpServerSelector: McpServerSelector; permissionToggle: PermissionToggle; } { const modelSelector = new ModelSelector(parentEl, callbacks); const thinkingBudgetSelector = new ThinkingBudgetSelector(parentEl, callbacks); const contextUsageMeter = new ContextUsageMeter(parentEl); const externalContextSelector = new ExternalContextSelector(parentEl, callbacks); const mcpServerSelector = new McpServerSelector(parentEl); const permissionToggle = new PermissionToggle(parentEl, callbacks); return { modelSelector, thinkingBudgetSelector, contextUsageMeter, externalContextSelector, mcpServerSelector, permissionToggle }; } ================================================ FILE: src/features/chat/ui/InstructionModeManager.ts ================================================ export interface InstructionModeCallbacks { onSubmit: (rawInstruction: string) => Promise; getInputWrapper: () => HTMLElement | null; resetInputHeight?: () => void; } export interface InstructionModeState { active: boolean; rawInstruction: string; } const INSTRUCTION_MODE_PLACEHOLDER = '# Save in custom system prompt'; export class InstructionModeManager { private inputEl: HTMLTextAreaElement; private callbacks: InstructionModeCallbacks; private state: InstructionModeState = { active: false, rawInstruction: '' }; private isSubmitting = false; private originalPlaceholder: string = ''; constructor( inputEl: HTMLTextAreaElement, callbacks: InstructionModeCallbacks ) { this.inputEl = inputEl; this.callbacks = callbacks; this.originalPlaceholder = inputEl.placeholder; } /** * Handles keydown to detect # trigger. * Returns true if the event was consumed (should prevent default). */ handleTriggerKey(e: KeyboardEvent): boolean { // Only trigger on # keystroke when input is empty and not already in mode if (!this.state.active && this.inputEl.value === '' && e.key === '#') { if (this.enterMode()) { e.preventDefault(); return true; } } return false; } /** Handles input changes to track instruction text. */ handleInputChange(): void { if (!this.state.active) return; const text = this.inputEl.value; if (text === '') { this.exitMode(); } else { this.state.rawInstruction = text; } } /** * Enters instruction mode. * Only enters if the indicator can be successfully shown. * Returns true if mode was entered, false otherwise. */ private enterMode(): boolean { // Indicator is single source of truth - only enter mode if we can show it const wrapper = this.callbacks.getInputWrapper(); if (!wrapper) return false; wrapper.addClass('claudian-input-instruction-mode'); this.state = { active: true, rawInstruction: '' }; this.inputEl.placeholder = INSTRUCTION_MODE_PLACEHOLDER; return true; } /** Exits instruction mode, restoring original state. */ private exitMode(): void { const wrapper = this.callbacks.getInputWrapper(); if (wrapper) { wrapper.removeClass('claudian-input-instruction-mode'); } this.state = { active: false, rawInstruction: '' }; this.inputEl.placeholder = this.originalPlaceholder; } /** Handles keydown events. Returns true if handled. */ handleKeydown(e: KeyboardEvent): boolean { if (!this.state.active) return false; // Check !e.isComposing for IME support (Chinese, Japanese, Korean, etc.) if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { // Don't handle if instruction is empty if (!this.state.rawInstruction.trim()) { return false; } e.preventDefault(); this.submit(); return true; } // Check !e.isComposing for IME support (Chinese, Japanese, Korean, etc.) if (e.key === 'Escape' && !e.isComposing) { e.preventDefault(); this.cancel(); return true; } return false; } /** Checks if instruction mode is active. */ isActive(): boolean { return this.state.active; } /** Gets the current raw instruction text. */ getRawInstruction(): string { return this.state.rawInstruction; } /** Submits the instruction for refinement. */ private async submit(): Promise { if (this.isSubmitting) return; const rawInstruction = this.state.rawInstruction.trim(); if (!rawInstruction) return; this.isSubmitting = true; try { await this.callbacks.onSubmit(rawInstruction); } finally { this.isSubmitting = false; } } /** Cancels instruction mode and clears input. */ private cancel(): void { this.inputEl.value = ''; this.exitMode(); this.callbacks.resetInputHeight?.(); } /** Clears the input and resets state (called after successful submission). */ clear(): void { this.inputEl.value = ''; this.exitMode(); this.callbacks.resetInputHeight?.(); } /** Cleans up event listeners. */ destroy(): void { // Remove indicator class and restore placeholder on destroy const wrapper = this.callbacks.getInputWrapper(); if (wrapper) { wrapper.removeClass('claudian-input-instruction-mode'); } this.inputEl.placeholder = this.originalPlaceholder; } } ================================================ FILE: src/features/chat/ui/NavigationSidebar.ts ================================================ import { setIcon } from 'obsidian'; /** * Floating sidebar for navigating chat history. * Provides quick access to top/bottom and previous/next user messages. */ export class NavigationSidebar { private container: HTMLElement; private topBtn: HTMLElement; private prevBtn: HTMLElement; private nextBtn: HTMLElement; private bottomBtn: HTMLElement; private scrollHandler: () => void; constructor( private parentEl: HTMLElement, private messagesEl: HTMLElement ) { this.container = this.parentEl.createDiv({ cls: 'claudian-nav-sidebar' }); // Create buttons this.topBtn = this.createButton('claudian-nav-btn-top', 'chevrons-up', 'Scroll to top'); this.prevBtn = this.createButton('claudian-nav-btn-prev', 'chevron-up', 'Previous message'); this.nextBtn = this.createButton('claudian-nav-btn-next', 'chevron-down', 'Next message'); this.bottomBtn = this.createButton('claudian-nav-btn-bottom', 'chevrons-down', 'Scroll to bottom'); this.setupEventListeners(); this.updateVisibility(); } private createButton(cls: string, icon: string, label: string): HTMLElement { const btn = this.container.createDiv({ cls: `claudian-nav-btn ${cls}` }); setIcon(btn, icon); btn.setAttribute('aria-label', label); return btn; } private setupEventListeners(): void { // Scroll handling to toggle visibility this.scrollHandler = () => this.updateVisibility(); this.messagesEl.addEventListener('scroll', this.scrollHandler, { passive: true }); // Button clicks this.topBtn.addEventListener('click', () => { this.messagesEl.scrollTo({ top: 0, behavior: 'smooth' }); }); this.bottomBtn.addEventListener('click', () => { this.messagesEl.scrollTo({ top: this.messagesEl.scrollHeight, behavior: 'smooth' }); }); this.prevBtn.addEventListener('click', () => this.scrollToMessage('prev')); this.nextBtn.addEventListener('click', () => this.scrollToMessage('next')); } /** * Updates visibility of the sidebar based on scroll state. * Visible if content overflows. */ updateVisibility(): void { const { scrollHeight, clientHeight } = this.messagesEl; const isScrollable = scrollHeight > clientHeight + 50; // Small buffer this.container.classList.toggle('visible', isScrollable); } /** * Scrolls to previous or next user message, skipping assistant messages. */ private scrollToMessage(direction: 'prev' | 'next'): void { const messages = Array.from(this.messagesEl.querySelectorAll('.claudian-message-user')) as HTMLElement[]; if (messages.length === 0) return; const scrollTop = this.messagesEl.scrollTop; const threshold = 30; if (direction === 'prev') { // Find the last message strictly above the current scroll position for (let i = messages.length - 1; i >= 0; i--) { if (messages[i].offsetTop < scrollTop - threshold) { this.messagesEl.scrollTo({ top: messages[i].offsetTop - 10, behavior: 'smooth' }); return; } } // Already at or above the first message — scroll to top this.messagesEl.scrollTo({ top: 0, behavior: 'smooth' }); } else { // Find the first message strictly below the current scroll position for (let i = 0; i < messages.length; i++) { if (messages[i].offsetTop > scrollTop + threshold) { this.messagesEl.scrollTo({ top: messages[i].offsetTop - 10, behavior: 'smooth' }); return; } } // Already at or past the last message — scroll to bottom this.messagesEl.scrollTo({ top: this.messagesEl.scrollHeight, behavior: 'smooth' }); } } destroy(): void { this.messagesEl.removeEventListener('scroll', this.scrollHandler); this.container.remove(); } } ================================================ FILE: src/features/chat/ui/StatusPanel.ts ================================================ import { Notice, setIcon } from 'obsidian'; import type { TodoItem } from '../../../core/tools'; import { getToolIcon, TOOL_TODO_WRITE } from '../../../core/tools'; import { t } from '../../../i18n'; import { renderTodoItems } from '../rendering/todoUtils'; export interface PanelBashOutput { id: string; command: string; status: 'running' | 'completed' | 'error'; output: string; exitCode?: number; } const MAX_BASH_OUTPUTS = 50; /** * StatusPanel - persistent bottom panel for todos and command output. */ export class StatusPanel { private containerEl: HTMLElement | null = null; private panelEl: HTMLElement | null = null; // Bash output section private bashOutputContainerEl: HTMLElement | null = null; private bashHeaderEl: HTMLElement | null = null; private bashContentEl: HTMLElement | null = null; private isBashExpanded = true; private currentBashOutputs: Map = new Map(); private bashEntryExpanded: Map = new Map(); // Todo section private todoContainerEl: HTMLElement | null = null; private todoHeaderEl: HTMLElement | null = null; private todoContentEl: HTMLElement | null = null; private isTodoExpanded = false; private currentTodos: TodoItem[] | null = null; // Event handler references for cleanup private todoClickHandler: (() => void) | null = null; private todoKeydownHandler: ((e: KeyboardEvent) => void) | null = null; private bashClickHandler: (() => void) | null = null; private bashKeydownHandler: ((e: KeyboardEvent) => void) | null = null; /** * Mount the panel into the messages container. * Appends to the end of the messages area. */ mount(containerEl: HTMLElement): void { this.containerEl = containerEl; this.createPanel(); } /** * Remount the panel to restore state after conversation changes. * Re-creates the panel structure and re-renders current state. */ remount(): void { if (!this.containerEl) { return; } // Remove old event listeners before removing DOM if (this.todoHeaderEl) { if (this.todoClickHandler) { this.todoHeaderEl.removeEventListener('click', this.todoClickHandler); } if (this.todoKeydownHandler) { this.todoHeaderEl.removeEventListener('keydown', this.todoKeydownHandler); } } this.todoClickHandler = null; this.todoKeydownHandler = null; if (this.bashHeaderEl) { if (this.bashClickHandler) { this.bashHeaderEl.removeEventListener('click', this.bashClickHandler); } if (this.bashKeydownHandler) { this.bashHeaderEl.removeEventListener('keydown', this.bashKeydownHandler); } } this.bashClickHandler = null; this.bashKeydownHandler = null; // Remove old panel from DOM if (this.panelEl) { this.panelEl.remove(); } // Clear references and recreate this.panelEl = null; this.bashOutputContainerEl = null; this.bashHeaderEl = null; this.bashContentEl = null; this.todoContainerEl = null; this.todoHeaderEl = null; this.todoContentEl = null; this.createPanel(); // Re-render current state this.renderBashOutputs(); if (this.currentTodos && this.currentTodos.length > 0) { this.updateTodos(this.currentTodos); } } /** * Create the panel structure. */ private createPanel(): void { if (!this.containerEl) { return; } // Create panel element (no border/background - seamless) this.panelEl = document.createElement('div'); this.panelEl.className = 'claudian-status-panel'; // Bash output container - hidden by default this.bashOutputContainerEl = document.createElement('div'); this.bashOutputContainerEl.className = 'claudian-status-panel-bash'; this.bashOutputContainerEl.style.display = 'none'; this.bashHeaderEl = document.createElement('div'); this.bashHeaderEl.className = 'claudian-tool-header claudian-status-panel-bash-header'; this.bashHeaderEl.setAttribute('tabindex', '0'); this.bashHeaderEl.setAttribute('role', 'button'); this.bashClickHandler = () => this.toggleBashSection(); this.bashKeydownHandler = (e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); this.toggleBashSection(); } }; this.bashHeaderEl.addEventListener('click', this.bashClickHandler); this.bashHeaderEl.addEventListener('keydown', this.bashKeydownHandler); this.bashContentEl = document.createElement('div'); this.bashContentEl.className = 'claudian-status-panel-bash-content'; this.bashOutputContainerEl.appendChild(this.bashHeaderEl); this.bashOutputContainerEl.appendChild(this.bashContentEl); this.panelEl.appendChild(this.bashOutputContainerEl); // Todo container this.todoContainerEl = document.createElement('div'); this.todoContainerEl.className = 'claudian-status-panel-todos'; this.todoContainerEl.style.display = 'none'; this.panelEl.appendChild(this.todoContainerEl); // Todo header (collapsed view) this.todoHeaderEl = document.createElement('div'); this.todoHeaderEl.className = 'claudian-status-panel-header'; this.todoHeaderEl.setAttribute('tabindex', '0'); this.todoHeaderEl.setAttribute('role', 'button'); // Store handler references for cleanup this.todoClickHandler = () => this.toggleTodos(); this.todoKeydownHandler = (e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); this.toggleTodos(); } }; this.todoHeaderEl.addEventListener('click', this.todoClickHandler); this.todoHeaderEl.addEventListener('keydown', this.todoKeydownHandler); this.todoContainerEl.appendChild(this.todoHeaderEl); // Todo content (expanded list) this.todoContentEl = document.createElement('div'); this.todoContentEl.className = 'claudian-status-panel-content claudian-todo-list-container'; this.todoContentEl.style.display = 'none'; this.todoContainerEl.appendChild(this.todoContentEl); this.containerEl.appendChild(this.panelEl); } /** * Update the panel with new todo items. * Called by ChatState.onTodosChanged callback when TodoWrite tool is used. * Passing null or empty array hides the panel. */ updateTodos(todos: TodoItem[] | null): void { if (!this.todoContainerEl || !this.todoHeaderEl || !this.todoContentEl) { // Component not ready - don't update internal state to keep it consistent with display return; } // Update internal state only after confirming component is ready this.currentTodos = todos; if (!todos || todos.length === 0) { this.todoContainerEl.style.display = 'none'; this.todoHeaderEl.empty(); this.todoContentEl.empty(); return; } this.todoContainerEl.style.display = 'block'; // Count completed and find current task const completedCount = todos.filter(t => t.status === 'completed').length; const totalCount = todos.length; const currentTask = todos.find(t => t.status === 'in_progress'); // Update header this.renderTodoHeader(completedCount, totalCount, currentTask); // Update content this.renderTodoContent(todos); // Update ARIA this.updateTodoAriaLabel(completedCount, totalCount); this.scrollToBottom(); } /** * Render the todo collapsed header. */ private renderTodoHeader(completedCount: number, totalCount: number, currentTask: TodoItem | undefined): void { if (!this.todoHeaderEl) return; this.todoHeaderEl.empty(); // List icon const icon = document.createElement('span'); icon.className = 'claudian-status-panel-icon'; setIcon(icon, getToolIcon(TOOL_TODO_WRITE)); this.todoHeaderEl.appendChild(icon); // Label const label = document.createElement('span'); label.className = 'claudian-status-panel-label'; label.textContent = `Tasks (${completedCount}/${totalCount})`; this.todoHeaderEl.appendChild(label); // Collapsed-only elements: status indicator and current task preview if (!this.isTodoExpanded) { // Status indicator (tick only when all todos complete) if (completedCount === totalCount && totalCount > 0) { const status = document.createElement('span'); status.className = 'claudian-status-panel-status status-completed'; setIcon(status, 'check'); this.todoHeaderEl.appendChild(status); } // Current task preview if (currentTask) { const current = document.createElement('span'); current.className = 'claudian-status-panel-current'; current.textContent = currentTask.activeForm; this.todoHeaderEl.appendChild(current); } } } /** * Render the expanded todo content. */ private renderTodoContent(todos: TodoItem[]): void { if (!this.todoContentEl) return; renderTodoItems(this.todoContentEl, todos); } /** * Toggle todo expanded/collapsed state. */ private toggleTodos(): void { this.isTodoExpanded = !this.isTodoExpanded; this.updateTodoDisplay(); } /** * Update todo display based on expanded state. */ private updateTodoDisplay(): void { if (!this.todoContentEl || !this.todoHeaderEl) return; // Show/hide content this.todoContentEl.style.display = this.isTodoExpanded ? 'block' : 'none'; // Re-render header to update current task visibility if (this.currentTodos && this.currentTodos.length > 0) { const completedCount = this.currentTodos.filter(t => t.status === 'completed').length; const totalCount = this.currentTodos.length; const currentTask = this.currentTodos.find(t => t.status === 'in_progress'); this.renderTodoHeader(completedCount, totalCount, currentTask); this.updateTodoAriaLabel(completedCount, totalCount); } this.scrollToBottom(); } /** * Update todo ARIA label. */ private updateTodoAriaLabel(completedCount: number, totalCount: number): void { if (!this.todoHeaderEl) return; const action = this.isTodoExpanded ? 'Collapse' : 'Expand'; this.todoHeaderEl.setAttribute( 'aria-label', `${action} task list - ${completedCount} of ${totalCount} completed` ); this.todoHeaderEl.setAttribute('aria-expanded', String(this.isTodoExpanded)); } /** * Scroll messages container to bottom. */ private scrollToBottom(): void { if (this.containerEl) { this.containerEl.scrollTop = this.containerEl.scrollHeight; } } // ============================================ // Bash Output Methods // ============================================ private truncateDescription(description: string, maxLength = 50): string { if (description.length <= maxLength) return description; return description.substring(0, maxLength) + '...'; } addBashOutput(info: PanelBashOutput): void { this.currentBashOutputs.set(info.id, info); while (this.currentBashOutputs.size > MAX_BASH_OUTPUTS) { const oldest = this.currentBashOutputs.keys().next().value as string | undefined; if (!oldest) break; this.currentBashOutputs.delete(oldest); this.bashEntryExpanded.delete(oldest); } this.renderBashOutputs(); } updateBashOutput(id: string, updates: Partial>): void { const existing = this.currentBashOutputs.get(id); if (!existing) return; this.currentBashOutputs.set(id, { ...existing, ...updates }); this.renderBashOutputs(); } clearBashOutputs(): void { this.currentBashOutputs.clear(); this.bashEntryExpanded.clear(); this.renderBashOutputs(); } private renderBashOutputs(options: { scroll?: boolean } = {}): void { if (!this.bashOutputContainerEl || !this.bashHeaderEl || !this.bashContentEl) return; const scroll = options.scroll ?? true; if (this.currentBashOutputs.size === 0) { this.bashOutputContainerEl.style.display = 'none'; return; } this.bashOutputContainerEl.style.display = 'block'; this.bashHeaderEl.empty(); this.bashContentEl.empty(); const headerIconEl = document.createElement('span'); headerIconEl.className = 'claudian-tool-icon'; headerIconEl.setAttribute('aria-hidden', 'true'); setIcon(headerIconEl, 'terminal'); this.bashHeaderEl.appendChild(headerIconEl); const latest = Array.from(this.currentBashOutputs.values()).at(-1); const headerLabelEl = document.createElement('span'); headerLabelEl.className = 'claudian-tool-label'; if (this.isBashExpanded) { headerLabelEl.textContent = t('chat.bangBash.commandPanel'); } else { headerLabelEl.textContent = latest ? this.truncateDescription(latest.command, 60) : t('chat.bangBash.commandPanel'); } this.bashHeaderEl.appendChild(headerLabelEl); const previewEl = document.createElement('span'); previewEl.className = 'claudian-tool-current'; previewEl.style.display = this.isBashExpanded ? '' : 'none'; this.bashHeaderEl.appendChild(previewEl); const summaryStatusEl = document.createElement('span'); summaryStatusEl.className = 'claudian-tool-status'; if (!this.isBashExpanded && latest) { summaryStatusEl.classList.add(`status-${latest.status}`); summaryStatusEl.setAttribute('aria-label', t('chat.bangBash.statusLabel', { status: latest.status })); if (latest.status === 'completed') setIcon(summaryStatusEl, 'check'); if (latest.status === 'error') setIcon(summaryStatusEl, 'x'); } else { summaryStatusEl.style.display = 'none'; } this.bashHeaderEl.appendChild(summaryStatusEl); this.bashHeaderEl.setAttribute('aria-expanded', String(this.isBashExpanded)); const actionsEl = document.createElement('span'); actionsEl.className = 'claudian-status-panel-bash-actions'; this.appendActionButton(actionsEl, 'copy', t('chat.bangBash.copyAriaLabel'), 'copy', () => { void this.copyLatestBashOutput(); }); this.appendActionButton(actionsEl, 'clear', t('chat.bangBash.clearAriaLabel'), 'trash', () => { this.clearBashOutputs(); }); this.bashHeaderEl.appendChild(actionsEl); this.bashContentEl.style.display = this.isBashExpanded ? 'block' : 'none'; if (!this.isBashExpanded) { return; } for (const info of this.currentBashOutputs.values()) { this.bashContentEl.appendChild(this.renderBashEntry(info)); } if (scroll) { this.bashContentEl.scrollTop = this.bashContentEl.scrollHeight; this.scrollToBottom(); } } private renderBashEntry(info: PanelBashOutput): HTMLElement { const entryEl = document.createElement('div'); entryEl.className = 'claudian-tool-call claudian-status-panel-bash-entry'; const entryHeaderEl = document.createElement('div'); entryHeaderEl.className = 'claudian-tool-header'; entryHeaderEl.setAttribute('tabindex', '0'); entryHeaderEl.setAttribute('role', 'button'); const entryIconEl = document.createElement('span'); entryIconEl.className = 'claudian-tool-icon'; entryIconEl.setAttribute('aria-hidden', 'true'); setIcon(entryIconEl, 'dollar-sign'); entryHeaderEl.appendChild(entryIconEl); const entryLabelEl = document.createElement('span'); entryLabelEl.className = 'claudian-tool-label'; entryLabelEl.textContent = t('chat.bangBash.commandLabel', { command: this.truncateDescription(info.command, 60) }); entryHeaderEl.appendChild(entryLabelEl); const entryStatusEl = document.createElement('span'); entryStatusEl.className = 'claudian-tool-status'; entryStatusEl.classList.add(`status-${info.status}`); entryStatusEl.setAttribute('aria-label', t('chat.bangBash.statusLabel', { status: info.status })); if (info.status === 'completed') setIcon(entryStatusEl, 'check'); if (info.status === 'error') setIcon(entryStatusEl, 'x'); entryHeaderEl.appendChild(entryStatusEl); entryEl.appendChild(entryHeaderEl); const contentEl = document.createElement('div'); contentEl.className = 'claudian-tool-content'; const isEntryExpanded = this.bashEntryExpanded.get(info.id) ?? true; contentEl.style.display = isEntryExpanded ? 'block' : 'none'; entryHeaderEl.setAttribute('aria-expanded', String(isEntryExpanded)); entryHeaderEl.setAttribute('aria-label', isEntryExpanded ? t('chat.bangBash.collapseOutput') : t('chat.bangBash.expandOutput')); entryHeaderEl.addEventListener('click', () => { this.bashEntryExpanded.set(info.id, !isEntryExpanded); this.renderBashOutputs({ scroll: false }); }); entryHeaderEl.addEventListener('keydown', (e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); this.bashEntryExpanded.set(info.id, !isEntryExpanded); this.renderBashOutputs({ scroll: false }); } }); const rowEl = document.createElement('div'); rowEl.className = 'claudian-tool-result-row'; const textEl = document.createElement('span'); textEl.className = 'claudian-tool-result-text'; if (info.status === 'running' && !info.output) { textEl.textContent = t('chat.bangBash.running'); } else if (info.output) { textEl.textContent = info.output; } rowEl.appendChild(textEl); contentEl.appendChild(rowEl); entryEl.appendChild(contentEl); return entryEl; } private async copyLatestBashOutput(): Promise { const latest = Array.from(this.currentBashOutputs.values()).at(-1); if (!latest) return; const output = latest.output?.trim() || (latest.status === 'running' ? t('chat.bangBash.running') : ''); const text = output ? `$ ${latest.command}\n${output}` : `$ ${latest.command}`; try { await navigator.clipboard.writeText(text); } catch { new Notice(t('chat.bangBash.copyFailed')); } } private appendActionButton( parent: HTMLElement, name: string, ariaLabel: string, icon: string, action: () => void ): void { const el = document.createElement('span'); el.className = `claudian-status-panel-bash-action claudian-status-panel-bash-action-${name}`; el.setAttribute('role', 'button'); el.setAttribute('tabindex', '0'); el.setAttribute('aria-label', ariaLabel); setIcon(el, icon); el.addEventListener('click', (e) => { e.stopPropagation(); action(); }); el.addEventListener('keydown', (e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); action(); } }); parent.appendChild(el); } private toggleBashSection(): void { this.isBashExpanded = !this.isBashExpanded; this.renderBashOutputs({ scroll: false }); } // ============================================ // Cleanup // ============================================ /** * Destroy the panel. */ destroy(): void { // Remove event listeners before removing elements if (this.todoHeaderEl) { if (this.todoClickHandler) { this.todoHeaderEl.removeEventListener('click', this.todoClickHandler); } if (this.todoKeydownHandler) { this.todoHeaderEl.removeEventListener('keydown', this.todoKeydownHandler); } } this.todoClickHandler = null; this.todoKeydownHandler = null; if (this.bashHeaderEl) { if (this.bashClickHandler) { this.bashHeaderEl.removeEventListener('click', this.bashClickHandler); } if (this.bashKeydownHandler) { this.bashHeaderEl.removeEventListener('keydown', this.bashKeydownHandler); } } this.bashClickHandler = null; this.bashKeydownHandler = null; // Clear bash output tracking this.currentBashOutputs.clear(); if (this.panelEl) { this.panelEl.remove(); this.panelEl = null; } this.bashOutputContainerEl = null; this.bashHeaderEl = null; this.bashContentEl = null; this.todoContainerEl = null; this.todoHeaderEl = null; this.todoContentEl = null; this.containerEl = null; this.currentTodos = null; } } ================================================ FILE: src/features/chat/ui/file-context/state/FileContextState.ts ================================================ export class FileContextState { private attachedFiles: Set = new Set(); private sessionStarted = false; private mentionedMcpServers: Set = new Set(); private currentNoteSent = false; getAttachedFiles(): Set { return new Set(this.attachedFiles); } hasSentCurrentNote(): boolean { return this.currentNoteSent; } markCurrentNoteSent(): void { this.currentNoteSent = true; } isSessionStarted(): boolean { return this.sessionStarted; } startSession(): void { this.sessionStarted = true; } resetForNewConversation(): void { this.sessionStarted = false; this.currentNoteSent = false; this.attachedFiles.clear(); this.clearMcpMentions(); } resetForLoadedConversation(hasMessages: boolean): void { this.currentNoteSent = hasMessages; this.attachedFiles.clear(); this.sessionStarted = hasMessages; this.clearMcpMentions(); } setAttachedFiles(files: string[]): void { this.attachedFiles.clear(); for (const file of files) { this.attachedFiles.add(file); } } attachFile(path: string): void { this.attachedFiles.add(path); } detachFile(path: string): void { this.attachedFiles.delete(path); } clearAttachments(): void { this.attachedFiles.clear(); } getMentionedMcpServers(): Set { return new Set(this.mentionedMcpServers); } clearMcpMentions(): void { this.mentionedMcpServers.clear(); } setMentionedMcpServers(mentions: Set): boolean { const changed = mentions.size !== this.mentionedMcpServers.size || [...mentions].some(name => !this.mentionedMcpServers.has(name)); if (changed) { this.mentionedMcpServers = new Set(mentions); } return changed; } addMentionedMcpServer(name: string): void { this.mentionedMcpServers.add(name); } } ================================================ FILE: src/features/chat/ui/file-context/view/FileChipsView.ts ================================================ import { setIcon } from 'obsidian'; export interface FileChipsViewCallbacks { onRemoveAttachment: (path: string) => void; onOpenFile: (path: string) => void; } export class FileChipsView { private containerEl: HTMLElement; private callbacks: FileChipsViewCallbacks; private fileIndicatorEl: HTMLElement; constructor(containerEl: HTMLElement, callbacks: FileChipsViewCallbacks) { this.containerEl = containerEl; this.callbacks = callbacks; const firstChild = this.containerEl.firstChild; this.fileIndicatorEl = this.containerEl.createDiv({ cls: 'claudian-file-indicator' }); if (firstChild) { this.containerEl.insertBefore(this.fileIndicatorEl, firstChild); } } destroy(): void { this.fileIndicatorEl.remove(); } renderCurrentNote(filePath: string | null): void { this.fileIndicatorEl.empty(); if (!filePath) { this.fileIndicatorEl.style.display = 'none'; return; } this.fileIndicatorEl.style.display = 'flex'; this.renderFileChip(filePath, () => { this.callbacks.onRemoveAttachment(filePath); }); } private renderFileChip(filePath: string, onRemove: () => void): void { const chipEl = this.fileIndicatorEl.createDiv({ cls: 'claudian-file-chip' }); const iconEl = chipEl.createSpan({ cls: 'claudian-file-chip-icon' }); setIcon(iconEl, 'file-text'); const normalizedPath = filePath.replace(/\\/g, '/'); const filename = normalizedPath.split('/').pop() || filePath; const nameEl = chipEl.createSpan({ cls: 'claudian-file-chip-name' }); nameEl.setText(filename); nameEl.setAttribute('title', filePath); const removeEl = chipEl.createSpan({ cls: 'claudian-file-chip-remove' }); removeEl.setText('\u00D7'); removeEl.setAttribute('aria-label', 'Remove'); chipEl.addEventListener('click', (e) => { if (!(e.target as HTMLElement).closest('.claudian-file-chip-remove')) { this.callbacks.onOpenFile(filePath); } }); removeEl.addEventListener('click', () => { onRemove(); }); } } ================================================ FILE: src/features/chat/ui/index.ts ================================================ export { type BangBashModeCallbacks, BangBashModeManager, type BangBashModeState } from './BangBashModeManager'; export { type FileContextCallbacks,FileContextManager } from './FileContext'; export { type ImageContextCallbacks,ImageContextManager } from './ImageContext'; export { type AddExternalContextResult, ContextUsageMeter, createInputToolbar, ExternalContextSelector, McpServerSelector, ModelSelector, PermissionToggle, ThinkingBudgetSelector, } from './InputToolbar'; export { type InstructionModeCallbacks, InstructionModeManager, type InstructionModeState } from './InstructionModeManager'; export { NavigationSidebar } from './NavigationSidebar'; export { type PanelBashOutput, StatusPanel } from './StatusPanel'; ================================================ FILE: src/features/inline-edit/InlineEditService.ts ================================================ import type { HookCallbackMatcher, Options } from '@anthropic-ai/claude-agent-sdk'; import { query as agentQuery } from '@anthropic-ai/claude-agent-sdk'; import { getInlineEditSystemPrompt } from '../../core/prompts/inlineEdit'; import { getPathFromToolInput } from '../../core/tools/toolInput'; import { isReadOnlyTool, READ_ONLY_TOOLS, TOOL_GLOB, TOOL_GREP, TOOL_LS, TOOL_READ, } from '../../core/tools/toolNames'; import { isAdaptiveThinkingModel, THINKING_BUDGETS } from '../../core/types'; import type ClaudianPlugin from '../../main'; import { appendContextFiles } from '../../utils/context'; import { type CursorContext } from '../../utils/editor'; import { getEnhancedPath, getMissingNodeError, parseEnvironmentVariables } from '../../utils/env'; import { getPathAccessType, getVaultPath, type PathAccessType } from '../../utils/path'; export type InlineEditMode = 'selection' | 'cursor'; export interface InlineEditSelectionRequest { mode: 'selection'; instruction: string; notePath: string; selectedText: string; startLine?: number; // 1-indexed lineCount?: number; contextFiles?: string[]; } export interface InlineEditCursorRequest { mode: 'cursor'; instruction: string; notePath: string; cursorContext: CursorContext; contextFiles?: string[]; } export type InlineEditRequest = InlineEditSelectionRequest | InlineEditCursorRequest; export interface InlineEditResult { success: boolean; editedText?: string; // replacement (selection mode) insertedText?: string; // insertion (cursor mode) clarification?: string; error?: string; } /** Parses response text for or tag. */ export function parseInlineEditResponse(responseText: string): InlineEditResult { const replacementMatch = responseText.match(/([\s\S]*?)<\/replacement>/); if (replacementMatch) { return { success: true, editedText: replacementMatch[1] }; } const insertionMatch = responseText.match(/([\s\S]*?)<\/insertion>/); if (insertionMatch) { return { success: true, insertedText: insertionMatch[1] }; } const trimmed = responseText.trim(); if (trimmed) { return { success: true, clarification: trimmed }; } return { success: false, error: 'Empty response' }; } function buildCursorPrompt(request: InlineEditCursorRequest): string { const ctx = request.cursorContext; const lineAttr = ` line="${ctx.line + 1}"`; // 1-indexed let cursorContent: string; if (ctx.isInbetween) { const parts = []; if (ctx.beforeCursor) parts.push(ctx.beforeCursor); parts.push('| #inbetween'); if (ctx.afterCursor) parts.push(ctx.afterCursor); cursorContent = parts.join('\n'); } else { cursorContent = `${ctx.beforeCursor}|${ctx.afterCursor} #inline`; } return [ request.instruction, '', ``, cursorContent, '', ].join('\n'); } export function buildInlineEditPrompt(request: InlineEditRequest): string { let prompt: string; if (request.mode === 'cursor') { prompt = buildCursorPrompt(request); } else { // Instruction first for slash command detection const lineAttr = request.startLine && request.lineCount ? ` lines="${request.startLine}-${request.startLine + request.lineCount - 1}"` : ''; prompt = [ request.instruction, '', ``, request.selectedText, '', ].join('\n'); } // User content first for slash command detection if (request.contextFiles && request.contextFiles.length > 0) { prompt = appendContextFiles(prompt, request.contextFiles); } return prompt; } export function createReadOnlyHook(): HookCallbackMatcher { return { hooks: [ async (hookInput) => { const input = hookInput as { tool_name: string; tool_input: Record; }; const toolName = input.tool_name; if (isReadOnlyTool(toolName)) { return { continue: true }; } return { continue: false, hookSpecificOutput: { hookEventName: 'PreToolUse' as const, permissionDecision: 'deny' as const, permissionDecisionReason: `Inline edit mode: tool "${toolName}" is not allowed (read-only)`, }, }; }, ], }; } export function createVaultRestrictionHook(vaultPath: string): HookCallbackMatcher { const fileTools = [TOOL_READ, TOOL_GLOB, TOOL_GREP, TOOL_LS] as const; return { hooks: [ async (hookInput) => { const input = hookInput as { tool_name: string; tool_input: Record; }; const toolName = input.tool_name; if (!fileTools.includes(toolName as (typeof fileTools)[number])) { return { continue: true }; } const filePath = getPathFromToolInput(toolName, input.tool_input); if (!filePath) { // Fail-closed: deny if we can't determine the path for a file tool return { continue: false, hookSpecificOutput: { hookEventName: 'PreToolUse' as const, permissionDecision: 'deny' as const, permissionDecisionReason: `Access denied: Could not determine path for "${toolName}" tool.`, }, }; } // Allows vault and ~/.claude/ paths (context/readwrite params are undefined) let accessType: PathAccessType; try { accessType = getPathAccessType(filePath, undefined, undefined, vaultPath); } catch { // Fail-closed: deny if path validation throws (ENOENT, ELOOP, EPERM, etc.) return { continue: false, hookSpecificOutput: { hookEventName: 'PreToolUse' as const, permissionDecision: 'deny' as const, permissionDecisionReason: `Access denied: Failed to validate path "${filePath}".`, }, }; } if (accessType === 'vault' || accessType === 'context' || accessType === 'readwrite') { return { continue: true }; } return { continue: false, hookSpecificOutput: { hookEventName: 'PreToolUse' as const, permissionDecision: 'deny' as const, permissionDecisionReason: `Access denied: Path "${filePath}" is outside allowed paths. Inline edit is restricted to vault and ~/.claude/ directories.`, }, }; }, ], }; } export function extractTextFromSdkMessage(message: any): string | null { if (message.type === 'assistant' && message.message?.content) { for (const block of message.message.content) { if (block.type === 'text' && block.text) { return block.text; } } } if (message.type === 'stream_event') { const event = message.event; if (event?.type === 'content_block_start' && event.content_block?.type === 'text') { return event.content_block.text || null; } if (event?.type === 'content_block_delta' && event.delta?.type === 'text_delta') { return event.delta.text || null; } } return null; } export class InlineEditService { private plugin: ClaudianPlugin; private abortController: AbortController | null = null; private sessionId: string | null = null; constructor(plugin: ClaudianPlugin) { this.plugin = plugin; } resetConversation(): void { this.sessionId = null; } async editText(request: InlineEditRequest): Promise { this.sessionId = null; const prompt = buildInlineEditPrompt(request); return this.sendMessage(prompt); } async continueConversation(message: string, contextFiles?: string[]): Promise { if (!this.sessionId) { return { success: false, error: 'No active conversation to continue' }; } // User content first for slash command detection let prompt = message; if (contextFiles && contextFiles.length > 0) { prompt = appendContextFiles(message, contextFiles); } return this.sendMessage(prompt); } private async sendMessage(prompt: string): Promise { const vaultPath = getVaultPath(this.plugin.app); if (!vaultPath) { return { success: false, error: 'Could not determine vault path' }; } const resolvedClaudePath = this.plugin.getResolvedClaudeCliPath(); if (!resolvedClaudePath) { return { success: false, error: 'Claude CLI not found. Please install Claude Code CLI.' }; } this.abortController = new AbortController(); const customEnv = parseEnvironmentVariables(this.plugin.getActiveEnvironmentVariables()); const enhancedPath = getEnhancedPath(customEnv.PATH, resolvedClaudePath); const missingNodeError = getMissingNodeError(resolvedClaudePath, enhancedPath); if (missingNodeError) { return { success: false, error: missingNodeError }; } const options: Options = { cwd: vaultPath, systemPrompt: getInlineEditSystemPrompt(this.plugin.settings.allowExternalAccess), model: this.plugin.settings.model, abortController: this.abortController, pathToClaudeCodeExecutable: resolvedClaudePath, env: { ...process.env, ...customEnv, PATH: enhancedPath, }, tools: [...READ_ONLY_TOOLS], permissionMode: 'bypassPermissions', allowDangerouslySkipPermissions: true, settingSources: this.plugin.settings.loadUserClaudeSettings ? ['user', 'project'] : ['project'], hooks: { PreToolUse: this.plugin.settings.allowExternalAccess ? [createReadOnlyHook()] : [createReadOnlyHook(), createVaultRestrictionHook(vaultPath)], }, }; if (this.sessionId) { options.resume = this.sessionId; } if (isAdaptiveThinkingModel(this.plugin.settings.model)) { options.thinking = { type: 'adaptive' }; options.effort = this.plugin.settings.effortLevel; } else { const budgetConfig = THINKING_BUDGETS.find(b => b.value === this.plugin.settings.thinkingBudget); if (budgetConfig && budgetConfig.tokens > 0) { options.maxThinkingTokens = budgetConfig.tokens; } } try { const response = agentQuery({ prompt, options }); let responseText = ''; for await (const message of response) { if (this.abortController?.signal.aborted) { await response.interrupt(); return { success: false, error: 'Cancelled' }; } if (message.type === 'system' && message.subtype === 'init' && message.session_id) { this.sessionId = message.session_id; } const text = extractTextFromSdkMessage(message); if (text) { responseText += text; } } return parseInlineEditResponse(responseText); } catch (error) { const msg = error instanceof Error ? error.message : 'Unknown error'; return { success: false, error: msg }; } finally { this.abortController = null; } } cancel(): void { if (this.abortController) { this.abortController.abort(); } } } ================================================ FILE: src/features/inline-edit/ui/InlineEditModal.ts ================================================ import { RangeSetBuilder, StateEffect, StateField } from '@codemirror/state'; import type { DecorationSet } from '@codemirror/view'; import { Decoration, EditorView, WidgetType } from '@codemirror/view'; import type { App, Editor, MarkdownView } from 'obsidian'; import { Notice } from 'obsidian'; import type ClaudianPlugin from '../../../main'; import { hideSelectionHighlight, showSelectionHighlight } from '../../../shared/components/SelectionHighlight'; import { SlashCommandDropdown } from '../../../shared/components/SlashCommandDropdown'; import { MentionDropdownController } from '../../../shared/mention/MentionDropdownController'; import { VaultMentionDataProvider } from '../../../shared/mention/VaultMentionDataProvider'; import { createExternalContextLookupGetter, findBestMentionLookupMatch, isMentionStart, normalizeForPlatformLookup, normalizeMentionPath, resolveExternalMentionAtIndex, } from '../../../utils/contextMentionResolver'; import { type CursorContext, getEditorView } from '../../../utils/editor'; import { buildExternalContextDisplayEntries } from '../../../utils/externalContext'; import { externalContextScanner } from '../../../utils/externalContextScanner'; import { escapeHtml, normalizeInsertionText } from '../../../utils/inlineEdit'; import { getVaultPath, normalizePathForVault as normalizePathForVaultUtil } from '../../../utils/path'; import { type InlineEditMode, InlineEditService } from '../InlineEditService'; export type InlineEditContext = | { mode: 'selection'; selectedText: string } | { mode: 'cursor'; cursorContext: CursorContext }; const showInlineEdit = StateEffect.define<{ inputPos: number; selFrom: number; selTo: number; widget: InlineEditController; isInbetween?: boolean; }>(); const showDiff = StateEffect.define<{ from: number; to: number; diffHtml: string; widget: InlineEditController; }>(); const showInsertion = StateEffect.define<{ pos: number; diffHtml: string; widget: InlineEditController; }>(); const hideInlineEdit = StateEffect.define(); let activeController: InlineEditController | null = null; class DiffWidget extends WidgetType { constructor(private diffHtml: string, private controller: InlineEditController) { super(); } toDOM(): HTMLElement { const span = document.createElement('span'); span.className = 'claudian-inline-diff-replace'; span.innerHTML = this.diffHtml; const btns = document.createElement('span'); btns.className = 'claudian-inline-diff-buttons'; const rejectBtn = document.createElement('button'); rejectBtn.className = 'claudian-inline-diff-btn reject'; rejectBtn.textContent = '✕'; rejectBtn.title = 'Reject (Esc)'; rejectBtn.onclick = () => this.controller.reject(); const acceptBtn = document.createElement('button'); acceptBtn.className = 'claudian-inline-diff-btn accept'; acceptBtn.textContent = '✓'; acceptBtn.title = 'Accept (Enter)'; acceptBtn.onclick = () => this.controller.accept(); btns.appendChild(rejectBtn); btns.appendChild(acceptBtn); span.appendChild(btns); return span; } eq(other: DiffWidget): boolean { return this.diffHtml === other.diffHtml; } ignoreEvent(): boolean { return true; } } class InputWidget extends WidgetType { constructor(private controller: InlineEditController) { super(); } toDOM(): HTMLElement { return this.controller.createInputDOM(); } eq(): boolean { return false; } ignoreEvent(): boolean { return true; } } const inlineEditField = StateField.define({ create: () => Decoration.none, update: (deco, tr) => { deco = deco.map(tr.changes); for (const e of tr.effects) { if (e.is(showInlineEdit)) { const builder = new RangeSetBuilder(); // Block above line for selection/inline mode, inline widget for inbetween mode const isInbetween = e.value.isInbetween ?? false; builder.add(e.value.inputPos, e.value.inputPos, Decoration.widget({ widget: new InputWidget(e.value.widget), block: !isInbetween, side: isInbetween ? 1 : -1, })); deco = builder.finish(); } else if (e.is(showDiff)) { const builder = new RangeSetBuilder(); builder.add(e.value.from, e.value.to, Decoration.replace({ widget: new DiffWidget(e.value.diffHtml, e.value.widget), })); deco = builder.finish(); } else if (e.is(showInsertion)) { const builder = new RangeSetBuilder(); builder.add(e.value.pos, e.value.pos, Decoration.widget({ widget: new DiffWidget(e.value.diffHtml, e.value.widget), side: 1, // After the position })); deco = builder.finish(); } else if (e.is(hideInlineEdit)) { deco = Decoration.none; } } return deco; }, provide: (f) => EditorView.decorations.from(f), }); const installedEditors = new WeakSet(); interface DiffOp { type: 'equal' | 'insert' | 'delete'; text: string; } function computeDiff(oldText: string, newText: string): DiffOp[] { const oldWords = oldText.split(/(\s+)/); const newWords = newText.split(/(\s+)/); const m = oldWords.length, n = newWords.length; const dp: number[][] = Array(m + 1).fill(null).map(() => Array(n + 1).fill(0)); for (let i = 1; i <= m; i++) { for (let j = 1; j <= n; j++) { dp[i][j] = oldWords[i-1] === newWords[j-1] ? dp[i-1][j-1] + 1 : Math.max(dp[i-1][j], dp[i][j-1]); } } const ops: DiffOp[] = []; let i = m, j = n; const temp: DiffOp[] = []; while (i > 0 || j > 0) { if (i > 0 && j > 0 && oldWords[i-1] === newWords[j-1]) { temp.push({ type: 'equal', text: oldWords[i-1] }); i--; j--; } else if (j > 0 && (i === 0 || dp[i][j-1] >= dp[i-1][j])) { temp.push({ type: 'insert', text: newWords[j-1] }); j--; } else { temp.push({ type: 'delete', text: oldWords[i-1] }); i--; } } temp.reverse(); for (const op of temp) { if (ops.length > 0 && ops[ops.length-1].type === op.type) { ops[ops.length-1].text += op.text; } else { ops.push({ ...op }); } } return ops; } function diffToHtml(ops: DiffOp[]): string { return ops.map(op => { const escaped = escapeHtml(op.text); switch (op.type) { case 'delete': return `${escaped}`; case 'insert': return `${escaped}`; default: return escaped; } }).join(''); } export type InlineEditDecision = 'accept' | 'edit' | 'reject'; export class InlineEditModal { private controller: InlineEditController | null = null; constructor( private app: App, private plugin: ClaudianPlugin, private editor: Editor, private view: MarkdownView, private editContext: InlineEditContext, private notePath: string, private getExternalContexts: () => string[] = () => [] ) {} async openAndWait(): Promise<{ decision: InlineEditDecision; editedText?: string }> { if (activeController) { activeController.reject(); return { decision: 'reject' }; } // Use the editor/view provided by Obsidian's editorCallback. // This avoids timing issues during leaf/view transitions (e.g., navigating via Search in the same tab). let editor = this.editor; let editorView = getEditorView(editor); // Fallback: in rare cases Obsidian may re-initialize the editor between callback and modal open. if (!editorView) { editor = this.view.editor; editorView = getEditorView(editor); } if (!editorView) { new Notice('Inline edit unavailable: could not access the active editor. Try reopening the note.'); return { decision: 'reject' }; } return new Promise((resolve) => { this.controller = new InlineEditController( this.app, this.plugin, editorView, editor, this.editContext, this.notePath, this.getExternalContexts, resolve ); activeController = this.controller; this.controller.show(); }); } } class InlineEditController { private inputEl: HTMLInputElement | null = null; private spinnerEl: HTMLElement | null = null; private agentReplyEl: HTMLElement | null = null; private containerEl: HTMLElement | null = null; private editedText: string | null = null; private insertedText: string | null = null; private selFrom: number; private selTo: number; private selectedText: string; private startLine: number = 0; // 1-indexed private mode: InlineEditMode; private cursorContext: CursorContext | null = null; private inlineEditService: InlineEditService; private escHandler: ((e: KeyboardEvent) => void) | null = null; private selectionListener: ((e: Event) => void) | null = null; private isConversing = false; private slashCommandDropdown: SlashCommandDropdown | null = null; private mentionDropdown: MentionDropdownController | null = null; private mentionDataProvider: VaultMentionDataProvider; constructor( private app: App, private plugin: ClaudianPlugin, private editorView: EditorView, private editor: Editor, editContext: InlineEditContext, private notePath: string, private getExternalContexts: () => string[], private resolve: (result: { decision: InlineEditDecision; editedText?: string }) => void ) { this.inlineEditService = new InlineEditService(plugin); this.mentionDataProvider = new VaultMentionDataProvider(this.app, { onFileLoadError: () => { new Notice('Failed to load vault files. Vault @-mentions may be unavailable.'); }, }); this.mentionDataProvider.initializeInBackground(); this.mode = editContext.mode; if (editContext.mode === 'cursor') { this.cursorContext = editContext.cursorContext; this.selectedText = ''; } else { this.selectedText = editContext.selectedText; } this.updatePositionsFromEditor(); } private updatePositionsFromEditor() { const doc = this.editorView.state.doc; if (this.mode === 'cursor') { const ctx = this.cursorContext as CursorContext; const line = doc.line(ctx.line + 1); this.selFrom = line.from + ctx.column; this.selTo = this.selFrom; } else { const from = this.editor.getCursor('from'); const to = this.editor.getCursor('to'); const fromLine = doc.line(from.line + 1); const toLine = doc.line(to.line + 1); this.selFrom = fromLine.from + from.ch; this.selTo = toLine.from + to.ch; this.selectedText = this.editor.getSelection() || this.selectedText; this.startLine = from.line + 1; // 1-indexed } } show() { if (!installedEditors.has(this.editorView)) { this.editorView.dispatch({ effects: StateEffect.appendConfig.of(inlineEditField), }); installedEditors.add(this.editorView); } this.updateHighlight(); if (this.mode === 'selection') { this.attachSelectionListeners(); } // !e.isComposing: skip during IME composition (Chinese, Japanese, Korean, etc.) this.escHandler = (e: KeyboardEvent) => { if (e.key === 'Escape' && !e.isComposing) { this.reject(); } }; document.addEventListener('keydown', this.escHandler); } private updateHighlight() { const doc = this.editorView.state.doc; const line = doc.lineAt(this.selFrom); const isInbetween = this.mode === 'cursor' && this.cursorContext?.isInbetween; this.editorView.dispatch({ effects: showInlineEdit.of({ inputPos: isInbetween ? this.selFrom : line.from, selFrom: this.selFrom, selTo: this.selTo, widget: this, isInbetween, }), }); this.updateSelectionHighlight(); } private updateSelectionHighlight(): void { if (this.mode === 'selection' && this.selFrom !== this.selTo) { showSelectionHighlight(this.editorView, this.selFrom, this.selTo); } else { hideSelectionHighlight(this.editorView); } } private attachSelectionListeners() { this.removeSelectionListeners(); this.selectionListener = (e: Event) => { const target = e.target as Node | null; if (target && this.inputEl && (target === this.inputEl || this.inputEl.contains(target))) { return; } const prevFrom = this.selFrom; const prevTo = this.selTo; const newSelection = this.editor.getSelection(); if (newSelection && newSelection.length > 0) { this.updatePositionsFromEditor(); if (prevFrom !== this.selFrom || prevTo !== this.selTo) { this.updateHighlight(); } } }; this.editorView.dom.addEventListener('mouseup', this.selectionListener); this.editorView.dom.addEventListener('keyup', this.selectionListener); } createInputDOM(): HTMLElement { const container = document.createElement('div'); container.className = 'claudian-inline-input-container'; this.containerEl = container; this.agentReplyEl = document.createElement('div'); this.agentReplyEl.className = 'claudian-inline-agent-reply'; this.agentReplyEl.style.display = 'none'; container.appendChild(this.agentReplyEl); const inputWrap = document.createElement('div'); inputWrap.className = 'claudian-inline-input-wrap'; container.appendChild(inputWrap); this.inputEl = document.createElement('input'); this.inputEl.type = 'text'; this.inputEl.className = 'claudian-inline-input'; this.inputEl.placeholder = this.mode === 'cursor' ? 'Insert instructions...' : 'Edit instructions...'; this.inputEl.spellcheck = false; inputWrap.appendChild(this.inputEl); this.spinnerEl = document.createElement('div'); this.spinnerEl.className = 'claudian-inline-spinner'; this.spinnerEl.style.display = 'none'; inputWrap.appendChild(this.spinnerEl); this.slashCommandDropdown = new SlashCommandDropdown( document.body, // Fixed positioning this.inputEl, { onSelect: () => {}, onHide: () => {}, getSdkCommands: () => this.plugin.getSdkCommands(), }, { fixed: true, hiddenCommands: new Set((this.plugin.settings.hiddenSlashCommands || []).map(c => c.toLowerCase())), } ); this.mentionDropdown = new MentionDropdownController( document.body, this.inputEl, { // Inline-edit resolves @mentions at send time from input text. onAttachFile: () => {}, onMcpMentionChange: () => {}, getMentionedMcpServers: () => new Set(), setMentionedMcpServers: () => false, addMentionedMcpServer: () => {}, getExternalContexts: this.getExternalContexts, getCachedVaultFolders: () => this.mentionDataProvider.getCachedVaultFolders(), getCachedVaultFiles: () => this.mentionDataProvider.getCachedVaultFiles(), normalizePathForVault: (rawPath) => this.normalizePathForVault(rawPath), }, { fixed: true } ); this.inputEl.addEventListener('keydown', (e) => this.handleKeydown(e)); this.inputEl.addEventListener('input', () => this.mentionDropdown?.handleInputChange()); setTimeout(() => this.inputEl?.focus(), 50); return container; } private async generate() { if (!this.inputEl || !this.spinnerEl) return; const userMessage = this.inputEl.value.trim(); if (!userMessage) return; // Slash commands are passed directly to SDK for handling this.removeSelectionListeners(); this.inputEl.disabled = true; this.spinnerEl.style.display = 'block'; const contextFiles = this.resolveContextFilesFromMessage(userMessage); let result; if (this.isConversing) { result = await this.inlineEditService.continueConversation(userMessage, contextFiles); } else { if (this.mode === 'cursor') { result = await this.inlineEditService.editText({ mode: 'cursor', instruction: userMessage, notePath: this.notePath, cursorContext: this.cursorContext as CursorContext, contextFiles, }); } else { const lineCount = this.selectedText.split(/\r?\n/).length; result = await this.inlineEditService.editText({ mode: 'selection', instruction: userMessage, notePath: this.notePath, selectedText: this.selectedText, startLine: this.startLine, lineCount, contextFiles, }); } } this.spinnerEl.style.display = 'none'; if (result.success) { if (result.editedText !== undefined) { this.editedText = result.editedText; this.showDiffInPlace(); } else if (result.insertedText !== undefined) { this.insertedText = result.insertedText; this.showInsertionInPlace(); } else if (result.clarification) { this.showAgentReply(result.clarification); this.isConversing = true; this.inputEl.disabled = false; this.inputEl.value = ''; this.inputEl.placeholder = 'Reply to continue...'; this.inputEl.focus(); } else { this.handleError('No response from agent'); } } else { this.handleError(result.error || 'Error - try again'); } } private showAgentReply(message: string) { if (!this.agentReplyEl || !this.containerEl) return; this.agentReplyEl.style.display = 'block'; this.agentReplyEl.textContent = message; this.containerEl.classList.add('has-agent-reply'); } private handleError(errorMessage: string) { if (!this.inputEl) return; this.inputEl.disabled = false; this.inputEl.placeholder = errorMessage; this.updatePositionsFromEditor(); this.updateHighlight(); this.attachSelectionListeners(); this.inputEl.focus(); } private showDiffInPlace() { if (this.editedText === null) return; hideSelectionHighlight(this.editorView); const diffOps = computeDiff(this.selectedText, this.editedText); const diffHtml = diffToHtml(diffOps); this.editorView.dispatch({ effects: showDiff.of({ from: this.selFrom, to: this.selTo, diffHtml, widget: this, }), }); this.installAcceptRejectHandler(); } private showInsertionInPlace() { if (this.insertedText === null) return; hideSelectionHighlight(this.editorView); const trimmedText = normalizeInsertionText(this.insertedText); this.insertedText = trimmedText; const escaped = escapeHtml(trimmedText); const diffHtml = `${escaped}`; this.editorView.dispatch({ effects: showInsertion.of({ pos: this.selFrom, diffHtml, widget: this, }), }); this.installAcceptRejectHandler(); } private installAcceptRejectHandler() { if (this.escHandler) { document.removeEventListener('keydown', this.escHandler); } this.escHandler = (e: KeyboardEvent) => { if (e.key === 'Escape' && !e.isComposing) { this.reject(); } else if (e.key === 'Enter' && !e.isComposing) { this.accept(); } }; document.addEventListener('keydown', this.escHandler); } accept() { const textToInsert = this.editedText ?? this.insertedText; if (textToInsert !== null) { // Convert CM6 positions back to Obsidian Editor positions const doc = this.editorView.state.doc; const fromLine = doc.lineAt(this.selFrom); const toLine = doc.lineAt(this.selTo); const from = { line: fromLine.number - 1, ch: this.selFrom - fromLine.from }; const to = { line: toLine.number - 1, ch: this.selTo - toLine.from }; this.cleanup(); this.editor.replaceRange(textToInsert, from, to); this.resolve({ decision: 'accept', editedText: textToInsert }); } else { this.cleanup(); this.resolve({ decision: 'reject' }); } } reject() { this.cleanup({ keepSelectionHighlight: true }); this.restoreSelectionHighlight(); this.resolve({ decision: 'reject' }); } private removeSelectionListeners() { if (this.selectionListener) { this.editorView.dom.removeEventListener('mouseup', this.selectionListener); this.editorView.dom.removeEventListener('keyup', this.selectionListener); this.selectionListener = null; } } private cleanup(options?: { keepSelectionHighlight?: boolean }) { this.inlineEditService.cancel(); this.inlineEditService.resetConversation(); this.isConversing = false; this.removeSelectionListeners(); if (this.escHandler) { document.removeEventListener('keydown', this.escHandler); } this.slashCommandDropdown?.destroy(); this.slashCommandDropdown = null; this.mentionDropdown?.destroy(); this.mentionDropdown = null; if (activeController === this) { activeController = null; } this.editorView.dispatch({ effects: hideInlineEdit.of(null), }); if (!options?.keepSelectionHighlight) { hideSelectionHighlight(this.editorView); } } private restoreSelectionHighlight(): void { if (this.mode !== 'selection' || this.selFrom === this.selTo) { return; } showSelectionHighlight(this.editorView, this.selFrom, this.selTo); } private handleKeydown(e: KeyboardEvent) { if (this.mentionDropdown?.handleKeydown(e)) { return; } if (this.slashCommandDropdown?.handleKeydown(e)) { return; } if (e.key === 'Enter' && !e.isComposing) { e.preventDefault(); this.generate(); } } private normalizePathForVault(rawPath: string | undefined | null): string | null { try { const vaultPath = getVaultPath(this.app); return normalizePathForVaultUtil(rawPath, vaultPath); } catch { new Notice('Failed to attach file: invalid path'); return null; } } private resolveContextFilesFromMessage(message: string): string[] { if (!message.includes('@')) return []; const vaultFiles = this.mentionDataProvider.getCachedVaultFiles(); const pathLookup = new Map(); for (const file of vaultFiles) { const normalized = this.normalizePathForVault(file.path); if (!normalized) continue; const lookupKey = normalizeForPlatformLookup(normalizeMentionPath(normalized)); if (!pathLookup.has(lookupKey)) { pathLookup.set(lookupKey, normalized); } } const resolved = new Set(); const externalEntries = buildExternalContextDisplayEntries(this.getExternalContexts()) .sort((a, b) => b.displayNameLower.length - a.displayNameLower.length); const getExternalLookup = createExternalContextLookupGetter( contextRoot => externalContextScanner.scanPaths([contextRoot]) ); for (let index = 0; index < message.length; index++) { if (!isMentionStart(message, index)) continue; const externalMatch = resolveExternalMentionAtIndex( message, index, externalEntries, getExternalLookup ); if (externalMatch) { resolved.add(externalMatch.resolvedPath); index = externalMatch.endIndex - 1; continue; } const vaultMatch = findBestMentionLookupMatch( message, index + 1, pathLookup, normalizeMentionPath, normalizeForPlatformLookup ); if (vaultMatch) { resolved.add(vaultMatch.resolvedPath); index = vaultMatch.endIndex - 1; } } return [...resolved]; } } ================================================ FILE: src/features/settings/ClaudianSettings.ts ================================================ import * as fs from 'fs'; import type { App } from 'obsidian'; import { Notice, PluginSettingTab, Setting } from 'obsidian'; import { getCurrentPlatformKey, getHostnameKey } from '../../core/types'; import { DEFAULT_CLAUDE_MODELS, filterVisibleModelOptions } from '../../core/types/models'; import { getAvailableLocales, getLocaleDisplayName, setLocale, t } from '../../i18n'; import type { Locale, TranslationKey } from '../../i18n/types'; import type ClaudianPlugin from '../../main'; import { findNodeExecutable, formatContextLimit, getCustomModelIds, getEnhancedPath, getModelsFromEnvironment, parseContextLimit, parseEnvironmentVariables } from '../../utils/env'; import { expandHomePath } from '../../utils/path'; import { ClaudianView } from '../chat/ClaudianView'; import { buildNavMappingText, parseNavMappings } from './keyboardNavigation'; import { AgentSettings } from './ui/AgentSettings'; import { EnvSnippetManager } from './ui/EnvSnippetManager'; import { McpSettingsManager } from './ui/McpSettingsManager'; import { PluginSettingsManager } from './ui/PluginSettingsManager'; import { SlashCommandSettings } from './ui/SlashCommandSettings'; function formatHotkey(hotkey: { modifiers: string[]; key: string }): string { const isMac = navigator.platform.includes('Mac'); const modMap: Record = isMac ? { Mod: '⌘', Ctrl: '⌃', Alt: '⌥', Shift: '⇧', Meta: '⌘' } : { Mod: 'Ctrl', Ctrl: 'Ctrl', Alt: 'Alt', Shift: 'Shift', Meta: 'Win' }; const mods = hotkey.modifiers.map((m) => modMap[m] || m); const key = hotkey.key.length === 1 ? hotkey.key.toUpperCase() : hotkey.key; return isMac ? [...mods, key].join('') : [...mods, key].join('+'); } function openHotkeySettings(app: App): void { const setting = (app as any).setting; setting.open(); setting.openTabById('hotkeys'); setTimeout(() => { const tab = setting.activeTab; if (tab) { // Handle both old and new Obsidian versions const searchEl = tab.searchInputEl ?? tab.searchComponent?.inputEl; if (searchEl) { searchEl.value = 'Claudian'; tab.updateHotkeyVisibility?.(); } } }, 100); } function getHotkeyForCommand(app: App, commandId: string): string | null { const hotkeyManager = (app as any).hotkeyManager; if (!hotkeyManager) return null; const customHotkeys = hotkeyManager.customKeys?.[commandId]; const defaultHotkeys = hotkeyManager.defaultKeys?.[commandId]; const hotkeys = customHotkeys?.length > 0 ? customHotkeys : defaultHotkeys; if (!hotkeys || hotkeys.length === 0) return null; return hotkeys.map(formatHotkey).join(', '); } function addHotkeySettingRow( containerEl: HTMLElement, app: App, commandId: string, translationPrefix: string ): void { const hotkey = getHotkeyForCommand(app, commandId); const item = containerEl.createDiv({ cls: 'claudian-hotkey-item' }); item.createSpan({ cls: 'claudian-hotkey-name', text: t(`${translationPrefix}.name` as TranslationKey) }); if (hotkey) { item.createSpan({ cls: 'claudian-hotkey-badge', text: hotkey }); } item.addEventListener('click', () => openHotkeySettings(app)); } export class ClaudianSettingTab extends PluginSettingTab { plugin: ClaudianPlugin; private contextLimitsContainer: HTMLElement | null = null; constructor(app: App, plugin: ClaudianPlugin) { super(app, plugin); this.plugin = plugin; } private normalizeModelVariantSettings(): void { this.plugin.normalizeModelVariantSettings(); } display(): void { const { containerEl } = this; containerEl.empty(); containerEl.addClass('claudian-settings'); setLocale(this.plugin.settings.locale); new Setting(containerEl) .setName(t('settings.language.name')) .setDesc(t('settings.language.desc')) .addDropdown((dropdown) => { const locales = getAvailableLocales(); for (const locale of locales) { dropdown.addOption(locale, getLocaleDisplayName(locale)); } dropdown .setValue(this.plugin.settings.locale) .onChange(async (value: Locale) => { if (!setLocale(value)) { // Invalid locale - reset dropdown to current value dropdown.setValue(this.plugin.settings.locale); return; } this.plugin.settings.locale = value; await this.plugin.saveSettings(); // Re-render the entire settings page with new language this.display(); }); }); new Setting(containerEl).setName(t('settings.customization')).setHeading(); new Setting(containerEl) .setName(t('settings.userName.name')) .setDesc(t('settings.userName.desc')) .addText((text) => { text .setPlaceholder(t('settings.userName.name')) .setValue(this.plugin.settings.userName) .onChange(async (value) => { this.plugin.settings.userName = value; await this.plugin.saveSettings(); }); text.inputEl.addEventListener('blur', () => this.restartServiceForPromptChange()); }); new Setting(containerEl) .setName(t('settings.excludedTags.name')) .setDesc(t('settings.excludedTags.desc')) .addTextArea((text) => { text .setPlaceholder('system\nprivate\ndraft') .setValue(this.plugin.settings.excludedTags.join('\n')) .onChange(async (value) => { this.plugin.settings.excludedTags = value .split(/\r?\n/) .map((s) => s.trim().replace(/^#/, '')) .filter((s) => s.length > 0); await this.plugin.saveSettings(); }); text.inputEl.rows = 4; text.inputEl.cols = 30; }); new Setting(containerEl) .setName(t('settings.mediaFolder.name')) .setDesc(t('settings.mediaFolder.desc')) .addText((text) => { text .setPlaceholder('attachments') .setValue(this.plugin.settings.mediaFolder) .onChange(async (value) => { this.plugin.settings.mediaFolder = value.trim(); await this.plugin.saveSettings(); }); text.inputEl.addClass('claudian-settings-media-input'); text.inputEl.addEventListener('blur', () => this.restartServiceForPromptChange()); }); new Setting(containerEl) .setName(t('settings.systemPrompt.name')) .setDesc(t('settings.systemPrompt.desc')) .addTextArea((text) => { text .setPlaceholder(t('settings.systemPrompt.name')) .setValue(this.plugin.settings.systemPrompt) .onChange(async (value) => { this.plugin.settings.systemPrompt = value; await this.plugin.saveSettings(); }); text.inputEl.rows = 6; text.inputEl.cols = 50; text.inputEl.addEventListener('blur', () => this.restartServiceForPromptChange()); }); new Setting(containerEl) .setName(t('settings.enableAutoScroll.name')) .setDesc(t('settings.enableAutoScroll.desc')) .addToggle((toggle) => toggle .setValue(this.plugin.settings.enableAutoScroll ?? true) .onChange(async (value) => { this.plugin.settings.enableAutoScroll = value; await this.plugin.saveSettings(); }) ); new Setting(containerEl) .setName(t('settings.autoTitle.name')) .setDesc(t('settings.autoTitle.desc')) .addToggle((toggle) => toggle .setValue(this.plugin.settings.enableAutoTitleGeneration) .onChange(async (value) => { this.plugin.settings.enableAutoTitleGeneration = value; await this.plugin.saveSettings(); this.display(); }) ); if (this.plugin.settings.enableAutoTitleGeneration) { new Setting(containerEl) .setName(t('settings.titleModel.name')) .setDesc(t('settings.titleModel.desc')) .addDropdown((dropdown) => { // Add "Auto" option (empty string = use default logic) dropdown.addOption('', t('settings.titleModel.auto')); // Get available models from environment or defaults const envVars = parseEnvironmentVariables(this.plugin.settings.environmentVariables); const customModels = getModelsFromEnvironment(envVars); const models = filterVisibleModelOptions( customModels.length > 0 ? customModels : [...DEFAULT_CLAUDE_MODELS], this.plugin.settings.enableOpus1M, this.plugin.settings.enableSonnet1M ); for (const model of models) { dropdown.addOption(model.value, model.label); } dropdown .setValue(this.plugin.settings.titleGenerationModel || '') .onChange(async (value) => { this.plugin.settings.titleGenerationModel = value; await this.plugin.saveSettings(); }); }); } new Setting(containerEl) .setName(t('settings.navMappings.name')) .setDesc(t('settings.navMappings.desc')) .addTextArea((text) => { let pendingValue = buildNavMappingText(this.plugin.settings.keyboardNavigation); let saveTimeout: number | null = null; const commitValue = async (showError: boolean): Promise => { if (saveTimeout !== null) { window.clearTimeout(saveTimeout); saveTimeout = null; } const result = parseNavMappings(pendingValue); if (!result.settings) { if (showError) { new Notice(`${t('common.error')}: ${result.error}`); pendingValue = buildNavMappingText(this.plugin.settings.keyboardNavigation); text.setValue(pendingValue); } return; } this.plugin.settings.keyboardNavigation.scrollUpKey = result.settings.scrollUp; this.plugin.settings.keyboardNavigation.scrollDownKey = result.settings.scrollDown; this.plugin.settings.keyboardNavigation.focusInputKey = result.settings.focusInput; await this.plugin.saveSettings(); pendingValue = buildNavMappingText(this.plugin.settings.keyboardNavigation); text.setValue(pendingValue); }; const scheduleSave = (): void => { if (saveTimeout !== null) { window.clearTimeout(saveTimeout); } saveTimeout = window.setTimeout(() => { void commitValue(false); }, 500); }; text .setPlaceholder('map w scrollUp\nmap s scrollDown\nmap i focusInput') .setValue(pendingValue) .onChange((value) => { pendingValue = value; scheduleSave(); }); text.inputEl.rows = 3; text.inputEl.addEventListener('blur', async () => { await commitValue(true); }); }); // Tab bar position setting new Setting(containerEl) .setName(t('settings.tabBarPosition.name')) .setDesc(t('settings.tabBarPosition.desc')) .addDropdown((dropdown) => { dropdown .addOption('input', t('settings.tabBarPosition.input')) .addOption('header', t('settings.tabBarPosition.header')) .setValue(this.plugin.settings.tabBarPosition ?? 'input') .onChange(async (value: 'input' | 'header') => { this.plugin.settings.tabBarPosition = value; await this.plugin.saveSettings(); // Update all views' layouts immediately for (const leaf of this.plugin.app.workspace.getLeavesOfType('claudian-view')) { if (leaf.view instanceof ClaudianView) { leaf.view.updateLayoutForPosition(); } } }); }); // Open in main tab setting new Setting(containerEl) .setName(t('settings.openInMainTab.name')) .setDesc(t('settings.openInMainTab.desc')) .addToggle((toggle) => toggle .setValue(this.plugin.settings.openInMainTab) .onChange(async (value) => { this.plugin.settings.openInMainTab = value; await this.plugin.saveSettings(); }) ); new Setting(containerEl).setName(t('settings.hotkeys')).setHeading(); const hotkeyGrid = containerEl.createDiv({ cls: 'claudian-hotkey-grid' }); addHotkeySettingRow(hotkeyGrid, this.app, 'claudian:inline-edit', 'settings.inlineEditHotkey'); addHotkeySettingRow(hotkeyGrid, this.app, 'claudian:open-view', 'settings.openChatHotkey'); addHotkeySettingRow(hotkeyGrid, this.app, 'claudian:new-session', 'settings.newSessionHotkey'); addHotkeySettingRow(hotkeyGrid, this.app, 'claudian:new-tab', 'settings.newTabHotkey'); addHotkeySettingRow(hotkeyGrid, this.app, 'claudian:close-current-tab', 'settings.closeTabHotkey'); new Setting(containerEl).setName(t('settings.slashCommands.name')).setHeading(); const slashCommandsDesc = containerEl.createDiv({ cls: 'claudian-sp-settings-desc' }); const descP = slashCommandsDesc.createEl('p', { cls: 'setting-item-description' }); descP.appendText(t('settings.slashCommands.desc') + ' '); descP.createEl('a', { text: 'Learn more', href: 'https://code.claude.com/docs/en/skills', }); const slashCommandsContainer = containerEl.createDiv({ cls: 'claudian-slash-commands-container' }); new SlashCommandSettings(slashCommandsContainer, this.plugin); new Setting(containerEl) .setName(t('settings.hiddenSlashCommands.name')) .setDesc(t('settings.hiddenSlashCommands.desc')) .addTextArea((text) => { text .setPlaceholder(t('settings.hiddenSlashCommands.placeholder')) .setValue((this.plugin.settings.hiddenSlashCommands || []).join('\n')) .onChange(async (value) => { this.plugin.settings.hiddenSlashCommands = value .split(/\r?\n/) .map((s) => s.trim().replace(/^\//, '')) .filter((s) => s.length > 0); await this.plugin.saveSettings(); this.plugin.getView()?.updateHiddenSlashCommands(); }); text.inputEl.rows = 4; text.inputEl.cols = 30; }); new Setting(containerEl).setName(t('settings.subagents.name')).setHeading(); const agentsDesc = containerEl.createDiv({ cls: 'claudian-sp-settings-desc' }); agentsDesc.createEl('p', { text: t('settings.subagents.desc'), cls: 'setting-item-description', }); const agentsContainer = containerEl.createDiv({ cls: 'claudian-agents-container' }); new AgentSettings(agentsContainer, this.plugin); new Setting(containerEl).setName(t('settings.mcpServers.name')).setHeading(); const mcpDesc = containerEl.createDiv({ cls: 'claudian-mcp-settings-desc' }); mcpDesc.createEl('p', { text: t('settings.mcpServers.desc'), cls: 'setting-item-description', }); const mcpContainer = containerEl.createDiv({ cls: 'claudian-mcp-container' }); new McpSettingsManager(mcpContainer, this.plugin); new Setting(containerEl).setName(t('settings.plugins.name')).setHeading(); const pluginsDesc = containerEl.createDiv({ cls: 'claudian-plugin-settings-desc' }); pluginsDesc.createEl('p', { text: t('settings.plugins.desc'), cls: 'setting-item-description', }); const pluginsContainer = containerEl.createDiv({ cls: 'claudian-plugins-container' }); new PluginSettingsManager(pluginsContainer, this.plugin); new Setting(containerEl).setName(t('settings.safety')).setHeading(); new Setting(containerEl) .setName(t('settings.loadUserSettings.name')) .setDesc(t('settings.loadUserSettings.desc')) .addToggle((toggle) => toggle .setValue(this.plugin.settings.loadUserClaudeSettings) .onChange(async (value) => { this.plugin.settings.loadUserClaudeSettings = value; await this.plugin.saveSettings(); }) ); new Setting(containerEl) .setName(t('settings.enableBlocklist.name')) .setDesc(t('settings.enableBlocklist.desc')) .addToggle((toggle) => toggle .setValue(this.plugin.settings.enableBlocklist) .onChange(async (value) => { this.plugin.settings.enableBlocklist = value; await this.plugin.saveSettings(); }) ); new Setting(containerEl) .setName(t('settings.allowExternalAccess.name')) .setDesc(t('settings.allowExternalAccess.desc')) .addToggle((toggle) => toggle .setValue(this.plugin.settings.allowExternalAccess) .onChange(async (value) => { this.plugin.settings.allowExternalAccess = value; await this.plugin.saveSettings(); this.display(); await this.restartServiceForPromptChange(); }) ); const platformKey = getCurrentPlatformKey(); const isWindows = platformKey === 'windows'; const platformLabel = isWindows ? 'Windows' : 'Unix'; new Setting(containerEl) .setName(t('settings.blockedCommands.name', { platform: platformLabel })) .setDesc(t('settings.blockedCommands.desc', { platform: platformLabel })) .addTextArea((text) => { const placeholder = isWindows ? 'del /s /q\nrd /s /q\nRemove-Item -Recurse -Force' : 'rm -rf\nchmod 777\nmkfs'; text .setPlaceholder(placeholder) .setValue(this.plugin.settings.blockedCommands[platformKey].join('\n')) .onChange(async (value) => { this.plugin.settings.blockedCommands[platformKey] = value .split(/\r?\n/) .map((s) => s.trim()) .filter((s) => s.length > 0); await this.plugin.saveSettings(); }); text.inputEl.rows = 6; text.inputEl.cols = 40; }); // On Windows, show Unix blocklist too since Git Bash can run Unix commands if (isWindows) { new Setting(containerEl) .setName(t('settings.blockedCommands.unixName')) .setDesc(t('settings.blockedCommands.unixDesc')) .addTextArea((text) => { text .setPlaceholder('rm -rf\nchmod 777\nmkfs') .setValue(this.plugin.settings.blockedCommands.unix.join('\n')) .onChange(async (value) => { this.plugin.settings.blockedCommands.unix = value .split(/\r?\n/) .map((s) => s.trim()) .filter((s) => s.length > 0); await this.plugin.saveSettings(); }); text.inputEl.rows = 4; text.inputEl.cols = 40; }); } new Setting(containerEl) .setName(t('settings.exportPaths.name')) .setDesc( this.plugin.settings.allowExternalAccess ? t('settings.exportPaths.disabledDesc') : t('settings.exportPaths.desc') ) .addTextArea((text) => { const placeholder = process.platform === 'win32' ? '~/Desktop\n~/Downloads\n%TEMP%' : '~/Desktop\n~/Downloads\n/tmp'; text .setPlaceholder(placeholder) .setValue(this.plugin.settings.allowedExportPaths.join('\n')) .setDisabled(this.plugin.settings.allowExternalAccess) .onChange(async (value) => { this.plugin.settings.allowedExportPaths = value .split(/\r?\n/) .map((s) => s.trim()) .filter((s) => s.length > 0); await this.plugin.saveSettings(); }); text.inputEl.rows = 4; text.inputEl.cols = 40; text.inputEl.addEventListener('blur', () => this.restartServiceForPromptChange()); }); new Setting(containerEl).setName(t('settings.environment')).setHeading(); new Setting(containerEl) .setName(t('settings.customVariables.name')) .setDesc(t('settings.customVariables.desc')) .addTextArea((text) => { text .setPlaceholder('ANTHROPIC_API_KEY=your-key\nANTHROPIC_BASE_URL=https://api.example.com\nANTHROPIC_MODEL=custom-model') .setValue(this.plugin.settings.environmentVariables); text.inputEl.rows = 6; text.inputEl.cols = 50; text.inputEl.addClass('claudian-settings-env-textarea'); text.inputEl.addEventListener('blur', async () => { await this.plugin.applyEnvironmentVariables(text.inputEl.value); this.renderContextLimitsSection(); }); }); this.contextLimitsContainer = containerEl.createDiv({ cls: 'claudian-context-limits-container' }); this.renderContextLimitsSection(); const envSnippetsContainer = containerEl.createDiv({ cls: 'claudian-env-snippets-container' }); new EnvSnippetManager(envSnippetsContainer, this.plugin, () => { this.renderContextLimitsSection(); }); new Setting(containerEl).setName(t('settings.advanced')).setHeading(); new Setting(containerEl) .setName(t('settings.enableOpus1M.name')) .setDesc(t('settings.enableOpus1M.desc')) .addToggle((toggle) => toggle .setValue(this.plugin.settings.enableOpus1M ?? false) .onChange(async (value) => { this.plugin.settings.enableOpus1M = value; this.normalizeModelVariantSettings(); await this.plugin.saveSettings(); for (const view of this.plugin.getAllViews()) { view.refreshModelSelector(); } this.display(); }) ); new Setting(containerEl) .setName(t('settings.enableSonnet1M.name')) .setDesc(t('settings.enableSonnet1M.desc')) .addToggle((toggle) => toggle .setValue(this.plugin.settings.enableSonnet1M ?? false) .onChange(async (value) => { this.plugin.settings.enableSonnet1M = value; this.normalizeModelVariantSettings(); await this.plugin.saveSettings(); for (const view of this.plugin.getAllViews()) { view.refreshModelSelector(); } this.display(); }) ); new Setting(containerEl) .setName(t('settings.enableChrome.name')) .setDesc(t('settings.enableChrome.desc')) .addToggle((toggle) => toggle .setValue(this.plugin.settings.enableChrome ?? false) .onChange(async (value) => { this.plugin.settings.enableChrome = value; await this.plugin.saveSettings(); }) ); new Setting(containerEl) .setName(t('settings.enableBangBash.name')) .setDesc(t('settings.enableBangBash.desc')) .addToggle((toggle) => toggle .setValue(this.plugin.settings.enableBangBash ?? false) .onChange(async (value) => { bangBashValidationEl.style.display = 'none'; if (value) { const enhancedPath = getEnhancedPath(); const nodePath = findNodeExecutable(enhancedPath); if (!nodePath) { bangBashValidationEl.setText(t('settings.enableBangBash.validation.noNode')); bangBashValidationEl.style.display = 'block'; toggle.setValue(false); return; } } this.plugin.settings.enableBangBash = value; await this.plugin.saveSettings(); }) ); const bangBashValidationEl = containerEl.createDiv({ cls: 'claudian-bang-bash-validation' }); bangBashValidationEl.style.color = 'var(--text-error)'; bangBashValidationEl.style.fontSize = '0.85em'; bangBashValidationEl.style.marginTop = '-0.5em'; bangBashValidationEl.style.marginBottom = '0.5em'; bangBashValidationEl.style.display = 'none'; const maxTabsSetting = new Setting(containerEl) .setName(t('settings.maxTabs.name')) .setDesc(t('settings.maxTabs.desc')); const maxTabsWarningEl = containerEl.createDiv({ cls: 'claudian-max-tabs-warning' }); maxTabsWarningEl.style.color = 'var(--text-warning)'; maxTabsWarningEl.style.fontSize = '0.85em'; maxTabsWarningEl.style.marginTop = '-0.5em'; maxTabsWarningEl.style.marginBottom = '0.5em'; maxTabsWarningEl.style.display = 'none'; maxTabsWarningEl.setText(t('settings.maxTabs.warning')); const updateMaxTabsWarning = (value: number): void => { maxTabsWarningEl.style.display = value > 5 ? 'block' : 'none'; }; maxTabsSetting.addSlider((slider) => { slider .setLimits(3, 10, 1) .setValue(this.plugin.settings.maxTabs ?? 3) .setDynamicTooltip() .onChange(async (value) => { this.plugin.settings.maxTabs = value; await this.plugin.saveSettings(); updateMaxTabsWarning(value); }); updateMaxTabsWarning(this.plugin.settings.maxTabs ?? 3); }); const hostnameKey = getHostnameKey(); const platformDesc = process.platform === 'win32' ? t('settings.cliPath.descWindows') : t('settings.cliPath.descUnix'); const cliPathDescription = `${t('settings.cliPath.desc')} ${platformDesc}`; const cliPathSetting = new Setting(containerEl) .setName(`${t('settings.cliPath.name')} (${hostnameKey})`) .setDesc(cliPathDescription); const validationEl = containerEl.createDiv({ cls: 'claudian-cli-path-validation' }); validationEl.style.color = 'var(--text-error)'; validationEl.style.fontSize = '0.85em'; validationEl.style.marginTop = '-0.5em'; validationEl.style.marginBottom = '0.5em'; validationEl.style.display = 'none'; const validatePath = (value: string): string | null => { const trimmed = value.trim(); if (!trimmed) return null; // Empty is valid (auto-detect) const expandedPath = expandHomePath(trimmed); if (!fs.existsSync(expandedPath)) { return t('settings.cliPath.validation.notExist'); } const stat = fs.statSync(expandedPath); if (!stat.isFile()) { return t('settings.cliPath.validation.isDirectory'); } return null; }; cliPathSetting.addText((text) => { const placeholder = process.platform === 'win32' ? 'D:\\nodejs\\node_global\\node_modules\\@anthropic-ai\\claude-code\\cli.js' : '/usr/local/lib/node_modules/@anthropic-ai/claude-code/cli.js'; const currentValue = this.plugin.settings.claudeCliPathsByHost?.[hostnameKey] || ''; text .setPlaceholder(placeholder) .setValue(currentValue) .onChange(async (value) => { const error = validatePath(value); if (error) { validationEl.setText(error); validationEl.style.display = 'block'; text.inputEl.style.borderColor = 'var(--text-error)'; } else { validationEl.style.display = 'none'; text.inputEl.style.borderColor = ''; } const trimmed = value.trim(); if (!this.plugin.settings.claudeCliPathsByHost) { this.plugin.settings.claudeCliPathsByHost = {}; } this.plugin.settings.claudeCliPathsByHost[hostnameKey] = trimmed; await this.plugin.saveSettings(); this.plugin.cliResolver?.reset(); const view = this.plugin.getView(); await view?.getTabManager()?.broadcastToAllTabs( (service) => Promise.resolve(service.cleanup()) ); }); text.inputEl.addClass('claudian-settings-cli-path-input'); text.inputEl.style.width = '100%'; const initialError = validatePath(currentValue); if (initialError) { validationEl.setText(initialError); validationEl.style.display = 'block'; text.inputEl.style.borderColor = 'var(--text-error)'; } }); } private renderContextLimitsSection(): void { const container = this.contextLimitsContainer; if (!container) return; container.empty(); const envVars = parseEnvironmentVariables(this.plugin.settings.environmentVariables); const uniqueModelIds = getCustomModelIds(envVars); if (uniqueModelIds.size === 0) { return; } const headerEl = container.createDiv({ cls: 'claudian-context-limits-header' }); headerEl.createSpan({ text: t('settings.customContextLimits.name'), cls: 'claudian-context-limits-label' }); const descEl = container.createDiv({ cls: 'claudian-context-limits-desc' }); descEl.setText(t('settings.customContextLimits.desc')); const listEl = container.createDiv({ cls: 'claudian-context-limits-list' }); for (const modelId of uniqueModelIds) { const currentValue = this.plugin.settings.customContextLimits?.[modelId]; const itemEl = listEl.createDiv({ cls: 'claudian-context-limits-item' }); const nameEl = itemEl.createDiv({ cls: 'claudian-context-limits-model' }); nameEl.setText(modelId); const inputWrapper = itemEl.createDiv({ cls: 'claudian-context-limits-input-wrapper' }); const inputEl = inputWrapper.createEl('input', { type: 'text', placeholder: '200k', cls: 'claudian-context-limits-input', value: currentValue ? formatContextLimit(currentValue) : '', }); // Validation element const validationEl = inputWrapper.createDiv({ cls: 'claudian-context-limit-validation' }); inputEl.addEventListener('input', async () => { const trimmed = inputEl.value.trim(); if (!this.plugin.settings.customContextLimits) { this.plugin.settings.customContextLimits = {}; } if (!trimmed) { // Empty = use default (remove from custom limits) delete this.plugin.settings.customContextLimits[modelId]; validationEl.style.display = 'none'; inputEl.classList.remove('claudian-input-error'); } else { const parsed = parseContextLimit(trimmed); if (parsed === null) { validationEl.setText(t('settings.customContextLimits.invalid')); validationEl.style.display = 'block'; inputEl.classList.add('claudian-input-error'); return; // Don't save invalid value } this.plugin.settings.customContextLimits[modelId] = parsed; validationEl.style.display = 'none'; inputEl.classList.remove('claudian-input-error'); } await this.plugin.saveSettings(); }); } } private async restartServiceForPromptChange(): Promise { const view = this.plugin.getView(); const tabManager = view?.getTabManager(); if (!tabManager) return; try { await tabManager.broadcastToAllTabs( async (service) => { await service.ensureReady({ force: true }); } ); } catch { // Silently ignore restart failures - changes will apply on next conversation } } } ================================================ FILE: src/features/settings/keyboardNavigation.ts ================================================ import type { KeyboardNavigationSettings } from '@/core/types/settings'; const NAV_ACTIONS = ['scrollUp', 'scrollDown', 'focusInput'] as const; type NavAction = (typeof NAV_ACTIONS)[number]; export const buildNavMappingText = (settings: KeyboardNavigationSettings): string => { return [ `map ${settings.scrollUpKey} scrollUp`, `map ${settings.scrollDownKey} scrollDown`, `map ${settings.focusInputKey} focusInput`, ].join('\n'); }; export const parseNavMappings = ( value: string ): { settings?: Record; error?: string } => { const parsed: Partial> = {}; const usedKeys = new Map(); const lines = value.split('\n'); for (const rawLine of lines) { const line = rawLine.trim(); if (!line) continue; const parts = line.split(/\s+/); if (parts.length !== 3 || parts[0] !== 'map') { return { error: 'Each line must follow "map "' }; } const key = parts[1]; const action = parts[2] as NavAction; if (!NAV_ACTIONS.includes(action)) { return { error: `Unknown action: ${parts[2]}` }; } if (key.length !== 1) { return { error: `Key must be a single character for ${action}` }; } const normalizedKey = key.toLowerCase(); if (usedKeys.has(normalizedKey)) { return { error: 'Navigation keys must be unique' }; } if (parsed[action]) { return { error: `Duplicate mapping for ${action}` }; } usedKeys.set(normalizedKey, action); parsed[action] = key; } const missing = NAV_ACTIONS.filter((action) => !parsed[action]); if (missing.length > 0) { return { error: `Missing mapping for ${missing.join(', ')}` }; } return { settings: parsed as Record }; }; ================================================ FILE: src/features/settings/ui/AgentSettings.ts ================================================ import type { App } from 'obsidian'; import { Modal, Notice, setIcon, Setting } from 'obsidian'; import type { AgentDefinition } from '../../../core/types'; import { t } from '../../../i18n'; import type ClaudianPlugin from '../../../main'; import { confirmDelete } from '../../../shared/modals/ConfirmModal'; import { validateAgentName } from '../../../utils/agent'; const MODEL_OPTIONS = [ { value: 'inherit', label: 'Inherit' }, { value: 'sonnet', label: 'Sonnet' }, { value: 'opus', label: 'Opus' }, { value: 'haiku', label: 'Haiku' }, ] as const; class AgentModal extends Modal { private plugin: ClaudianPlugin; private existingAgent: AgentDefinition | null; private onSave: (agent: AgentDefinition) => Promise; constructor( app: App, plugin: ClaudianPlugin, existingAgent: AgentDefinition | null, onSave: (agent: AgentDefinition) => Promise ) { super(app); this.plugin = plugin; this.existingAgent = existingAgent; this.onSave = onSave; } onOpen() { this.setTitle( this.existingAgent ? t('settings.subagents.modal.titleEdit') : t('settings.subagents.modal.titleAdd') ); this.modalEl.addClass('claudian-sp-modal'); const { contentEl } = this; let nameInput: HTMLInputElement; let descInput: HTMLInputElement; let modelValue: string = this.existingAgent?.model ?? 'inherit'; let toolsInput: HTMLInputElement; let disallowedToolsInput: HTMLInputElement; let skillsInput: HTMLInputElement; new Setting(contentEl) .setName(t('settings.subagents.modal.name')) .setDesc(t('settings.subagents.modal.nameDesc')) .addText(text => { nameInput = text.inputEl; text.setValue(this.existingAgent?.name || '') .setPlaceholder(t('settings.subagents.modal.namePlaceholder')); }); new Setting(contentEl) .setName(t('settings.subagents.modal.description')) .setDesc(t('settings.subagents.modal.descriptionDesc')) .addText(text => { descInput = text.inputEl; text.setValue(this.existingAgent?.description || '') .setPlaceholder(t('settings.subagents.modal.descriptionPlaceholder')); }); const details = contentEl.createEl('details', { cls: 'claudian-sp-advanced-section' }); details.createEl('summary', { text: t('settings.subagents.modal.advancedOptions'), cls: 'claudian-sp-advanced-summary', }); if ((this.existingAgent?.model && this.existingAgent.model !== 'inherit') || this.existingAgent?.tools?.length || this.existingAgent?.disallowedTools?.length || this.existingAgent?.skills?.length) { details.open = true; } new Setting(details) .setName(t('settings.subagents.modal.model')) .setDesc(t('settings.subagents.modal.modelDesc')) .addDropdown(dropdown => { for (const opt of MODEL_OPTIONS) { dropdown.addOption(opt.value, opt.label); } dropdown .setValue(modelValue) .onChange(value => { modelValue = value; }); }); new Setting(details) .setName(t('settings.subagents.modal.tools')) .setDesc(t('settings.subagents.modal.toolsDesc')) .addText(text => { toolsInput = text.inputEl; text.setValue(this.existingAgent?.tools?.join(', ') || ''); }); new Setting(details) .setName(t('settings.subagents.modal.disallowedTools')) .setDesc(t('settings.subagents.modal.disallowedToolsDesc')) .addText(text => { disallowedToolsInput = text.inputEl; text.setValue(this.existingAgent?.disallowedTools?.join(', ') || ''); }); new Setting(details) .setName(t('settings.subagents.modal.skills')) .setDesc(t('settings.subagents.modal.skillsDesc')) .addText(text => { skillsInput = text.inputEl; text.setValue(this.existingAgent?.skills?.join(', ') || ''); }); new Setting(contentEl) .setName(t('settings.subagents.modal.prompt')) .setDesc(t('settings.subagents.modal.promptDesc')); const contentArea = contentEl.createEl('textarea', { cls: 'claudian-sp-content-area', attr: { rows: '10', placeholder: t('settings.subagents.modal.promptPlaceholder'), }, }); contentArea.value = this.existingAgent?.prompt || ''; const buttonContainer = contentEl.createDiv({ cls: 'claudian-sp-modal-buttons' }); const cancelBtn = buttonContainer.createEl('button', { text: t('common.cancel'), cls: 'claudian-cancel-btn', }); cancelBtn.addEventListener('click', () => this.close()); const saveBtn = buttonContainer.createEl('button', { text: t('common.save'), cls: 'claudian-save-btn', }); saveBtn.addEventListener('click', async () => { const name = nameInput.value.trim(); const nameError = validateAgentName(name); if (nameError) { new Notice(nameError); return; } const description = descInput.value.trim(); if (!description) { new Notice(t('settings.subagents.descriptionRequired')); return; } const prompt = contentArea.value; if (!prompt.trim()) { new Notice(t('settings.subagents.promptRequired')); return; } const allAgents = this.plugin.agentManager.getAvailableAgents(); const duplicate = allAgents.find( a => a.id.toLowerCase() === name.toLowerCase() && a.id !== this.existingAgent?.id ); if (duplicate) { new Notice(t('settings.subagents.duplicateName', { name })); return; } const parseList = (input: HTMLInputElement): string[] | undefined => { const val = input.value.trim(); if (!val) return undefined; return val.split(',').map(s => s.trim()).filter(Boolean); }; const agent: AgentDefinition = { id: name, name, description, prompt, tools: parseList(toolsInput), disallowedTools: parseList(disallowedToolsInput), model: (modelValue as AgentDefinition['model']) || 'inherit', source: 'vault', filePath: this.existingAgent?.filePath, skills: parseList(skillsInput), permissionMode: this.existingAgent?.permissionMode, hooks: this.existingAgent?.hooks, extraFrontmatter: this.existingAgent?.extraFrontmatter, }; try { await this.onSave(agent); } catch (err) { const message = err instanceof Error ? err.message : 'Unknown error'; new Notice(t('settings.subagents.saveFailed', { message })); return; } this.close(); }); } onClose() { this.contentEl.empty(); } } export class AgentSettings { private containerEl: HTMLElement; private plugin: ClaudianPlugin; constructor(containerEl: HTMLElement, plugin: ClaudianPlugin) { this.containerEl = containerEl; this.plugin = plugin; this.render(); } private render(): void { this.containerEl.empty(); const headerEl = this.containerEl.createDiv({ cls: 'claudian-sp-header' }); headerEl.createSpan({ text: t('settings.subagents.name'), cls: 'claudian-sp-label' }); const actionsEl = headerEl.createDiv({ cls: 'claudian-sp-header-actions' }); const refreshBtn = actionsEl.createEl('button', { cls: 'claudian-settings-action-btn', attr: { 'aria-label': t('common.refresh') }, }); setIcon(refreshBtn, 'refresh-cw'); refreshBtn.addEventListener('click', () => { void this.refreshAgents(); }); const addBtn = actionsEl.createEl('button', { cls: 'claudian-settings-action-btn', attr: { 'aria-label': t('common.add') }, }); setIcon(addBtn, 'plus'); addBtn.addEventListener('click', () => { void this.openAgentModal(null); }); const allAgents = this.plugin.agentManager.getAvailableAgents(); const vaultAgents = allAgents.filter(a => a.source === 'vault'); if (vaultAgents.length === 0) { const emptyEl = this.containerEl.createDiv({ cls: 'claudian-sp-empty-state' }); emptyEl.setText(t('settings.subagents.noAgents')); return; } const listEl = this.containerEl.createDiv({ cls: 'claudian-sp-list' }); for (const agent of vaultAgents) { this.renderAgentItem(listEl, agent); } } private renderAgentItem(listEl: HTMLElement, agent: AgentDefinition): void { const itemEl = listEl.createDiv({ cls: 'claudian-sp-item' }); const infoEl = itemEl.createDiv({ cls: 'claudian-sp-info' }); const headerRow = infoEl.createDiv({ cls: 'claudian-sp-item-header' }); const nameEl = headerRow.createSpan({ cls: 'claudian-sp-item-name' }); nameEl.setText(agent.name); if (agent.description) { const descEl = infoEl.createDiv({ cls: 'claudian-sp-item-desc' }); descEl.setText(agent.description); } const actionsEl = itemEl.createDiv({ cls: 'claudian-sp-item-actions' }); const editBtn = actionsEl.createEl('button', { cls: 'claudian-settings-action-btn', attr: { 'aria-label': t('common.edit') }, }); setIcon(editBtn, 'pencil'); editBtn.addEventListener('click', () => { void this.openAgentModal(agent); }); const deleteBtn = actionsEl.createEl('button', { cls: 'claudian-settings-action-btn claudian-settings-delete-btn', attr: { 'aria-label': t('common.delete') }, }); setIcon(deleteBtn, 'trash-2'); deleteBtn.addEventListener('click', async () => { const confirmed = await confirmDelete( this.plugin.app, t('settings.subagents.deleteConfirm', { name: agent.name }) ); if (!confirmed) return; try { await this.deleteAgent(agent); } catch (err) { const message = err instanceof Error ? err.message : 'Unknown error'; new Notice(t('settings.subagents.deleteFailed', { message })); } }); } private async refreshAgents(): Promise { try { await this.plugin.agentManager.loadAgents(); this.render(); } catch (err) { const message = err instanceof Error ? err.message : 'Unknown error'; new Notice(t('settings.subagents.refreshFailed', { message })); } } private async openAgentModal(existingAgent: AgentDefinition | null): Promise { let fresh: AgentDefinition | null; if (existingAgent) { try { fresh = await this.plugin.storage.agents.load(existingAgent) ?? existingAgent; } catch (err) { const message = err instanceof Error ? err.message : 'Unknown error'; new Notice(`Failed to load subagent "${existingAgent.name}": ${message}`); return; } } else { fresh = null; } new AgentModal( this.plugin.app, this.plugin, fresh, (agent) => this.saveAgent(agent, fresh) ).open(); } private async saveAgent(agent: AgentDefinition, existing: AgentDefinition | null): Promise { if (existing && existing.name !== agent.name) { // Rename: save to new name-based path, then delete old file await this.plugin.storage.agents.save({ ...agent, filePath: undefined }); try { await this.plugin.storage.agents.delete(existing); } catch { new Notice(t('settings.subagents.renameCleanupFailed', { name: existing.name })); } } else { await this.plugin.storage.agents.save(agent); } try { await this.plugin.agentManager.loadAgents(); } catch { // Non-critical: agent list will refresh on next settings open } this.render(); new Notice( existing ? t('settings.subagents.updated', { name: agent.name }) : t('settings.subagents.created', { name: agent.name }) ); } private async deleteAgent(agent: AgentDefinition): Promise { await this.plugin.storage.agents.delete(agent); try { await this.plugin.agentManager.loadAgents(); } catch { // Non-critical: agent list will refresh on next settings open } this.render(); new Notice(t('settings.subagents.deleted', { name: agent.name })); } } ================================================ FILE: src/features/settings/ui/EnvSnippetManager.ts ================================================ import type { App } from 'obsidian'; import { Modal, Notice, setIcon, Setting } from 'obsidian'; import type { EnvSnippet } from '../../../core/types'; import { t } from '../../../i18n'; import type ClaudianPlugin from '../../../main'; import { formatContextLimit, getCustomModelIds, parseContextLimit, parseEnvironmentVariables } from '../../../utils/env'; import type { ClaudianView } from '../../chat/ClaudianView'; export class EnvSnippetModal extends Modal { plugin: ClaudianPlugin; snippet: EnvSnippet | null; onSave: (snippet: EnvSnippet) => void; constructor(app: App, plugin: ClaudianPlugin, snippet: EnvSnippet | null, onSave: (snippet: EnvSnippet) => void) { super(app); this.plugin = plugin; this.snippet = snippet; this.onSave = onSave; } onOpen() { const { contentEl } = this; this.setTitle(this.snippet ? t('settings.envSnippets.modal.titleEdit') : t('settings.envSnippets.modal.titleSave')); this.modalEl.addClass('claudian-env-snippet-modal'); let nameEl: HTMLInputElement; let descEl: HTMLInputElement; let envVarsEl: HTMLTextAreaElement; const contextLimitInputs: Map = new Map(); let contextLimitsContainer: HTMLElement | null = null; // !e.isComposing for IME support (Chinese, Japanese, Korean, etc.) const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Enter' && !e.isComposing) { e.preventDefault(); saveSnippet(); } else if (e.key === 'Escape' && !e.isComposing) { e.preventDefault(); this.close(); } }; const saveSnippet = () => { const name = nameEl.value.trim(); if (!name) { new Notice(t('settings.envSnippets.nameRequired')); return; } const contextLimits: Record = {}; for (const [modelId, input] of contextLimitInputs) { const value = input.value.trim(); if (value) { const parsed = parseContextLimit(value); if (parsed !== null) { contextLimits[modelId] = parsed; } } } const snippet: EnvSnippet = { id: this.snippet?.id || `snippet-${Date.now()}`, name, description: descEl.value.trim(), envVars: envVarsEl.value, contextLimits: Object.keys(contextLimits).length > 0 ? contextLimits : undefined, }; this.onSave(snippet); this.close(); }; const renderContextLimitFields = () => { if (!contextLimitsContainer) return; contextLimitsContainer.empty(); contextLimitInputs.clear(); const envVars = parseEnvironmentVariables(envVarsEl.value); const uniqueModelIds = getCustomModelIds(envVars); if (uniqueModelIds.size === 0) { contextLimitsContainer.style.display = 'none'; return; } contextLimitsContainer.style.display = 'block'; const existingLimits = this.snippet?.contextLimits ?? this.plugin.settings.customContextLimits ?? {}; contextLimitsContainer.createEl('div', { text: t('settings.customContextLimits.name'), cls: 'setting-item-name', }); contextLimitsContainer.createEl('div', { text: t('settings.customContextLimits.desc'), cls: 'setting-item-description', }); for (const modelId of uniqueModelIds) { const row = contextLimitsContainer.createDiv({ cls: 'claudian-snippet-limit-row' }); row.createSpan({ text: modelId, cls: 'claudian-snippet-limit-model' }); row.createSpan({ cls: 'claudian-snippet-limit-spacer' }); const input = row.createEl('input', { type: 'text', placeholder: '200k', cls: 'claudian-snippet-limit-input', }); input.value = existingLimits[modelId] ? formatContextLimit(existingLimits[modelId]) : ''; contextLimitInputs.set(modelId, input); } }; new Setting(contentEl) .setName(t('settings.envSnippets.modal.name')) .setDesc(t('settings.envSnippets.modal.namePlaceholder')) .addText((text) => { nameEl = text.inputEl; text.setValue(this.snippet?.name || ''); text.inputEl.addEventListener('keydown', handleKeyDown); }); new Setting(contentEl) .setName(t('settings.envSnippets.modal.description')) .setDesc(t('settings.envSnippets.modal.descPlaceholder')) .addText((text) => { descEl = text.inputEl; text.setValue(this.snippet?.description || ''); text.inputEl.addEventListener('keydown', handleKeyDown); }); const envVarsSetting = new Setting(contentEl) .setName(t('settings.envSnippets.modal.envVars')) .setDesc(t('settings.envSnippets.modal.envVarsPlaceholder')) .addTextArea((text) => { envVarsEl = text.inputEl; const envVarsToShow = this.snippet?.envVars ?? this.plugin.settings.environmentVariables; text.setValue(envVarsToShow); text.inputEl.rows = 8; text.inputEl.addEventListener('blur', () => renderContextLimitFields()); }); envVarsSetting.settingEl.addClass('claudian-env-snippet-setting'); envVarsSetting.controlEl.addClass('claudian-env-snippet-control'); contextLimitsContainer = contentEl.createDiv({ cls: 'claudian-snippet-context-limits' }); renderContextLimitFields(); const buttonContainer = contentEl.createDiv({ cls: 'claudian-snippet-buttons' }); const cancelBtn = buttonContainer.createEl('button', { text: t('settings.envSnippets.modal.cancel'), cls: 'claudian-cancel-btn' }); cancelBtn.addEventListener('click', () => this.close()); const saveBtn = buttonContainer.createEl('button', { text: this.snippet ? t('settings.envSnippets.modal.update') : t('settings.envSnippets.modal.save'), cls: 'claudian-save-btn' }); saveBtn.addEventListener('click', () => saveSnippet()); // Focus name input after modal is rendered (timeout for Windows compatibility) setTimeout(() => nameEl?.focus(), 50); } onClose() { const { contentEl } = this; contentEl.empty(); } } export class EnvSnippetManager { private containerEl: HTMLElement; private plugin: ClaudianPlugin; private onContextLimitsChange?: () => void; constructor(containerEl: HTMLElement, plugin: ClaudianPlugin, onContextLimitsChange?: () => void) { this.containerEl = containerEl; this.plugin = plugin; this.onContextLimitsChange = onContextLimitsChange; this.render(); } private render() { this.containerEl.empty(); const headerEl = this.containerEl.createDiv({ cls: 'claudian-snippet-header' }); headerEl.createSpan({ text: t('settings.envSnippets.name'), cls: 'claudian-snippet-label' }); const saveBtn = headerEl.createEl('button', { cls: 'claudian-settings-action-btn', attr: { 'aria-label': t('settings.envSnippets.addBtn') }, }); setIcon(saveBtn, 'plus'); saveBtn.addEventListener('click', () => this.saveCurrentEnv()); const snippets = this.plugin.settings.envSnippets; if (snippets.length === 0) { const emptyEl = this.containerEl.createDiv({ cls: 'claudian-snippet-empty' }); emptyEl.setText(t('settings.envSnippets.noSnippets')); return; } const listEl = this.containerEl.createDiv({ cls: 'claudian-snippet-list' }); for (const snippet of snippets) { const itemEl = listEl.createDiv({ cls: 'claudian-snippet-item' }); const infoEl = itemEl.createDiv({ cls: 'claudian-snippet-info' }); const nameEl = infoEl.createDiv({ cls: 'claudian-snippet-name' }); nameEl.setText(snippet.name); if (snippet.description) { const descEl = infoEl.createDiv({ cls: 'claudian-snippet-description' }); descEl.setText(snippet.description); } const actionsEl = itemEl.createDiv({ cls: 'claudian-snippet-actions' }); const restoreBtn = actionsEl.createEl('button', { cls: 'claudian-settings-action-btn', attr: { 'aria-label': 'Insert' }, }); setIcon(restoreBtn, 'clipboard-paste'); restoreBtn.addEventListener('click', async () => { try { await this.insertSnippet(snippet); } catch { new Notice('Failed to insert snippet'); } }); const editBtn = actionsEl.createEl('button', { cls: 'claudian-settings-action-btn', attr: { 'aria-label': 'Edit' }, }); setIcon(editBtn, 'pencil'); editBtn.addEventListener('click', () => { this.editSnippet(snippet); }); const deleteBtn = actionsEl.createEl('button', { cls: 'claudian-settings-action-btn claudian-settings-delete-btn', attr: { 'aria-label': 'Delete' }, }); setIcon(deleteBtn, 'trash-2'); deleteBtn.addEventListener('click', async () => { try { if (confirm(`Delete environment snippet "${snippet.name}"?`)) { await this.deleteSnippet(snippet); } } catch { new Notice('Failed to delete snippet'); } }); } } private async saveCurrentEnv() { const modal = new EnvSnippetModal( this.plugin.app, this.plugin, null, async (snippet) => { this.plugin.settings.envSnippets.push(snippet); await this.plugin.saveSettings(); this.render(); new Notice(`Environment snippet "${snippet.name}" saved`); } ); modal.open(); } private async insertSnippet(snippet: EnvSnippet) { const snippetContent = snippet.envVars.trim(); const envTextarea = document.querySelector('.claudian-settings-env-textarea') as HTMLTextAreaElement; if (envTextarea) { envTextarea.value = snippetContent; } else { this.render(); } await this.plugin.applyEnvironmentVariables(snippetContent); // Legacy snippets without contextLimits don't modify limits if (snippet.contextLimits) { this.plugin.settings.customContextLimits = { ...this.plugin.settings.customContextLimits, ...snippet.contextLimits, }; } await this.plugin.saveSettings(); this.onContextLimitsChange?.(); const view = this.plugin.app.workspace.getLeavesOfType('claudian-view')[0]?.view as ClaudianView | undefined; view?.refreshModelSelector(); } private editSnippet(snippet: EnvSnippet) { const modal = new EnvSnippetModal( this.plugin.app, this.plugin, snippet, async (updatedSnippet) => { const index = this.plugin.settings.envSnippets.findIndex(s => s.id === snippet.id); if (index !== -1) { this.plugin.settings.envSnippets[index] = updatedSnippet; await this.plugin.saveSettings(); this.render(); new Notice(`Environment snippet "${updatedSnippet.name}" updated`); } } ); modal.open(); } private async deleteSnippet(snippet: EnvSnippet) { this.plugin.settings.envSnippets = this.plugin.settings.envSnippets.filter(s => s.id !== snippet.id); await this.plugin.saveSettings(); this.render(); new Notice(`Environment snippet "${snippet.name}" deleted`); } public refresh() { this.render(); } } ================================================ FILE: src/features/settings/ui/McpServerModal.ts ================================================ import type { App } from 'obsidian'; import { Modal, Notice, Setting } from 'obsidian'; import type { ClaudianMcpServer, McpHttpServerConfig, McpServerConfig, McpServerType, McpSSEServerConfig, McpStdioServerConfig, } from '../../../core/types'; import { DEFAULT_MCP_SERVER, getMcpServerType } from '../../../core/types'; import type ClaudianPlugin from '../../../main'; import { parseCommand } from '../../../utils/mcp'; export class McpServerModal extends Modal { private plugin: ClaudianPlugin; private existingServer: ClaudianMcpServer | null; private onSave: (server: ClaudianMcpServer) => void; private serverName = ''; private serverType: McpServerType = 'stdio'; private enabled = DEFAULT_MCP_SERVER.enabled; private contextSaving = DEFAULT_MCP_SERVER.contextSaving; private command = ''; private env = ''; private url = ''; private headers = ''; private typeFieldsEl: HTMLElement | null = null; private nameInputEl: HTMLInputElement | null = null; constructor( app: App, plugin: ClaudianPlugin, existingServer: ClaudianMcpServer | null, onSave: (server: ClaudianMcpServer) => void, initialType?: McpServerType, prefillConfig?: { name: string; config: McpServerConfig } ) { super(app); this.plugin = plugin; this.existingServer = existingServer; this.onSave = onSave; if (existingServer) { this.serverName = existingServer.name; this.serverType = getMcpServerType(existingServer.config); this.enabled = existingServer.enabled; this.contextSaving = existingServer.contextSaving; this.initFromConfig(existingServer.config); } else if (prefillConfig) { this.serverName = prefillConfig.name; this.serverType = getMcpServerType(prefillConfig.config); this.initFromConfig(prefillConfig.config); } else if (initialType) { this.serverType = initialType; } } private initFromConfig(config: McpServerConfig) { const type = getMcpServerType(config); if (type === 'stdio') { const stdioConfig = config as McpStdioServerConfig; if (stdioConfig.args && stdioConfig.args.length > 0) { this.command = stdioConfig.command + ' ' + stdioConfig.args.join(' '); } else { this.command = stdioConfig.command; } this.env = this.envRecordToString(stdioConfig.env); } else { const urlConfig = config as McpSSEServerConfig | McpHttpServerConfig; this.url = urlConfig.url; this.headers = this.envRecordToString(urlConfig.headers); } } onOpen() { this.setTitle(this.existingServer ? 'Edit MCP Server' : 'Add MCP Server'); this.modalEl.addClass('claudian-mcp-modal'); const { contentEl } = this; new Setting(contentEl) .setName('Server name') .setDesc('Unique identifier for this server') .addText((text) => { this.nameInputEl = text.inputEl; text.setValue(this.serverName); text.setPlaceholder('my-mcp-server'); text.onChange((value) => { this.serverName = value; }); text.inputEl.addEventListener('keydown', (e) => this.handleKeyDown(e)); }); new Setting(contentEl) .setName('Type') .setDesc('Server connection type') .addDropdown((dropdown) => { dropdown.addOption('stdio', 'stdio (local command)'); dropdown.addOption('sse', 'sse (Server-Sent Events)'); dropdown.addOption('http', 'http (HTTP endpoint)'); dropdown.setValue(this.serverType); dropdown.onChange((value) => { this.serverType = value as McpServerType; this.renderTypeFields(); }); }); this.typeFieldsEl = contentEl.createDiv({ cls: 'claudian-mcp-type-fields' }); this.renderTypeFields(); new Setting(contentEl) .setName('Enabled') .setDesc('Whether this server is active') .addToggle((toggle) => { toggle.setValue(this.enabled); toggle.onChange((value) => { this.enabled = value; }); }); new Setting(contentEl) .setName('Context-saving mode') .setDesc('Hide tools from agent unless @-mentioned (saves context window)') .addToggle((toggle) => { toggle.setValue(this.contextSaving); toggle.onChange((value) => { this.contextSaving = value; }); }); const buttonContainer = contentEl.createDiv({ cls: 'claudian-mcp-buttons' }); const cancelBtn = buttonContainer.createEl('button', { text: 'Cancel', cls: 'claudian-cancel-btn', }); cancelBtn.addEventListener('click', () => this.close()); const saveBtn = buttonContainer.createEl('button', { text: this.existingServer ? 'Update' : 'Add', cls: 'claudian-save-btn mod-cta', }); saveBtn.addEventListener('click', () => this.save()); } private renderTypeFields() { if (!this.typeFieldsEl) return; this.typeFieldsEl.empty(); if (this.serverType === 'stdio') { this.renderStdioFields(); } else { this.renderUrlFields(); } } private renderStdioFields() { if (!this.typeFieldsEl) return; const cmdSetting = new Setting(this.typeFieldsEl) .setName('Command') .setDesc('Full command with arguments'); cmdSetting.settingEl.addClass('claudian-mcp-cmd-setting'); const cmdTextarea = cmdSetting.controlEl.createEl('textarea', { cls: 'claudian-mcp-cmd-textarea', }); cmdTextarea.value = this.command; cmdTextarea.placeholder = 'docker exec -i mcp-server python -m src.server'; cmdTextarea.rows = 2; cmdTextarea.addEventListener('input', () => { this.command = cmdTextarea.value; }); const envSetting = new Setting(this.typeFieldsEl) .setName('Environment variables') .setDesc('KEY=VALUE per line (optional)'); envSetting.settingEl.addClass('claudian-mcp-env-setting'); const envTextarea = envSetting.controlEl.createEl('textarea', { cls: 'claudian-mcp-env-textarea', }); envTextarea.value = this.env; envTextarea.placeholder = 'API_KEY=your-key'; envTextarea.rows = 2; envTextarea.addEventListener('input', () => { this.env = envTextarea.value; }); } private renderUrlFields() { if (!this.typeFieldsEl) return; new Setting(this.typeFieldsEl) .setName('URL') .setDesc(this.serverType === 'sse' ? 'SSE endpoint URL' : 'HTTP endpoint URL') .addText((text) => { text.setValue(this.url); text.setPlaceholder('http://localhost:3000/sse'); text.onChange((value) => { this.url = value; }); text.inputEl.addEventListener('keydown', (e) => this.handleKeyDown(e)); }); const headersSetting = new Setting(this.typeFieldsEl) .setName('Headers') .setDesc('HTTP headers (KEY=VALUE per line)'); headersSetting.settingEl.addClass('claudian-mcp-env-setting'); const headersTextarea = headersSetting.controlEl.createEl('textarea', { cls: 'claudian-mcp-env-textarea', }); headersTextarea.value = this.headers; headersTextarea.placeholder = 'Authorization=Bearer token\nContent-Type=application/json'; headersTextarea.rows = 3; headersTextarea.addEventListener('input', () => { this.headers = headersTextarea.value; }); } private handleKeyDown(e: KeyboardEvent) { // !e.isComposing for IME support if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); this.save(); } else if (e.key === 'Escape' && !e.isComposing) { e.preventDefault(); this.close(); } } private save() { const name = this.serverName.trim(); if (!name) { new Notice('Please enter a server name'); this.nameInputEl?.focus(); return; } if (!/^[a-zA-Z0-9._-]+$/.test(name)) { new Notice('Server name can only contain letters, numbers, dots, hyphens, and underscores'); this.nameInputEl?.focus(); return; } let config: McpServerConfig; if (this.serverType === 'stdio') { const fullCommand = this.command.trim(); if (!fullCommand) { new Notice('Please enter a command'); return; } const { cmd, args } = parseCommand(fullCommand); const stdioConfig: McpStdioServerConfig = { command: cmd }; if (args.length > 0) { stdioConfig.args = args; } const env = this.parseEnvString(this.env); if (Object.keys(env).length > 0) { stdioConfig.env = env; } config = stdioConfig; } else { const url = this.url.trim(); if (!url) { new Notice('Please enter a URL'); return; } if (this.serverType === 'sse') { const sseConfig: McpSSEServerConfig = { type: 'sse', url }; const headers = this.parseEnvString(this.headers); if (Object.keys(headers).length > 0) { sseConfig.headers = headers; } config = sseConfig; } else { const httpConfig: McpHttpServerConfig = { type: 'http', url }; const headers = this.parseEnvString(this.headers); if (Object.keys(headers).length > 0) { httpConfig.headers = headers; } config = httpConfig; } } const server: ClaudianMcpServer = { name, config, enabled: this.enabled, contextSaving: this.contextSaving, disabledTools: this.existingServer?.disabledTools, }; this.onSave(server); this.close(); } private parseEnvString(envStr: string): Record { const result: Record = {}; if (!envStr.trim()) return result; for (const line of envStr.split('\n')) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith('#')) continue; const eqIndex = trimmed.indexOf('='); if (eqIndex === -1) continue; const key = trimmed.substring(0, eqIndex).trim(); const value = trimmed.substring(eqIndex + 1).trim(); if (key) { result[key] = value; } } return result; } private envRecordToString(env: Record | undefined): string { if (!env) return ''; return Object.entries(env) .map(([key, value]) => `${key}=${value}`) .join('\n'); } onClose() { this.contentEl.empty(); } } ================================================ FILE: src/features/settings/ui/McpSettingsManager.ts ================================================ import { Notice, setIcon } from 'obsidian'; import { testMcpServer } from '../../../core/mcp/McpTester'; import { McpStorage } from '../../../core/storage'; import type { ClaudianMcpServer, McpServerConfig, McpServerType } from '../../../core/types'; import { DEFAULT_MCP_SERVER, getMcpServerType } from '../../../core/types'; import type ClaudianPlugin from '../../../main'; import { McpServerModal } from './McpServerModal'; import { McpTestModal } from './McpTestModal'; export class McpSettingsManager { private containerEl: HTMLElement; private plugin: ClaudianPlugin; private servers: ClaudianMcpServer[] = []; /** * Broadcasts MCP reload to all open Claudian views. * With multiple views open (split workspace), each view's tabs need to reload MCP config. */ private async broadcastMcpReloadToAllViews(): Promise { const views = this.plugin.getAllViews(); for (const view of views) { await view.getTabManager()?.broadcastToAllTabs( (service) => service.reloadMcpServers() ); } } constructor(containerEl: HTMLElement, plugin: ClaudianPlugin) { this.containerEl = containerEl; this.plugin = plugin; this.loadAndRender(); } private async loadAndRender() { this.servers = await this.plugin.storage.mcp.load(); this.render(); } private render() { this.containerEl.empty(); const headerEl = this.containerEl.createDiv({ cls: 'claudian-mcp-header' }); headerEl.createSpan({ text: 'MCP Servers', cls: 'claudian-mcp-label' }); const addContainer = headerEl.createDiv({ cls: 'claudian-mcp-add-container' }); const addBtn = addContainer.createEl('button', { cls: 'claudian-settings-action-btn', attr: { 'aria-label': 'Add' }, }); setIcon(addBtn, 'plus'); const dropdown = addContainer.createDiv({ cls: 'claudian-mcp-add-dropdown' }); const stdioOption = dropdown.createDiv({ cls: 'claudian-mcp-add-option' }); setIcon(stdioOption.createSpan({ cls: 'claudian-mcp-add-option-icon' }), 'terminal'); stdioOption.createSpan({ text: 'stdio (local command)' }); stdioOption.addEventListener('click', () => { dropdown.removeClass('is-visible'); this.openModal(null, 'stdio'); }); const httpOption = dropdown.createDiv({ cls: 'claudian-mcp-add-option' }); setIcon(httpOption.createSpan({ cls: 'claudian-mcp-add-option-icon' }), 'globe'); httpOption.createSpan({ text: 'http / sse (remote)' }); httpOption.addEventListener('click', () => { dropdown.removeClass('is-visible'); this.openModal(null, 'http'); }); const importOption = dropdown.createDiv({ cls: 'claudian-mcp-add-option' }); setIcon(importOption.createSpan({ cls: 'claudian-mcp-add-option-icon' }), 'clipboard-paste'); importOption.createSpan({ text: 'Import from clipboard' }); importOption.addEventListener('click', () => { dropdown.removeClass('is-visible'); this.importFromClipboard(); }); addBtn.addEventListener('click', (e) => { e.stopPropagation(); dropdown.toggleClass('is-visible', !dropdown.hasClass('is-visible')); }); document.addEventListener('click', () => { dropdown.removeClass('is-visible'); }); if (this.servers.length === 0) { const emptyEl = this.containerEl.createDiv({ cls: 'claudian-mcp-empty' }); emptyEl.setText('No MCP servers configured. Click "Add" to add one.'); return; } const listEl = this.containerEl.createDiv({ cls: 'claudian-mcp-list' }); for (const server of this.servers) { this.renderServerItem(listEl, server); } } private renderServerItem(listEl: HTMLElement, server: ClaudianMcpServer) { const itemEl = listEl.createDiv({ cls: 'claudian-mcp-item' }); if (!server.enabled) { itemEl.addClass('claudian-mcp-item-disabled'); } const statusEl = itemEl.createDiv({ cls: 'claudian-mcp-status' }); statusEl.addClass( server.enabled ? 'claudian-mcp-status-enabled' : 'claudian-mcp-status-disabled' ); const infoEl = itemEl.createDiv({ cls: 'claudian-mcp-info' }); const nameRow = infoEl.createDiv({ cls: 'claudian-mcp-name-row' }); const nameEl = nameRow.createSpan({ cls: 'claudian-mcp-name' }); nameEl.setText(server.name); const serverType = getMcpServerType(server.config); const typeEl = nameRow.createSpan({ cls: 'claudian-mcp-type-badge' }); typeEl.setText(serverType); if (server.contextSaving) { const csEl = nameRow.createSpan({ cls: 'claudian-mcp-context-saving-badge' }); csEl.setText('@'); csEl.setAttribute('title', 'Context-saving: mention with @' + server.name + ' to enable'); } const previewEl = infoEl.createDiv({ cls: 'claudian-mcp-preview' }); if (server.description) { previewEl.setText(server.description); } else { previewEl.setText(this.getServerPreview(server, serverType)); } const actionsEl = itemEl.createDiv({ cls: 'claudian-mcp-actions' }); const testBtn = actionsEl.createEl('button', { cls: 'claudian-mcp-action-btn', attr: { 'aria-label': 'Verify (show tools)' }, }); setIcon(testBtn, 'zap'); testBtn.addEventListener('click', () => this.testServer(server)); const toggleBtn = actionsEl.createEl('button', { cls: 'claudian-mcp-action-btn', attr: { 'aria-label': server.enabled ? 'Disable' : 'Enable' }, }); setIcon(toggleBtn, server.enabled ? 'toggle-right' : 'toggle-left'); toggleBtn.addEventListener('click', () => this.toggleServer(server)); const editBtn = actionsEl.createEl('button', { cls: 'claudian-mcp-action-btn', attr: { 'aria-label': 'Edit' }, }); setIcon(editBtn, 'pencil'); editBtn.addEventListener('click', () => this.openModal(server)); const deleteBtn = actionsEl.createEl('button', { cls: 'claudian-mcp-action-btn claudian-mcp-delete-btn', attr: { 'aria-label': 'Delete' }, }); setIcon(deleteBtn, 'trash-2'); deleteBtn.addEventListener('click', () => this.deleteServer(server)); } private async testServer(server: ClaudianMcpServer) { const modal = new McpTestModal( this.plugin.app, server.name, server.disabledTools, async (toolName, enabled) => { await this.updateDisabledTool(server, toolName, enabled); }, async (disabledTools) => { await this.updateAllDisabledTools(server, disabledTools); } ); modal.open(); try { const result = await testMcpServer(server); modal.setResult(result); } catch (error) { modal.setError(error instanceof Error ? error.message : 'Verification failed'); } } /** Rolls back on save failure; warns on reload failure (since save succeeded). */ private async updateServerDisabledTools( server: ClaudianMcpServer, newDisabledTools: string[] | undefined ): Promise { const previous = server.disabledTools ? [...server.disabledTools] : undefined; server.disabledTools = newDisabledTools; try { await this.plugin.storage.mcp.save(this.servers); } catch (error) { server.disabledTools = previous; throw error; } try { await this.broadcastMcpReloadToAllViews(); } catch { // Save succeeded but reload failed - don't rollback since disk has correct state new Notice('Setting saved but reload failed. Changes will apply on next session.'); } } private async updateDisabledTool( server: ClaudianMcpServer, toolName: string, enabled: boolean ) { const disabledTools = new Set(server.disabledTools ?? []); if (enabled) { disabledTools.delete(toolName); } else { disabledTools.add(toolName); } await this.updateServerDisabledTools( server, disabledTools.size > 0 ? Array.from(disabledTools) : undefined ); } private async updateAllDisabledTools(server: ClaudianMcpServer, disabledTools: string[]) { await this.updateServerDisabledTools( server, disabledTools.length > 0 ? disabledTools : undefined ); } private getServerPreview(server: ClaudianMcpServer, type: McpServerType): string { if (type === 'stdio') { const config = server.config as { command: string; args?: string[] }; const args = config.args?.join(' ') || ''; return args ? `${config.command} ${args}` : config.command; } else { const config = server.config as { url: string }; return config.url; } } private openModal(existing: ClaudianMcpServer | null, initialType?: McpServerType) { const modal = new McpServerModal( this.plugin.app, this.plugin, existing, async (server) => { await this.saveServer(server, existing); }, initialType ); modal.open(); } private async importFromClipboard() { try { const text = await navigator.clipboard.readText(); if (!text.trim()) { new Notice('Clipboard is empty'); return; } const parsed = McpStorage.tryParseClipboardConfig(text); if (!parsed || parsed.servers.length === 0) { new Notice('No valid MCP configuration found in clipboard'); return; } if (parsed.needsName || parsed.servers.length === 1) { const server = parsed.servers[0]; const type = getMcpServerType(server.config); const modal = new McpServerModal( this.plugin.app, this.plugin, null, async (savedServer) => { await this.saveServer(savedServer, null); }, type, server // Pre-fill with parsed config ); modal.open(); if (parsed.needsName) { new Notice('Enter a name for the server'); } return; } await this.importServers(parsed.servers); } catch { new Notice('Failed to read clipboard'); } } private async saveServer(server: ClaudianMcpServer, existing: ClaudianMcpServer | null) { if (existing) { const index = this.servers.findIndex((s) => s.name === existing.name); if (index !== -1) { if (server.name !== existing.name) { const conflict = this.servers.find((s) => s.name === server.name); if (conflict) { new Notice(`Server "${server.name}" already exists`); return; } } this.servers[index] = server; } } else { const conflict = this.servers.find((s) => s.name === server.name); if (conflict) { new Notice(`Server "${server.name}" already exists`); return; } this.servers.push(server); } await this.plugin.storage.mcp.save(this.servers); await this.broadcastMcpReloadToAllViews(); this.render(); new Notice(existing ? `MCP server "${server.name}" updated` : `MCP server "${server.name}" added`); } private async importServers(servers: Array<{ name: string; config: McpServerConfig }>) { const added: string[] = []; const skipped: string[] = []; for (const server of servers) { const name = server.name.trim(); if (!name || !/^[a-zA-Z0-9._-]+$/.test(name)) { skipped.push(server.name || ''); continue; } const conflict = this.servers.find((s) => s.name === name); if (conflict) { skipped.push(name); continue; } this.servers.push({ name, config: server.config, enabled: DEFAULT_MCP_SERVER.enabled, contextSaving: DEFAULT_MCP_SERVER.contextSaving, }); added.push(name); } if (added.length === 0) { new Notice('No new MCP servers imported'); return; } await this.plugin.storage.mcp.save(this.servers); await this.broadcastMcpReloadToAllViews(); this.render(); let message = `Imported ${added.length} MCP server${added.length > 1 ? 's' : ''}`; if (skipped.length > 0) { message += ` (${skipped.length} skipped)`; } new Notice(message); } private async toggleServer(server: ClaudianMcpServer) { server.enabled = !server.enabled; await this.plugin.storage.mcp.save(this.servers); await this.broadcastMcpReloadToAllViews(); this.render(); new Notice(`MCP server "${server.name}" ${server.enabled ? 'enabled' : 'disabled'}`); } private async deleteServer(server: ClaudianMcpServer) { if (!confirm(`Delete MCP server "${server.name}"?`)) { return; } this.servers = this.servers.filter((s) => s.name !== server.name); await this.plugin.storage.mcp.save(this.servers); await this.broadcastMcpReloadToAllViews(); this.render(); new Notice(`MCP server "${server.name}" deleted`); } /** Refresh the server list (call after external changes). */ public refresh() { this.loadAndRender(); } } ================================================ FILE: src/features/settings/ui/McpTestModal.ts ================================================ import type { App } from 'obsidian'; import { Modal, Notice, setIcon } from 'obsidian'; import type { McpTestResult, McpTool } from '../../../core/mcp/McpTester'; function formatToggleError(error: unknown): string { if (!(error instanceof Error)) return 'Failed to update tool setting'; const msg = error.message.toLowerCase(); if (msg.includes('permission') || msg.includes('eacces')) { return 'Permission denied. Check .claude/ folder permissions.'; } if (msg.includes('enospc') || msg.includes('disk full') || msg.includes('no space')) { return 'Disk full. Free up space and try again.'; } if (msg.includes('json') || msg.includes('syntax')) { return 'Config file corrupted. Check .claude/mcp.json'; } return error.message || 'Failed to update tool setting'; } export class McpTestModal extends Modal { private serverName: string; private result: McpTestResult | null = null; private loading = true; private contentEl_: HTMLElement | null = null; private disabledTools: Set; private onToolToggle?: (toolName: string, enabled: boolean) => Promise; private onBulkToggle?: (disabledTools: string[]) => Promise; private toolToggles: Map = new Map(); private toolElements: Map = new Map(); private toggleAllBtn: HTMLButtonElement | null = null; private pendingToggle = false; constructor( app: App, serverName: string, initialDisabledTools?: string[], onToolToggle?: (toolName: string, enabled: boolean) => Promise, onBulkToggle?: (disabledTools: string[]) => Promise ) { super(app); this.serverName = serverName; this.disabledTools = new Set( (initialDisabledTools ?? []) .map((tool) => tool.trim()) .filter((tool) => tool.length > 0) ); this.onToolToggle = onToolToggle; this.onBulkToggle = onBulkToggle; } onOpen() { this.setTitle(`Verify: ${this.serverName}`); this.modalEl.addClass('claudian-mcp-test-modal'); this.contentEl_ = this.contentEl; this.renderLoading(); } setResult(result: McpTestResult) { this.result = result; this.loading = false; this.render(); } setError(error: string) { this.result = { success: false, tools: [], error }; this.loading = false; this.render(); } private renderLoading() { if (!this.contentEl_) return; this.contentEl_.empty(); const loadingEl = this.contentEl_.createDiv({ cls: 'claudian-mcp-test-loading' }); const spinnerEl = loadingEl.createDiv({ cls: 'claudian-mcp-test-spinner' }); spinnerEl.innerHTML = ` `; loadingEl.createSpan({ text: 'Connecting to MCP server...' }); } private render() { if (!this.contentEl_) return; this.contentEl_.empty(); if (!this.result) { this.renderLoading(); return; } const statusEl = this.contentEl_.createDiv({ cls: 'claudian-mcp-test-status' }); const iconEl = statusEl.createSpan({ cls: 'claudian-mcp-test-icon' }); if (this.result.success) { setIcon(iconEl, 'check-circle'); iconEl.addClass('success'); } else { setIcon(iconEl, 'x-circle'); iconEl.addClass('error'); } const textEl = statusEl.createSpan({ cls: 'claudian-mcp-test-text' }); if (this.result.success) { let statusText = 'Connected successfully'; if (this.result.serverName) { statusText += ` to ${this.result.serverName}`; if (this.result.serverVersion) { statusText += ` v${this.result.serverVersion}`; } } textEl.setText(statusText); } else { textEl.setText('Connection failed'); } if (this.result.error) { const errorEl = this.contentEl_.createDiv({ cls: 'claudian-mcp-test-error' }); errorEl.setText(this.result.error); } this.toolToggles.clear(); this.toolElements.clear(); if (this.result.tools.length > 0) { const toolsSection = this.contentEl_.createDiv({ cls: 'claudian-mcp-test-tools' }); const toolsHeader = toolsSection.createDiv({ cls: 'claudian-mcp-test-tools-header' }); toolsHeader.setText(`Available Tools (${this.result.tools.length})`); const toolsList = toolsSection.createDiv({ cls: 'claudian-mcp-test-tools-list' }); for (const tool of this.result.tools) { this.renderTool(toolsList, tool); } } else if (this.result.success) { const noToolsEl = this.contentEl_.createDiv({ cls: 'claudian-mcp-test-no-tools' }); noToolsEl.setText('No tools information available. Tools will be loaded when used in chat.'); } const buttonContainer = this.contentEl_.createDiv({ cls: 'claudian-mcp-test-buttons' }); if (this.result.tools.length > 0 && this.onToolToggle) { this.toggleAllBtn = buttonContainer.createEl('button', { cls: 'claudian-mcp-toggle-all-btn', }); this.updateToggleAllButton(); this.toggleAllBtn.addEventListener('click', () => this.handleToggleAll()); } const closeBtn = buttonContainer.createEl('button', { text: 'Close', cls: 'mod-cta', }); closeBtn.addEventListener('click', () => this.close()); } private renderTool(container: HTMLElement, tool: McpTool) { const toolEl = container.createDiv({ cls: 'claudian-mcp-test-tool' }); const headerEl = toolEl.createDiv({ cls: 'claudian-mcp-test-tool-header' }); const iconEl = headerEl.createSpan({ cls: 'claudian-mcp-test-tool-icon' }); setIcon(iconEl, 'wrench'); const nameEl = headerEl.createSpan({ cls: 'claudian-mcp-test-tool-name' }); nameEl.setText(tool.name); const toggleEl = headerEl.createDiv({ cls: 'claudian-mcp-test-tool-toggle' }); const toggleContainer = toggleEl.createDiv({ cls: 'checkbox-container' }); const checkbox = toggleContainer.createEl('input', { type: 'checkbox', attr: { tabindex: '0' }, }); const isEnabled = !this.disabledTools.has(tool.name); checkbox.checked = isEnabled; toggleContainer.toggleClass('is-enabled', isEnabled); this.updateToolState(toolEl, isEnabled); this.toolToggles.set(tool.name, { checkbox, container: toggleContainer }); this.toolElements.set(tool.name, toolEl); if (!this.onToolToggle) { checkbox.disabled = true; } else { // Click on container instead of checkbox change event for cross-browser reliability toggleContainer.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); if (checkbox.disabled) return; checkbox.checked = !checkbox.checked; this.handleToolToggle(tool.name, checkbox, toggleContainer); }); } if (tool.description) { const descEl = toolEl.createDiv({ cls: 'claudian-mcp-test-tool-desc' }); descEl.setText(tool.description); } } private async handleToolToggle( toolName: string, checkbox: HTMLInputElement, container: HTMLElement ) { const toolEl = this.toolElements.get(toolName); if (!toolEl) return; const wasDisabled = this.disabledTools.has(toolName); const nextDisabled = !checkbox.checked; if (nextDisabled) { this.disabledTools.add(toolName); } else { this.disabledTools.delete(toolName); } container.toggleClass('is-enabled', !nextDisabled); this.updateToolState(toolEl, !nextDisabled); this.updateToggleAllButton(); checkbox.disabled = true; try { await this.onToolToggle?.(toolName, !nextDisabled); } catch (error) { // Rollback if (nextDisabled) { this.disabledTools.delete(toolName); } else { this.disabledTools.add(toolName); } checkbox.checked = !wasDisabled; container.toggleClass('is-enabled', !wasDisabled); this.updateToolState(toolEl, !wasDisabled); this.updateToggleAllButton(); new Notice(formatToggleError(error)); } finally { checkbox.disabled = false; } } private updateToolState(toolEl: HTMLElement, enabled: boolean) { toolEl.toggleClass('claudian-mcp-test-tool-disabled', !enabled); } private updateToggleAllButton() { if (!this.toggleAllBtn || !this.result) return; const allEnabled = this.disabledTools.size === 0; const allDisabled = this.disabledTools.size === this.result.tools.length; if (allEnabled) { this.toggleAllBtn.setText('Disable All'); this.toggleAllBtn.toggleClass('is-destructive', true); } else { this.toggleAllBtn.setText(allDisabled ? 'Enable All' : 'Enable All'); this.toggleAllBtn.toggleClass('is-destructive', false); } } private async handleToggleAll() { if (!this.result || this.pendingToggle || !this.onBulkToggle) return; const allEnabled = this.disabledTools.size === 0; const previousDisabled = new Set(this.disabledTools); const newDisabledTools: string[] = allEnabled ? this.result.tools.map((t) => t.name) // Disable all : []; // Enable all this.pendingToggle = true; if (this.toggleAllBtn) this.toggleAllBtn.disabled = true; for (const { checkbox } of this.toolToggles.values()) { checkbox.disabled = true; } // Optimistic UI update this.disabledTools = new Set(newDisabledTools); for (const tool of this.result.tools) { const toggle = this.toolToggles.get(tool.name); const toolEl = this.toolElements.get(tool.name); if (!toggle || !toolEl) continue; const isEnabled = !this.disabledTools.has(tool.name); toggle.checkbox.checked = isEnabled; toggle.container.toggleClass('is-enabled', isEnabled); this.updateToolState(toolEl, isEnabled); } this.updateToggleAllButton(); try { await this.onBulkToggle(newDisabledTools); } catch (error) { this.disabledTools = previousDisabled; for (const tool of this.result.tools) { const toggle = this.toolToggles.get(tool.name); const toolEl = this.toolElements.get(tool.name); if (!toggle || !toolEl) continue; const isEnabled = !this.disabledTools.has(tool.name); toggle.checkbox.checked = isEnabled; toggle.container.toggleClass('is-enabled', isEnabled); this.updateToolState(toolEl, isEnabled); } this.updateToggleAllButton(); new Notice(formatToggleError(error)); } for (const { checkbox } of this.toolToggles.values()) { checkbox.disabled = false; } this.pendingToggle = false; if (this.toggleAllBtn) this.toggleAllBtn.disabled = false; } onClose() { this.contentEl.empty(); } } ================================================ FILE: src/features/settings/ui/PluginSettingsManager.ts ================================================ import { Notice, setIcon } from 'obsidian'; import type { ClaudianPlugin as ClaudianPluginType } from '../../../core/types'; import type ClaudianPlugin from '../../../main'; export class PluginSettingsManager { private containerEl: HTMLElement; private plugin: ClaudianPlugin; constructor(containerEl: HTMLElement, plugin: ClaudianPlugin) { this.containerEl = containerEl; this.plugin = plugin; this.render(); } private render() { this.containerEl.empty(); const headerEl = this.containerEl.createDiv({ cls: 'claudian-plugin-header' }); headerEl.createSpan({ text: 'Claude Code Plugins', cls: 'claudian-plugin-label' }); const refreshBtn = headerEl.createEl('button', { cls: 'claudian-settings-action-btn', attr: { 'aria-label': 'Refresh' }, }); setIcon(refreshBtn, 'refresh-cw'); refreshBtn.addEventListener('click', () => this.refreshPlugins()); const plugins = this.plugin.pluginManager.getPlugins(); if (plugins.length === 0) { const emptyEl = this.containerEl.createDiv({ cls: 'claudian-plugin-empty' }); emptyEl.setText('No Claude Code plugins found. Enable plugins via the Claude CLI.'); return; } const projectPlugins = plugins.filter(p => p.scope === 'project'); const userPlugins = plugins.filter(p => p.scope === 'user'); const listEl = this.containerEl.createDiv({ cls: 'claudian-plugin-list' }); if (projectPlugins.length > 0) { const sectionHeader = listEl.createDiv({ cls: 'claudian-plugin-section-header' }); sectionHeader.setText('Project Plugins'); for (const plugin of projectPlugins) { this.renderPluginItem(listEl, plugin); } } if (userPlugins.length > 0) { const sectionHeader = listEl.createDiv({ cls: 'claudian-plugin-section-header' }); sectionHeader.setText('User Plugins'); for (const plugin of userPlugins) { this.renderPluginItem(listEl, plugin); } } } private renderPluginItem(listEl: HTMLElement, plugin: ClaudianPluginType) { const itemEl = listEl.createDiv({ cls: 'claudian-plugin-item' }); if (!plugin.enabled) { itemEl.addClass('claudian-plugin-item-disabled'); } const statusEl = itemEl.createDiv({ cls: 'claudian-plugin-status' }); if (plugin.enabled) { statusEl.addClass('claudian-plugin-status-enabled'); } else { statusEl.addClass('claudian-plugin-status-disabled'); } const infoEl = itemEl.createDiv({ cls: 'claudian-plugin-info' }); const nameRow = infoEl.createDiv({ cls: 'claudian-plugin-name-row' }); const nameEl = nameRow.createSpan({ cls: 'claudian-plugin-name' }); nameEl.setText(plugin.name); const actionsEl = itemEl.createDiv({ cls: 'claudian-plugin-actions' }); const toggleBtn = actionsEl.createEl('button', { cls: 'claudian-plugin-action-btn', attr: { 'aria-label': plugin.enabled ? 'Disable' : 'Enable' }, }); setIcon(toggleBtn, plugin.enabled ? 'toggle-right' : 'toggle-left'); toggleBtn.addEventListener('click', () => this.togglePlugin(plugin.id)); } private async togglePlugin(pluginId: string) { const plugin = this.plugin.pluginManager.getPlugins().find(p => p.id === pluginId); const wasEnabled = plugin?.enabled ?? false; try { await this.plugin.pluginManager.togglePlugin(pluginId); await this.plugin.agentManager.loadAgents(); const view = this.plugin.getView(); const tabManager = view?.getTabManager(); if (tabManager) { try { await tabManager.broadcastToAllTabs( async (service) => { await service.ensureReady({ force: true }); } ); } catch { new Notice('Plugin toggled, but some tabs failed to restart.'); } } new Notice(`Plugin "${pluginId}" ${wasEnabled ? 'disabled' : 'enabled'}`); } catch (err) { await this.plugin.pluginManager.togglePlugin(pluginId); const message = err instanceof Error ? err.message : 'Unknown error'; new Notice(`Failed to toggle plugin: ${message}`); } finally { this.render(); } } private async refreshPlugins() { try { await this.plugin.pluginManager.loadPlugins(); await this.plugin.agentManager.loadAgents(); new Notice('Plugin list refreshed'); } catch (err) { const message = err instanceof Error ? err.message : 'Unknown error'; new Notice(`Failed to refresh plugins: ${message}`); } finally { this.render(); } } public refresh() { this.render(); } } ================================================ FILE: src/features/settings/ui/SlashCommandSettings.ts ================================================ import type { App, ToggleComponent } from 'obsidian'; import { Modal, Notice, setIcon, Setting } from 'obsidian'; import type { SlashCommand } from '../../../core/types'; import { t } from '../../../i18n'; import type ClaudianPlugin from '../../../main'; import { extractFirstParagraph, isSkill, normalizeArgumentHint, parseSlashCommandContent, validateCommandName } from '../../../utils/slashCommand'; function resolveAllowedTools(inputValue: string, parsedTools?: string[]): string[] | undefined { const trimmed = inputValue.trim(); if (trimmed) { return trimmed.split(',').map(s => s.trim()).filter(Boolean); } if (parsedTools && parsedTools.length > 0) { return parsedTools; } return undefined; } export class SlashCommandModal extends Modal { private plugin: ClaudianPlugin; private existingCmd: SlashCommand | null; private onSave: (cmd: SlashCommand) => Promise; constructor( app: App, plugin: ClaudianPlugin, existingCmd: SlashCommand | null, onSave: (cmd: SlashCommand) => Promise ) { super(app); this.plugin = plugin; this.existingCmd = existingCmd; this.onSave = onSave; } onOpen() { const existingIsSkill = this.existingCmd ? isSkill(this.existingCmd) : false; let selectedType: 'command' | 'skill' = existingIsSkill ? 'skill' : 'command'; const typeLabel = () => selectedType === 'skill' ? 'Skill' : 'Slash Command'; this.setTitle(this.existingCmd ? `Edit ${typeLabel()}` : `Add ${typeLabel()}`); this.modalEl.addClass('claudian-sp-modal'); const { contentEl } = this; let nameInput: HTMLInputElement; let descInput: HTMLInputElement; let hintInput: HTMLInputElement; let modelInput: HTMLInputElement; let toolsInput: HTMLInputElement; let disableModelToggle: boolean = this.existingCmd?.disableModelInvocation ?? false; let disableUserInvocation: boolean = this.existingCmd?.userInvocable === false; let contextValue: 'fork' | '' = this.existingCmd?.context ?? ''; let agentInput: HTMLInputElement; /* eslint-disable prefer-const -- assigned in Setting callbacks */ let disableUserSetting!: Setting; let disableUserToggle!: ToggleComponent; /* eslint-enable prefer-const */ const updateSkillOnlyFields = () => { const isSkillType = selectedType === 'skill'; disableUserSetting.settingEl.style.display = isSkillType ? '' : 'none'; if (!isSkillType) { disableUserInvocation = false; disableUserToggle.setValue(false); } }; new Setting(contentEl) .setName('Type') .setDesc('Command or skill') .addDropdown(dropdown => { dropdown .addOption('command', 'Command') .addOption('skill', 'Skill') .setValue(selectedType) .onChange(value => { selectedType = value as 'command' | 'skill'; this.setTitle(this.existingCmd ? `Edit ${typeLabel()}` : `Add ${typeLabel()}`); updateSkillOnlyFields(); }); if (this.existingCmd) { dropdown.setDisabled(true); } }); new Setting(contentEl) .setName('Command name') .setDesc('The name used after / (e.g., "review" for /review)') .addText(text => { nameInput = text.inputEl; text.setValue(this.existingCmd?.name || '') .setPlaceholder('review-code'); }); new Setting(contentEl) .setName('Description') .setDesc('Optional description shown in dropdown') .addText(text => { descInput = text.inputEl; text.setValue(this.existingCmd?.description || ''); }); const details = contentEl.createEl('details', { cls: 'claudian-sp-advanced-section' }); details.createEl('summary', { text: 'Advanced options', cls: 'claudian-sp-advanced-summary', }); if (this.existingCmd?.argumentHint || this.existingCmd?.model || this.existingCmd?.allowedTools?.length || this.existingCmd?.disableModelInvocation || this.existingCmd?.userInvocable === false || this.existingCmd?.context || this.existingCmd?.agent) { details.open = true; } new Setting(details) .setName('Argument hint') .setDesc('Placeholder text for arguments (e.g., "[file] [focus]")') .addText(text => { hintInput = text.inputEl; text.setValue(this.existingCmd?.argumentHint || ''); }); new Setting(details) .setName('Model override') .setDesc('Optional model to use for this command') .addText(text => { modelInput = text.inputEl; text.setValue(this.existingCmd?.model || '') .setPlaceholder('claude-sonnet-4-5'); }); new Setting(details) .setName('Allowed tools') .setDesc('Comma-separated list of tools to allow (empty = all)') .addText(text => { toolsInput = text.inputEl; text.setValue(this.existingCmd?.allowedTools?.join(', ') || ''); }); new Setting(details) .setName('Disable model invocation') .setDesc('Prevent the model from invoking this command itself') .addToggle(toggle => { toggle.setValue(disableModelToggle) .onChange(value => { disableModelToggle = value; }); }); disableUserSetting = new Setting(details) .setName('Disable user invocation') .setDesc('Prevent the user from invoking this skill directly') .addToggle(toggle => { disableUserToggle = toggle; toggle.setValue(disableUserInvocation) .onChange(value => { disableUserInvocation = value; }); }); updateSkillOnlyFields(); new Setting(details) .setName('Context') .setDesc('Run in a subagent (fork)') .addToggle(toggle => { toggle.setValue(contextValue === 'fork') .onChange(value => { contextValue = value ? 'fork' : ''; agentSetting.settingEl.style.display = value ? '' : 'none'; }); }); const agentSetting = new Setting(details) .setName('Agent') .setDesc('Subagent type when context is fork') .addText(text => { agentInput = text.inputEl; text.setValue(this.existingCmd?.agent || '') .setPlaceholder('code-reviewer'); }); agentSetting.settingEl.style.display = contextValue === 'fork' ? '' : 'none'; new Setting(contentEl) .setName('Prompt template') .setDesc('Use $ARGUMENTS, $1, $2, @file, !`bash`'); const contentArea = contentEl.createEl('textarea', { cls: 'claudian-sp-content-area', attr: { rows: '10', placeholder: 'Review this code for:\n$ARGUMENTS\n\n@$1', }, }); const initialContent = this.existingCmd ? parseSlashCommandContent(this.existingCmd.content).promptContent : ''; contentArea.value = initialContent; const buttonContainer = contentEl.createDiv({ cls: 'claudian-sp-modal-buttons' }); const cancelBtn = buttonContainer.createEl('button', { text: 'Cancel', cls: 'claudian-cancel-btn', }); cancelBtn.addEventListener('click', () => this.close()); const saveBtn = buttonContainer.createEl('button', { text: 'Save', cls: 'claudian-save-btn', }); saveBtn.addEventListener('click', async () => { const name = nameInput.value.trim(); const nameError = validateCommandName(name); if (nameError) { new Notice(nameError); return; } const content = contentArea.value; if (!content.trim()) { new Notice('Prompt template is required'); return; } const existing = this.plugin.settings.slashCommands.find( c => c.name.toLowerCase() === name.toLowerCase() && c.id !== this.existingCmd?.id ); if (existing) { new Notice(`A command named "/${name}" already exists`); return; } const parsed = parseSlashCommandContent(content); const promptContent = parsed.promptContent; const isSkillType = selectedType === 'skill'; const id = this.existingCmd?.id || (isSkillType ? `skill-${name}` : `cmd-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`); const cmd: SlashCommand = { id, name, description: descInput.value.trim() || parsed.description || undefined, argumentHint: normalizeArgumentHint(hintInput.value.trim()) || parsed.argumentHint || undefined, model: modelInput.value.trim() || parsed.model || undefined, allowedTools: resolveAllowedTools(toolsInput.value, parsed.allowedTools), content: promptContent, source: isSkillType ? 'user' : undefined, disableModelInvocation: disableModelToggle || undefined, userInvocable: disableUserInvocation ? false : undefined, context: contextValue || undefined, agent: contextValue === 'fork' ? (agentInput.value.trim() || undefined) : undefined, }; try { await this.onSave(cmd); } catch { const label = isSkillType ? 'skill' : 'slash command'; new Notice(`Failed to save ${label}`); return; } this.close(); }); const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { e.preventDefault(); this.close(); } }; contentEl.addEventListener('keydown', handleKeyDown); } onClose() { this.contentEl.empty(); } } export class SlashCommandSettings { private containerEl: HTMLElement; private plugin: ClaudianPlugin; constructor(containerEl: HTMLElement, plugin: ClaudianPlugin) { this.containerEl = containerEl; this.plugin = plugin; this.render(); } private render(): void { this.containerEl.empty(); const headerEl = this.containerEl.createDiv({ cls: 'claudian-sp-header' }); headerEl.createSpan({ text: t('settings.slashCommands.name'), cls: 'claudian-sp-label' }); const actionsEl = headerEl.createDiv({ cls: 'claudian-sp-header-actions' }); const addBtn = actionsEl.createEl('button', { cls: 'claudian-settings-action-btn', attr: { 'aria-label': 'Add' }, }); setIcon(addBtn, 'plus'); addBtn.addEventListener('click', () => this.openCommandModal(null)); const commands = this.plugin.settings.slashCommands; if (commands.length === 0) { const emptyEl = this.containerEl.createDiv({ cls: 'claudian-sp-empty-state' }); emptyEl.setText('No commands or skills configured. Click + to create one.'); return; } const listEl = this.containerEl.createDiv({ cls: 'claudian-sp-list' }); for (const cmd of commands) { this.renderCommandItem(listEl, cmd); } } private renderCommandItem(listEl: HTMLElement, cmd: SlashCommand): void { const itemEl = listEl.createDiv({ cls: 'claudian-sp-item' }); const infoEl = itemEl.createDiv({ cls: 'claudian-sp-info' }); const headerRow = infoEl.createDiv({ cls: 'claudian-sp-item-header' }); const nameEl = headerRow.createSpan({ cls: 'claudian-sp-item-name' }); nameEl.setText(`/${cmd.name}`); if (isSkill(cmd)) { headerRow.createSpan({ text: 'skill', cls: 'claudian-slash-item-badge' }); } if (cmd.argumentHint) { const hintEl = headerRow.createSpan({ cls: 'claudian-slash-item-hint' }); hintEl.setText(cmd.argumentHint); } if (cmd.description) { const descEl = infoEl.createDiv({ cls: 'claudian-sp-item-desc' }); descEl.setText(cmd.description); } const actionsEl = itemEl.createDiv({ cls: 'claudian-sp-item-actions' }); const editBtn = actionsEl.createEl('button', { cls: 'claudian-settings-action-btn', attr: { 'aria-label': 'Edit' }, }); setIcon(editBtn, 'pencil'); editBtn.addEventListener('click', () => this.openCommandModal(cmd)); if (!isSkill(cmd)) { const convertBtn = actionsEl.createEl('button', { cls: 'claudian-settings-action-btn', attr: { 'aria-label': 'Convert to skill' }, }); setIcon(convertBtn, 'package'); convertBtn.addEventListener('click', async () => { try { await this.transformToSkill(cmd); } catch { new Notice('Failed to convert to skill'); } }); } const deleteBtn = actionsEl.createEl('button', { cls: 'claudian-settings-action-btn claudian-settings-delete-btn', attr: { 'aria-label': 'Delete' }, }); setIcon(deleteBtn, 'trash-2'); deleteBtn.addEventListener('click', async () => { try { await this.deleteCommand(cmd); } catch { const label = isSkill(cmd) ? 'skill' : 'slash command'; new Notice(`Failed to delete ${label}`); } }); } private openCommandModal(existingCmd: SlashCommand | null): void { const modal = new SlashCommandModal( this.plugin.app, this.plugin, existingCmd, async (cmd) => { await this.saveCommand(cmd, existingCmd); } ); modal.open(); } private storageFor(cmd: SlashCommand) { return isSkill(cmd) ? this.plugin.storage.skills : this.plugin.storage.commands; } private async saveCommand(cmd: SlashCommand, existing: SlashCommand | null): Promise { // Save new file first (safer: if this fails, old file still exists) await this.storageFor(cmd).save(cmd); // Delete old file only after successful save (if name changed) if (existing && existing.name !== cmd.name) { await this.storageFor(existing).delete(existing.id); } await this.reloadCommands(); this.render(); const label = isSkill(cmd) ? 'Skill' : 'Slash command'; new Notice(`${label} "/${cmd.name}" ${existing ? 'updated' : 'created'}`); } private async deleteCommand(cmd: SlashCommand): Promise { await this.storageFor(cmd).delete(cmd.id); await this.reloadCommands(); this.render(); const label = isSkill(cmd) ? 'Skill' : 'Slash command'; new Notice(`${label} "/${cmd.name}" deleted`); } private async transformToSkill(cmd: SlashCommand): Promise { const skillName = cmd.name.toLowerCase().replace(/[^a-z0-9-]/g, '-').slice(0, 64); const existingSkill = this.plugin.settings.slashCommands.find( c => isSkill(c) && c.name === skillName ); if (existingSkill) { new Notice(`A skill named "/${skillName}" already exists`); return; } const description = cmd.description || extractFirstParagraph(cmd.content); const skill: SlashCommand = { ...cmd, id: `skill-${skillName}`, name: skillName, description, source: 'user', }; await this.plugin.storage.skills.save(skill); await this.plugin.storage.commands.delete(cmd.id); await this.reloadCommands(); this.render(); new Notice(`Converted "/${cmd.name}" to skill`); } private async reloadCommands(): Promise { this.plugin.settings.slashCommands = await this.plugin.storage.loadAllSlashCommands(); } public refresh(): void { this.render(); } } ================================================ FILE: src/i18n/constants.ts ================================================ /** * i18n Constants and Utilities * * Centralized constants for language management and UI display */ import type { Locale } from './types'; /** * Supported locales with metadata */ export interface LocaleInfo { code: Locale; name: string; // Native name englishName: string; // English name flag?: string; // Optional flag emoji } /** * All supported locales with display information */ export const SUPPORTED_LOCALES: LocaleInfo[] = [ { code: 'en', name: 'English', englishName: 'English', flag: '🇺🇸' }, { code: 'zh-CN', name: '简体中文', englishName: 'Simplified Chinese', flag: '🇨🇳' }, { code: 'zh-TW', name: '繁體中文', englishName: 'Traditional Chinese', flag: '🇹🇼' }, { code: 'ja', name: '日本語', englishName: 'Japanese', flag: '🇯🇵' }, { code: 'ko', name: '한국어', englishName: 'Korean', flag: '🇰🇷' }, { code: 'de', name: 'Deutsch', englishName: 'German', flag: '🇩🇪' }, { code: 'fr', name: 'Français', englishName: 'French', flag: '🇫🇷' }, { code: 'es', name: 'Español', englishName: 'Spanish', flag: '🇪🇸' }, { code: 'ru', name: 'Русский', englishName: 'Russian', flag: '🇷🇺' }, { code: 'pt', name: 'Português', englishName: 'Portuguese', flag: '🇧🇷' }, ]; /** * Default locale */ export const DEFAULT_LOCALE: Locale = 'en'; /** * Get locale info by code */ export function getLocaleInfo(code: Locale): LocaleInfo | undefined { return SUPPORTED_LOCALES.find(locale => locale.code === code); } /** * Get display string for locale (with optional flag) */ export function getLocaleDisplayString(code: Locale, includeFlag = true): string { const info = getLocaleInfo(code); if (!info) return code; return includeFlag && info.flag ? `${info.flag} ${info.name} (${info.englishName})` : `${info.name} (${info.englishName})`; } ================================================ FILE: src/i18n/i18n.ts ================================================ /** * i18n - Internationalization service for Claudian * * Provides translation functionality for all UI strings. * Supports 10 locales with English as the default fallback. */ import * as de from './locales/de.json'; import * as en from './locales/en.json'; import * as es from './locales/es.json'; import * as fr from './locales/fr.json'; import * as ja from './locales/ja.json'; import * as ko from './locales/ko.json'; import * as pt from './locales/pt.json'; import * as ru from './locales/ru.json'; import * as zhCN from './locales/zh-CN.json'; import * as zhTW from './locales/zh-TW.json'; import type { Locale, TranslationKey } from './types'; const translations: Record = { en, 'zh-CN': zhCN, 'zh-TW': zhTW, ja, ko, de, fr, es, ru, pt, }; const DEFAULT_LOCALE: Locale = 'en'; let currentLocale: Locale = DEFAULT_LOCALE; /** * Get a translation by key with optional parameters */ export function t(key: TranslationKey, params?: Record): string { const dict = translations[currentLocale]; const keys = key.split('.'); let value: any = dict; for (const k of keys) { if (value && typeof value === 'object' && k in value) { value = value[k]; } else { if (currentLocale !== DEFAULT_LOCALE) { return tFallback(key, params); } return key; } } if (typeof value !== 'string') { return key; } if (params) { return value.replace(/\{(\w+)\}/g, (_, param) => { return params[param]?.toString() ?? `{${param}}`; }); } return value; } function tFallback(key: TranslationKey, params?: Record): string { const dict = translations[DEFAULT_LOCALE]; const keys = key.split('.'); let value: any = dict; for (const k of keys) { if (value && typeof value === 'object' && k in value) { value = value[k]; } else { return key; } } if (typeof value !== 'string') { return key; } if (params) { return value.replace(/\{(\w+)\}/g, (_, param) => { return params[param]?.toString() ?? `{${param}}`; }); } return value; } /** * Set the current locale * @returns true if locale was set successfully, false if locale is invalid */ export function setLocale(locale: Locale): boolean { if (!translations[locale]) { return false; } currentLocale = locale; return true; } /** * Get the current locale */ export function getLocale(): Locale { return currentLocale; } /** * Get all available locales */ export function getAvailableLocales(): Locale[] { return Object.keys(translations) as Locale[]; } /** * Get display name for a locale */ export function getLocaleDisplayName(locale: Locale): string { const names: Record = { 'en': 'English', 'zh-CN': '简体中文', 'zh-TW': '繁體中文', 'ja': '日本語', 'ko': '한국어', 'de': 'Deutsch', 'fr': 'Français', 'es': 'Español', 'ru': 'Русский', 'pt': 'Português', }; return names[locale] || locale; } ================================================ FILE: src/i18n/index.ts ================================================ // Types export type { LocaleInfo } from './constants'; export type { Locale, TranslationKey } from './types'; // Core i18n functions export { getAvailableLocales, getLocale, getLocaleDisplayName, setLocale, t, } from './i18n'; // Constants and utilities export { DEFAULT_LOCALE, getLocaleDisplayString, getLocaleInfo, SUPPORTED_LOCALES, } from './constants'; ================================================ FILE: src/i18n/locales/de.json ================================================ { "common": { "save": "Speichern", "cancel": "Abbrechen", "delete": "Löschen", "edit": "Bearbeiten", "add": "Hinzufügen", "remove": "Entfernen", "clear": "Löschen", "clearAll": "Alle löschen", "loading": "Lädt", "error": "Fehler", "success": "Erfolg", "warning": "Warnung", "confirm": "Bestätigen", "settings": "Einstellungen", "advanced": "Erweitert", "enabled": "Aktiviert", "disabled": "Deaktiviert", "platform": "Plattform", "refresh": "Aktualisieren", "rewind": "Zurückspulen" }, "chat": { "rewind": { "confirmMessage": "Zu diesem Punkt zurückspulen? Dateiänderungen nach dieser Nachricht werden rückgängig gemacht. Das Zurückspulen betrifft keine manuell oder über Bash bearbeiteten Dateien.", "confirmButton": "Zurückspulen", "ariaLabel": "Hierher zurückspulen", "notice": "Zurückgespult: {count} Datei(en) wiederhergestellt", "noticeSaveFailed": "Zurückgespult: {count} Datei(en) wiederhergestellt, aber Status konnte nicht gespeichert werden: {error}", "failed": "Zurückspulen fehlgeschlagen: {error}", "cannot": "Zurückspulen nicht möglich: {error}", "unavailableStreaming": "Zurückspulen während des Streamings nicht möglich", "unavailableNoUuid": "Zurückspulen nicht möglich: Nachrichtenkennungen fehlen" }, "fork": { "ariaLabel": "Konversation verzweigen", "chooseTarget": "Konversation verzweigen", "targetNewTab": "Neuer Tab", "targetCurrentTab": "Aktueller Tab", "maxTabsReached": "Verzweigung nicht möglich: maximal {count} Tabs erreicht", "notice": "In neuem Tab verzweigt", "noticeCurrentTab": "Im aktuellen Tab verzweigt", "failed": "Verzweigung fehlgeschlagen: {error}", "unavailableStreaming": "Verzweigung während des Streamings nicht möglich", "unavailableNoUuid": "Verzweigung nicht möglich: Nachrichtenkennungen fehlen", "unavailableNoResponse": "Verzweigung nicht möglich: keine Antwort zum Verzweigen vorhanden", "errorMessageNotFound": "Nachricht nicht gefunden", "errorNoSession": "Keine Sitzungs-ID verfügbar", "errorNoActiveTab": "Kein aktiver Tab", "commandNoMessages": "Verzweigung nicht möglich: keine Nachrichten in der Konversation", "commandNoAssistantUuid": "Verzweigung nicht möglich: keine Assistentenantwort mit Kennungen" }, "bangBash": { "placeholder": "> Einen Bash-Befehl ausführen...", "commandPanel": "Befehlspanel", "copyAriaLabel": "Neueste Befehlsausgabe kopieren", "clearAriaLabel": "Bash-Ausgabe löschen", "commandLabel": "{command}", "statusLabel": "Status des Befehls: {status}", "collapseOutput": "Befehlsausgabe einklappen", "expandOutput": "Befehlsausgabe ausklappen", "running": "Wird ausgeführt...", "copyFailed": "Kopieren in die Zwischenablage fehlgeschlagen" } }, "settings": { "title": "Claudian Einstellungen", "customization": "Anpassung", "userName": { "name": "Wie soll Claudian dich nennen?", "desc": "Dein Name für personalisierte Begrüßungen (leer lassen für allgemeine Begrüßungen)" }, "excludedTags": { "name": "Ausgeschlossene Tags", "desc": "Notizen mit diesen Tags werden nicht automatisch als Kontext geladen (einer pro Zeile, ohne #)" }, "mediaFolder": { "name": "Medienordner", "desc": "Ordner mit Anhängen/Bildern. Wenn Notizen ![[image.jpg]] verwenden, sucht Claude hier. Leer lassen für Vault-Stammverzeichnis." }, "systemPrompt": { "name": "Benutzerdefinierter System-Prompt", "desc": "Zusätzliche Anweisungen, die an den Standard-System-Prompt angehängt werden" }, "autoTitle": { "name": "Konversationstitel automatisch generieren", "desc": "Generiert automatisch Konversationstitel nach der ersten Nutzernachricht." }, "titleModel": { "name": "Titel-Generierungsmodell", "desc": "Modell zur automatischen Generierung von Konversationstiteln.", "auto": "Automatisch (Haiku)" }, "navMappings": { "name": "Vim-Style Navigationszuordnungen", "desc": "Eine Zuordnung pro Zeile. Format: \"map \" (Aktionen: scrollUp, scrollDown, focusInput)." }, "hotkeys": "Tastenkürzel", "inlineEditHotkey": { "name": "Inline-Bearbeitung", "descWithKey": "Aktuelles Tastenkürzel: {hotkey}", "descNoKey": "Kein Tastenkürzel festgelegt", "btnChange": "Ändern", "btnSet": "Festlegen" }, "openChatHotkey": { "name": "Chat öffnen", "descWithKey": "Aktuelles Tastenkürzel: {hotkey}", "descNoKey": "Kein Tastenkürzel festgelegt", "btnChange": "Ändern", "btnSet": "Festlegen" }, "newSessionHotkey": { "name": "Neue Sitzung", "descWithKey": "Aktuelles Tastenkürzel: {hotkey}", "descNoKey": "Kein Tastenkürzel festgelegt", "btnChange": "Ändern", "btnSet": "Festlegen" }, "newTabHotkey": { "name": "Neuer Tab", "descWithKey": "Aktuelles Tastenkürzel: {hotkey}", "descNoKey": "Kein Tastenkürzel festgelegt", "btnChange": "Ändern", "btnSet": "Festlegen" }, "closeTabHotkey": { "name": "Tab schließen", "descWithKey": "Aktuelles Tastenkürzel: {hotkey}", "descNoKey": "Kein Tastenkürzel festgelegt", "btnChange": "Ändern", "btnSet": "Festlegen" }, "slashCommands": { "name": "Befehle und Fähigkeiten", "desc": "Definiere benutzerdefinierte Befehle und Fähigkeiten, die durch /Name ausgelöst werden." }, "hiddenSlashCommands": { "name": "Ausgeblendete Befehle", "desc": "Bestimmte Schrägstrich-Befehle aus dem Dropdown ausblenden. Nützlich, um Claude Code-Befehle auszublenden, die für Claudian nicht relevant sind. Gib Befehlsnamen ohne führenden Schrägstrich ein, einen pro Zeile.", "placeholder": "commit\nbuild\ntest" }, "mcpServers": { "name": "MCP-Server", "desc": "Konfiguriere Model Context Protocol Server, um Claude mit externen Tools und Datenquellen zu erweitern. Server mit Kontext-Speichermodus benötigen @mention zur Aktivierung." }, "plugins": { "name": "Claude Code-Plugins", "desc": "Aktiviere oder deaktiviere Claude Code Plugins aus ~/.claude/plugins. Aktivierte Plugins werden pro Vault gespeichert." }, "subagents": { "name": "Sub-Agenten", "desc": "Konfiguriere benutzerdefinierte Sub-Agenten, an die Claude delegieren kann.", "noAgents": "Keine Sub-Agenten konfiguriert. Klicke auf +, um einen zu erstellen.", "deleteConfirm": "Sub-Agent \"{name}\" löschen?", "saveFailed": "Sub-Agent konnte nicht gespeichert werden: {message}", "refreshFailed": "Sub-Agenten konnten nicht aktualisiert werden: {message}", "deleteFailed": "Sub-Agent konnte nicht gelöscht werden: {message}", "renameCleanupFailed": "Warnung: Alte Datei für \"{name}\" konnte nicht entfernt werden", "created": "Sub-Agent \"{name}\" erstellt", "updated": "Sub-Agent \"{name}\" aktualisiert", "deleted": "Sub-Agent \"{name}\" gelöscht", "duplicateName": "Ein Agent mit dem Namen \"{name}\" existiert bereits", "descriptionRequired": "Beschreibung ist erforderlich", "promptRequired": "System-Prompt ist erforderlich", "modal": { "titleEdit": "Sub-Agent bearbeiten", "titleAdd": "Sub-Agent hinzufügen", "name": "Name", "nameDesc": "Nur Kleinbuchstaben, Zahlen und Bindestriche", "namePlaceholder": "code-reviewer", "description": "Beschreibung", "descriptionDesc": "Kurzbeschreibung dieses Agenten", "descriptionPlaceholder": "Prüft Code auf Fehler und Stil", "advancedOptions": "Erweiterte Optionen", "model": "Modell", "modelDesc": "Modellüberschreibung für diesen Agenten", "tools": "Tools", "toolsDesc": "Kommagetrennte Liste zulässiger Tools (leer = alle)", "disallowedTools": "Nicht erlaubte Tools", "disallowedToolsDesc": "Kommagetrennte Liste der zu verbietenden Tools", "skills": "Fähigkeiten", "skillsDesc": "Kommagetrennte Liste von Fähigkeiten", "prompt": "System-Prompt", "promptDesc": "Anweisungen für den Agenten", "promptPlaceholder": "Du bist ein Code-Reviewer. Analysiere den angegebenen Code auf..." } }, "safety": "Sicherheit", "loadUserSettings": { "name": "Benutzer-Claude-Einstellungen laden", "desc": "Lädt ~/.claude/settings.json. Wenn aktiviert, können Benutzer-Claude-Code-Berechtigungsregeln den Sicherheitsmodus umgehen." }, "enableBlocklist": { "name": "Befehlsblockliste aktivieren", "desc": "Blockiert potenziell gefährliche Bash-Befehle" }, "allowExternalAccess": { "name": "Externen Zugriff erlauben", "desc": "Erlaubt Datei- und Befehlszugriff außerhalb des Vault-Verzeichnisses. Wird sofort für alle aktiven Sitzungen wirksam. Das Deaktivieren der Vault-Beschränkung kann sensible Dateien für Prompt-Injection anfällig machen." }, "blockedCommands": { "name": "Blockierte Befehle ({platform})", "desc": "Muster zum Blockieren auf {platform} (einer pro Zeile). Unterstützt Regex.", "unixName": "Blockierte Befehle (Unix/Git Bash)", "unixDesc": "Unix-Muster werden auch auf Windows blockiert, da Git Bash sie aufrufen kann." }, "exportPaths": { "name": "Zugelassene Exportpfade", "desc": "Nur-Schreib-Ziele außerhalb des Vaults, wenn externer Zugriff deaktiviert ist (einer pro Zeile). Unterstützt ~ für das Home-Verzeichnis.", "disabledDesc": "Wird ignoriert, solange externer Zugriff aktiviert ist. Deaktiviere externen Zugriff, um Nur-Schreib-Exportpfade zu erzwingen." }, "environment": "Umgebung", "customVariables": { "name": "Benutzerdefinierte Variablen", "desc": "Umgebungsvariablen für Claude SDK (KEY=VALUE-Format, eine pro Zeile). Export-Präfix unterstützt." }, "envSnippets": { "name": "Snippets", "addBtn": "Snippet hinzufügen", "noSnippets": "Keine gespeicherten Umgebungsvariablen-Snippets. Klicken Sie auf +, um Ihre aktuelle Konfiguration zu speichern.", "nameRequired": "Bitte geben Sie einen Namen für das Snippet ein", "modal": { "titleEdit": "Snippet bearbeiten", "titleSave": "Snippet speichern", "name": "Name", "namePlaceholder": "Ein beschreibender Name für diese Umgebungskonfiguration", "description": "Beschreibung", "descPlaceholder": "Optionale Beschreibung", "envVars": "Umgebungsvariablen", "envVarsPlaceholder": "KEY=VALUE-Format, eine pro Zeile (export-Präfix unterstützt)", "save": "Speichern", "update": "Aktualisieren", "cancel": "Abbrechen" } }, "customContextLimits": { "name": "Benutzerdefinierte Kontextlimits", "desc": "Legen Sie die Kontextfenstergrößen für Ihre benutzerdefinierten Modelle fest. Leer lassen für den Standardwert (200k Token).", "invalid": "Ungültiges Format. Verwenden Sie: 256k, 1m oder exakte Anzahl (1000-10000000)." }, "advanced": "Erweitert", "enableOpus1M": { "name": "Opus 1M Kontextfenster", "desc": "Opus 1M in der Modellauswahl anzeigen. In Max-, Team- und Enterprise-Plänen enthalten. API- und Pro-Nutzer benötigen zusätzliche Nutzung." }, "enableSonnet1M": { "name": "Sonnet 1M Kontextfenster", "desc": "Sonnet 1M in der Modellauswahl anzeigen. Erfordert zusätzliche Nutzung bei Max-, Team- und Enterprise-Plänen. API- und Pro-Nutzer benötigen zusätzliche Nutzung." }, "enableChrome": { "name": "Chrome-Erweiterung aktivieren", "desc": "Erlaubt Claude die Interaktion mit Chrome über die claude-in-chrome-Erweiterung. Die Erweiterung muss installiert sein. Erfordert Neustart der Sitzung." }, "enableBangBash": { "name": "Bash-Modus (!) aktivieren", "desc": "Gib ! in ein leeres Eingabefeld ein, um den Bash-Modus zu starten. Führt Befehle direkt über Node.js child_process aus. Die Ansicht muss neu geöffnet werden.", "validation": { "noNode": "Node.js wurde auf PATH nicht gefunden. Installiere Node.js oder prüfe deine PATH-Konfiguration." } }, "maxTabs": { "name": "Maximale Chat-Tabs", "desc": "Maximale Anzahl gleichzeitiger Chat-Tabs (3-10). Jeder Tab verwendet eine separate Claude-Sitzung.", "warning": "Mehr als 5 Tabs können Leistung und Speichernutzung beeinträchtigen." }, "tabBarPosition": { "name": "Tab-Leiste Position", "desc": "Wählen Sie, wo Tab-Badges und Aktionsschaltflächen angezeigt werden", "input": "Über Eingabefeld (Standard)", "header": "In Kopfzeile" }, "enableAutoScroll": { "name": "Automatisches Scrollen während Streaming", "desc": "Automatisch nach unten scrollen, während Claude Antworten streamt. Deaktivieren, um oben zu bleiben und von Anfang an zu lesen." }, "openInMainTab": { "name": "Im Haupteditorbereich öffnen", "desc": "Chat-Panel als Haupttab im zentralen Editorbereich statt in der rechten Seitenleiste öffnen" }, "cliPath": { "name": "Claude CLI-Pfad", "desc": "Benutzerdefinierter Pfad zum Claude Code CLI. Leer lassen für automatische Erkennung.", "descWindows": "Für den nativen Installer verwenden Sie claude.exe. Für npm/pnpm/yarn oder andere Paketmanager-Installationen verwenden Sie den cli.js-Pfad (nicht claude.cmd).", "descUnix": "Fügen Sie die Ausgabe von \"which claude\" ein — funktioniert sowohl für native als auch npm/pnpm/yarn-Installationen.", "validation": { "notExist": "Pfad existiert nicht", "isDirectory": "Pfad ist ein Verzeichnis, keine Datei" } }, "language": { "name": "Sprache", "desc": "Anzeigesprache der Plugin-Oberfläche ändern" } } } ================================================ FILE: src/i18n/locales/en.json ================================================ { "common": { "save": "Save", "cancel": "Cancel", "delete": "Delete", "edit": "Edit", "add": "Add", "remove": "Remove", "clear": "Clear", "clearAll": "Clear all", "loading": "Loading", "error": "Error", "success": "Success", "warning": "Warning", "confirm": "Confirm", "settings": "Settings", "advanced": "Advanced", "enabled": "Enabled", "disabled": "Disabled", "platform": "Platform", "refresh": "Refresh", "rewind": "Rewind" }, "chat": { "rewind": { "confirmMessage": "Rewind to this point? File changes after this message will be reverted. Rewinding does not affect files edited manually or via bash.", "confirmButton": "Rewind", "ariaLabel": "Rewind to here", "notice": "Rewound: {count} file(s) reverted", "noticeSaveFailed": "Rewound: {count} file(s) reverted, but failed to save state: {error}", "failed": "Rewind failed: {error}", "cannot": "Cannot rewind: {error}", "unavailableStreaming": "Cannot rewind while streaming", "unavailableNoUuid": "Cannot rewind: missing message identifiers" }, "fork": { "ariaLabel": "Fork conversation", "chooseTarget": "Fork conversation", "targetNewTab": "New tab", "targetCurrentTab": "Current tab", "maxTabsReached": "Cannot fork: maximum {count} tabs reached", "notice": "Forked to new tab", "noticeCurrentTab": "Forked in current tab", "failed": "Fork failed: {error}", "unavailableStreaming": "Cannot fork while streaming", "unavailableNoUuid": "Cannot fork: missing message identifiers", "unavailableNoResponse": "Cannot fork: no response to fork from", "errorMessageNotFound": "Message not found", "errorNoSession": "No session ID available", "errorNoActiveTab": "No active tab", "commandNoMessages": "Cannot fork: no messages in conversation", "commandNoAssistantUuid": "Cannot fork: no assistant response with identifiers" }, "bangBash": { "placeholder": "> Run a bash command...", "commandPanel": "Command panel", "copyAriaLabel": "Copy latest command output", "clearAriaLabel": "Clear bash output", "commandLabel": "{command}", "statusLabel": "Status: {status}", "collapseOutput": "Collapse command output", "expandOutput": "Expand command output", "running": "Running...", "copyFailed": "Failed to copy to clipboard" } }, "settings": { "title": "Claudian Settings", "customization": "Customization", "userName": { "name": "What should Claudian call you?", "desc": "Your name for personalized greetings (leave empty for generic greetings)" }, "excludedTags": { "name": "Excluded tags", "desc": "Notes with these tags will not auto-load as context (one per line, without #)" }, "mediaFolder": { "name": "Media folder", "desc": "Folder containing attachments/images. When notes use ![[image.jpg]], Claude will look here. Leave empty for vault root." }, "systemPrompt": { "name": "Custom system prompt", "desc": "Additional instructions appended to the default system prompt" }, "autoTitle": { "name": "Auto-generate conversation titles", "desc": "Automatically generate conversation titles after the first user message is sent." }, "titleModel": { "name": "Title generation model", "desc": "Model used for auto-generating conversation titles.", "auto": "Auto (Haiku)" }, "navMappings": { "name": "Vim-style navigation mappings", "desc": "One mapping per line. Format: \"map \" (actions: scrollUp, scrollDown, focusInput)." }, "hotkeys": "Hotkeys", "inlineEditHotkey": { "name": "Inline Edit", "descWithKey": "Current hotkey: {hotkey}", "descNoKey": "No hotkey set", "btnChange": "Change", "btnSet": "Set hotkey" }, "openChatHotkey": { "name": "Open Chat", "descWithKey": "Current hotkey: {hotkey}", "descNoKey": "No hotkey set", "btnChange": "Change", "btnSet": "Set hotkey" }, "newSessionHotkey": { "name": "New Session", "descWithKey": "Current hotkey: {hotkey}", "descNoKey": "No hotkey set", "btnChange": "Change", "btnSet": "Set hotkey" }, "newTabHotkey": { "name": "New Tab", "descWithKey": "Current hotkey: {hotkey}", "descNoKey": "No hotkey set", "btnChange": "Change", "btnSet": "Set hotkey" }, "closeTabHotkey": { "name": "Close Tab", "descWithKey": "Current hotkey: {hotkey}", "descNoKey": "No hotkey set", "btnChange": "Change", "btnSet": "Set hotkey" }, "slashCommands": { "name": "Commands and Skills", "desc": "Define custom commands and skills triggered by /name." }, "hiddenSlashCommands": { "name": "Hidden Commands", "desc": "Hide specific slash commands from the dropdown. Useful for hiding Claude Code commands that are not relevant to Claudian. Enter command names without the leading slash, one per line.", "placeholder": "commit\nbuild\ntest" }, "mcpServers": { "name": "MCP Servers", "desc": "Configure Model Context Protocol servers to extend Claude's capabilities with external tools and data sources. Servers with context-saving mode require @mention to activate." }, "plugins": { "name": "Claude Code Plugins", "desc": "Enable or disable Claude Code plugins discovered from ~/.claude/plugins. Enabled plugins are stored per vault." }, "subagents": { "name": "Subagents", "desc": "Configure custom subagents that Claude can delegate to.", "noAgents": "No subagents configured. Click + to create one.", "deleteConfirm": "Delete subagent \"{name}\"?", "saveFailed": "Failed to save subagent: {message}", "refreshFailed": "Failed to refresh subagents: {message}", "deleteFailed": "Failed to delete subagent: {message}", "renameCleanupFailed": "Warning: could not remove old file for \"{name}\"", "created": "Subagent \"{name}\" created", "updated": "Subagent \"{name}\" updated", "deleted": "Subagent \"{name}\" deleted", "duplicateName": "An agent named \"{name}\" already exists", "descriptionRequired": "Description is required", "promptRequired": "System prompt is required", "modal": { "titleEdit": "Edit Subagent", "titleAdd": "Add Subagent", "name": "Name", "nameDesc": "Lowercase letters, numbers, and hyphens only", "namePlaceholder": "code-reviewer", "description": "Description", "descriptionDesc": "Brief description of this agent", "descriptionPlaceholder": "Reviews code for bugs and style", "advancedOptions": "Advanced options", "model": "Model", "modelDesc": "Model override for this agent", "tools": "Tools", "toolsDesc": "Comma-separated list of allowed tools (empty = all)", "disallowedTools": "Disallowed tools", "disallowedToolsDesc": "Comma-separated list of tools to disallow", "skills": "Skills", "skillsDesc": "Comma-separated list of skills", "prompt": "System prompt", "promptDesc": "Instructions for the agent", "promptPlaceholder": "You are a code reviewer. Analyze the given code for..." } }, "safety": "Safety", "loadUserSettings": { "name": "Load user Claude settings", "desc": "Load ~/.claude/settings.json. When enabled, user's Claude Code permission rules may bypass Safe mode." }, "enableBlocklist": { "name": "Enable command blocklist", "desc": "Block potentially dangerous bash commands" }, "allowExternalAccess": { "name": "Allow external access", "desc": "Allow file and command access outside the vault directory. Takes effect immediately for all active sessions. Disabling vault restriction may expose sensitive files to prompt injection." }, "blockedCommands": { "name": "Blocked commands ({platform})", "desc": "Patterns to block on {platform} (one per line). Supports regex.", "unixName": "Blocked commands (Unix/Git Bash)", "unixDesc": "Unix patterns also blocked on Windows because Git Bash can invoke them." }, "exportPaths": { "name": "Allowed export paths", "desc": "Write-only destinations outside the vault when external access is off (one per line). Supports ~ for home directory.", "disabledDesc": "Ignored while external access is enabled. Turn off external access to enforce write-only export paths." }, "environment": "Environment", "customVariables": { "name": "Custom variables", "desc": "Environment variables for Claude SDK (KEY=VALUE format, one per line). Shell export prefix supported." }, "envSnippets": { "name": "Snippets", "addBtn": "Add snippet", "noSnippets": "No saved environment snippets yet. Click + to save your current environment configuration.", "nameRequired": "Please enter a name for the snippet", "modal": { "titleEdit": "Edit snippet", "titleSave": "Save snippet", "name": "Name", "namePlaceholder": "A descriptive name for this environment configuration", "description": "Description", "descPlaceholder": "Optional description", "envVars": "Environment variables", "envVarsPlaceholder": "KEY=VALUE format, one per line (export prefix supported)", "save": "Save", "update": "Update", "cancel": "Cancel" } }, "customContextLimits": { "name": "Custom Context Limits", "desc": "Set context window sizes for your custom models. Leave empty to use the default (200k tokens).", "invalid": "Invalid format. Use: 256k, 1m, or exact count (1000-10000000)." }, "advanced": "Advanced", "enableOpus1M": { "name": "Opus 1M context window", "desc": "Show Opus 1M in the model selector. Included with Max, Team, and Enterprise plans. API and Pro users need extra usage." }, "enableSonnet1M": { "name": "Sonnet 1M context window", "desc": "Show Sonnet 1M in the model selector. Requires extra usage on Max, Team, and Enterprise plans. API and Pro users need extra usage." }, "enableChrome": { "name": "Enable Chrome extension", "desc": "Allow Claude to interact with Chrome via the claude-in-chrome extension. Requires the extension to be installed. Requires session restart." }, "enableBangBash": { "name": "Enable bash mode (!)", "desc": "Type ! on empty input to enter bash mode. Runs commands directly via Node.js child_process. Requires view reopen.", "validation": { "noNode": "Node.js not found on PATH. Install Node.js or check your PATH configuration." } }, "maxTabs": { "name": "Maximum chat tabs", "desc": "Maximum number of concurrent chat tabs (3-10). Each tab uses a separate Claude session.", "warning": "More than 5 tabs may impact performance and memory usage." }, "tabBarPosition": { "name": "Tab bar position", "desc": "Choose where to display tab badges and action buttons", "input": "Above input (default)", "header": "In header" }, "enableAutoScroll": { "name": "Auto-scroll during streaming", "desc": "Automatically scroll to the bottom as Claude streams responses. Disable to stay at the top and read from the beginning." }, "openInMainTab": { "name": "Open in main editor area", "desc": "Open chat panel as a main tab in the center editor area instead of the right sidebar" }, "cliPath": { "name": "Claude CLI path", "desc": "Custom path to Claude Code CLI. Leave empty for auto-detection.", "descWindows": "For the native installer, use claude.exe. For npm/pnpm/yarn or other package manager installs, use the cli.js path (not claude.cmd).", "descUnix": "Paste the output of \"which claude\" — works for both native and npm/pnpm/yarn installs.", "validation": { "notExist": "Path does not exist", "isDirectory": "Path is a directory, not a file" } }, "language": { "name": "Language", "desc": "Change the display language of the plugin interface" } } } ================================================ FILE: src/i18n/locales/es.json ================================================ { "common": { "save": "Guardar", "cancel": "Cancelar", "delete": "Eliminar", "edit": "Editar", "add": "Agregar", "remove": "Eliminar", "clear": "Limpiar", "clearAll": "Limpiar todo", "loading": "Cargando", "error": "Error", "success": "Éxito", "warning": "Advertencia", "confirm": "Confirmar", "settings": "Configuración", "advanced": "Avanzado", "enabled": "Habilitado", "disabled": "Deshabilitado", "platform": "Plataforma", "refresh": "Actualizar", "rewind": "Rebobinar" }, "chat": { "rewind": { "confirmMessage": "¿Rebobinar a este punto? Los cambios de archivos después de este mensaje serán revertidos. El rebobinado no afecta archivos editados manualmente o mediante bash.", "confirmButton": "Rebobinar", "ariaLabel": "Rebobinar hasta aquí", "notice": "Rebobinado: {count} archivo(s) revertido(s)", "noticeSaveFailed": "Rebobinado: {count} archivo(s) revertido(s), pero no se pudo guardar el estado: {error}", "failed": "Error al rebobinar: {error}", "cannot": "No se puede rebobinar: {error}", "unavailableStreaming": "No se puede rebobinar durante la transmisión", "unavailableNoUuid": "No se puede rebobinar: faltan identificadores de mensaje" }, "fork": { "ariaLabel": "Bifurcar conversación", "chooseTarget": "Bifurcar conversación", "targetNewTab": "Nueva pestaña", "targetCurrentTab": "Pestaña actual", "maxTabsReached": "No se puede bifurcar: máximo de {count} pestañas alcanzado", "notice": "Bifurcado a nueva pestaña", "noticeCurrentTab": "Bifurcado en pestaña actual", "failed": "Error al bifurcar: {error}", "unavailableStreaming": "No se puede bifurcar durante la transmisión", "unavailableNoUuid": "No se puede bifurcar: faltan identificadores de mensaje", "unavailableNoResponse": "No se puede bifurcar: no hay respuesta para bifurcar", "errorMessageNotFound": "Mensaje no encontrado", "errorNoSession": "No hay ningún ID de sesión disponible", "errorNoActiveTab": "No hay ninguna pestaña activa", "commandNoMessages": "No se puede bifurcar: no hay mensajes en la conversación", "commandNoAssistantUuid": "No se puede bifurcar: no hay respuesta del asistente con identificadores" }, "bangBash": { "placeholder": "> Ejecuta un comando bash...", "commandPanel": "Panel de comandos", "copyAriaLabel": "Copiar la salida del comando más reciente", "clearAriaLabel": "Limpiar la salida de bash", "commandLabel": "{command}", "statusLabel": "Estado: {status}", "collapseOutput": "Contraer la salida del comando", "expandOutput": "Expandir la salida del comando", "running": "Ejecutando...", "copyFailed": "No se pudo copiar al portapapeles" } }, "settings": { "title": "Configuración de Claudian", "customization": "Personalización", "userName": { "name": "¿Cómo debería Claudian llamarte?", "desc": "Tu nombre para saludos personalizados (dejar vacío para saludos genéricos)" }, "excludedTags": { "name": "Etiquetas excluidas", "desc": "Las notas con estas etiquetas no se cargarán automáticamente como contexto (una por línea, sin #)" }, "mediaFolder": { "name": "Carpeta de medios", "desc": "Carpeta que contiene archivos adjuntos/imagenes. Cuando las notas usan ![[image.jpg]], Claude buscará aquí. Dejar vacío para la raíz del depósito." }, "systemPrompt": { "name": "Prompt de sistema personalizado", "desc": "Instrucciones adicionales añadidas al prompt de sistema por defecto" }, "autoTitle": { "name": "Generar automáticamente títulos de conversación", "desc": "Genera automáticamente títulos de conversación después del primer mensaje del usuario." }, "titleModel": { "name": "Modelo de generación de títulos", "desc": "Modelo utilizado para generar automáticamente títulos de conversación.", "auto": "Automático (Haiku)" }, "navMappings": { "name": "Mapeos de navegación estilo Vim", "desc": "Un mapeo por línea. Formato: \"map \" (acciones: scrollUp, scrollDown, focusInput)." }, "hotkeys": "Atajos de teclado", "inlineEditHotkey": { "name": "Edición en línea", "descWithKey": "Atajo actual: {hotkey}", "descNoKey": "Sin atajo configurado", "btnChange": "Cambiar", "btnSet": "Configurar" }, "openChatHotkey": { "name": "Abrir chat", "descWithKey": "Atajo actual: {hotkey}", "descNoKey": "Sin atajo configurado", "btnChange": "Cambiar", "btnSet": "Configurar" }, "newSessionHotkey": { "name": "Nueva sesión", "descWithKey": "Atajo actual: {hotkey}", "descNoKey": "Sin atajo configurado", "btnChange": "Cambiar", "btnSet": "Configurar" }, "newTabHotkey": { "name": "Nueva pestaña", "descWithKey": "Atajo actual: {hotkey}", "descNoKey": "Sin atajo configurado", "btnChange": "Cambiar", "btnSet": "Configurar" }, "closeTabHotkey": { "name": "Cerrar pestaña", "descWithKey": "Atajo actual: {hotkey}", "descNoKey": "Sin atajo configurado", "btnChange": "Cambiar", "btnSet": "Configurar" }, "slashCommands": { "name": "Comandos y habilidades", "desc": "Define comandos y habilidades personalizados activados por /nombre." }, "hiddenSlashCommands": { "name": "Comandos ocultos", "desc": "Oculta comandos slash específicos del menú desplegable. Útil para ocultar comandos de Claude Code que no son relevantes para Claudian. Ingresa nombres de comandos sin la barra inicial, uno por línea.", "placeholder": "commit\nbuild\ntest" }, "mcpServers": { "name": "Servidores MCP", "desc": "Configura servidores Model Context Protocol para extender las capacidades de Claude con herramientas y fuentes de datos externas. Los servidores con modo de guardado de contexto requieren @mention para activarse." }, "plugins": { "name": "Plugins de Claude Code", "desc": "Habilita o deshabilita plugins de Claude Code descubiertos desde ~/.claude/plugins. Los plugins habilitados se almacenan por bóveda." }, "subagents": { "name": "Subagentes", "desc": "Configura subagentes personalizados a los que Claude puede delegar.", "noAgents": "No hay subagentes configurados. Haz clic en + para crear uno.", "deleteConfirm": "¿Eliminar el subagente \"{name}\"?", "saveFailed": "No se pudo guardar el subagente: {message}", "refreshFailed": "No se pudieron actualizar los subagentes: {message}", "deleteFailed": "No se pudo eliminar el subagente: {message}", "renameCleanupFailed": "Advertencia: no se pudo eliminar el archivo anterior de \"{name}\"", "created": "Se creó el subagente \"{name}\"", "updated": "Se actualizó el subagente \"{name}\"", "deleted": "Se eliminó el subagente \"{name}\"", "duplicateName": "Ya existe un agente con el nombre \"{name}\"", "descriptionRequired": "La descripción es obligatoria", "promptRequired": "El prompt del sistema es obligatorio", "modal": { "titleEdit": "Editar subagente", "titleAdd": "Agregar subagente", "name": "Nombre", "nameDesc": "Solo letras minúsculas, números y guiones", "namePlaceholder": "code-reviewer", "description": "Descripción", "descriptionDesc": "Descripción breve de este agente", "descriptionPlaceholder": "Revisa código en busca de errores y estilo", "advancedOptions": "Opciones avanzadas", "model": "Modelo", "modelDesc": "Modelo alternativo para este agente", "tools": "Herramientas", "toolsDesc": "Lista separada por comas de las herramientas permitidas (vacío = todas)", "disallowedTools": "Herramientas no permitidas", "disallowedToolsDesc": "Lista separada por comas de herramientas no permitidas", "skills": "Habilidades", "skillsDesc": "Lista separada por comas de habilidades", "prompt": "Prompt del sistema", "promptDesc": "Instrucciones para el agente", "promptPlaceholder": "Eres un revisor de código. Analiza el código proporcionado para..." } }, "safety": "Seguridad", "loadUserSettings": { "name": "Cargar configuración de usuario Claude", "desc": "Carga ~/.claude/settings.json. Cuando está habilitado, las reglas de permisos del usuario pueden eludir el modo seguro." }, "enableBlocklist": { "name": "Habilitar lista negra de comandos", "desc": "Bloquea comandos bash potencialmente peligrosos" }, "allowExternalAccess": { "name": "Permitir acceso externo", "desc": "Permite el acceso a archivos y comandos fuera del directorio del vault. Se aplica inmediatamente a todas las sesiones activas. Desactivar la restricción del vault puede exponer archivos sensibles a inyección de prompts." }, "blockedCommands": { "name": "Comandos bloqueados ({platform})", "desc": "Patrones a bloquear en {platform} (uno por línea). Soporta expresiones regulares.", "unixName": "Comandos bloqueados (Unix/Git Bash)", "unixDesc": "Los patrones Unix también se bloquean en Windows porque Git Bash puede invocarlos." }, "exportPaths": { "name": "Rutas de exportación permitidas", "desc": "Destinos de solo escritura fuera del vault cuando el acceso externo está desactivado (uno por línea). Admite ~ para el directorio personal.", "disabledDesc": "Se ignora mientras el acceso externo esté activado. Desactiva el acceso externo para aplicar rutas de exportación de solo escritura." }, "environment": "Entorno", "customVariables": { "name": "Variables personalizadas", "desc": "Variables de entorno para Claude SDK (formato KEY=VALUE, una por línea). Prefijo export soportado." }, "envSnippets": { "name": "Fragmentos", "addBtn": "Añadir fragmento", "noSnippets": "No hay fragmentos de entorno guardados. Haga clic en + para guardar su configuración actual.", "nameRequired": "Por favor ingrese un nombre para el fragmento", "modal": { "titleEdit": "Editar fragmento", "titleSave": "Guardar fragmento", "name": "Nombre", "namePlaceholder": "Un nombre descriptivo para esta configuración", "description": "Descripción", "descPlaceholder": "Descripción opcional", "envVars": "Variables de entorno", "envVarsPlaceholder": "Formato KEY=VALUE, una por línea (prefijo export soportado)", "save": "Guardar", "update": "Actualizar", "cancel": "Cancelar" } }, "customContextLimits": { "name": "Límites de contexto personalizados", "desc": "Establezca tamaños de ventana de contexto para sus modelos personalizados. Deje vacío para usar el valor predeterminado (200k tokens).", "invalid": "Formato inválido. Use: 256k, 1m o número exacto (1000-10000000)." }, "advanced": "Avanzado", "enableOpus1M": { "name": "Ventana de contexto Opus 1M", "desc": "Mostrar Opus 1M en el selector de modelos. Incluido en planes Max, Team y Enterprise. Usuarios de API y Pro necesitan uso adicional." }, "enableSonnet1M": { "name": "Ventana de contexto Sonnet 1M", "desc": "Mostrar Sonnet 1M en el selector de modelos. Requiere uso adicional en planes Max, Team y Enterprise. Usuarios de API y Pro necesitan uso adicional." }, "enableChrome": { "name": "Habilitar extensión de Chrome", "desc": "Permitir que Claude interactúe con Chrome a través de la extensión claude-in-chrome. Requiere que la extensión esté instalada. Requiere reinicio de sesión." }, "enableBangBash": { "name": "Habilitar modo bash (!)", "desc": "Escribe ! en una entrada vacía para entrar en modo bash. Ejecuta comandos directamente mediante Node.js child_process. Requiere volver a abrir la vista.", "validation": { "noNode": "Node.js no se encontró en PATH. Instala Node.js o revisa tu configuración de PATH." } }, "maxTabs": { "name": "Máximo de pestañas de chat", "desc": "Número máximo de pestañas de chat simultáneas (3-10). Cada pestaña usa una sesión de Claude separada.", "warning": "Más de 5 pestañas puede afectar el rendimiento y el uso de memoria." }, "tabBarPosition": { "name": "Posición de la barra de pestañas", "desc": "Elige dónde mostrar las insignias de pestañas y los botones de acción", "input": "Sobre el área de entrada (predeterminado)", "header": "En el encabezado" }, "enableAutoScroll": { "name": "Desplazamiento automático durante streaming", "desc": "Desplazarse automáticamente hacia abajo mientras Claude transmite respuestas. Desactivar para quedarse arriba y leer desde el principio." }, "openInMainTab": { "name": "Abrir en área de editor principal", "desc": "Abrir el panel de chat como una pestaña principal en el área de editor central en lugar de la barra lateral derecha" }, "cliPath": { "name": "Ruta CLI Claude", "desc": "Ruta personalizada a Claude Code CLI. Dejar vacío para detección automática.", "descWindows": "Para el instalador nativo, use claude.exe. Para instalaciones con npm/pnpm/yarn u otros gestores de paquetes, use la ruta cli.js (no claude.cmd).", "descUnix": "Pegue la salida de \"which claude\" — funciona tanto para instalaciones nativas como npm/pnpm/yarn.", "validation": { "notExist": "La ruta no existe", "isDirectory": "La ruta es un directorio, no un archivo" } }, "language": { "name": "Idioma", "desc": "Cambiar el idioma de visualización de la interfaz del plugin" } } } ================================================ FILE: src/i18n/locales/fr.json ================================================ { "common": { "save": "Enregistrer", "cancel": "Annuler", "delete": "Supprimer", "edit": "Modifier", "add": "Ajouter", "remove": "Supprimer", "clear": "Effacer", "clearAll": "Tout effacer", "loading": "Chargement", "error": "Erreur", "success": "Succès", "warning": "Avertissement", "confirm": "Confirmer", "settings": "Paramètres", "advanced": "Avancé", "enabled": "Activé", "disabled": "Désactivé", "platform": "Plateforme", "refresh": "Actualiser", "rewind": "Rembobiner" }, "chat": { "rewind": { "confirmMessage": "Rembobiner jusqu'à ce point ? Les modifications de fichiers après ce message seront annulées. Le rembobinage n'affecte pas les fichiers modifiés manuellement ou via bash.", "confirmButton": "Rembobiner", "ariaLabel": "Rembobiner jusqu'ici", "notice": "Rembobiné : {count} fichier(s) restauré(s)", "noticeSaveFailed": "Rembobiné : {count} fichier(s) restauré(s), mais impossible d'enregistrer l'état : {error}", "failed": "Échec du rembobinage : {error}", "cannot": "Impossible de rembobiner : {error}", "unavailableStreaming": "Impossible de rembobiner pendant le streaming", "unavailableNoUuid": "Impossible de rembobiner : identifiants de message manquants" }, "fork": { "ariaLabel": "Bifurquer la conversation", "chooseTarget": "Bifurquer la conversation", "targetNewTab": "Nouvel onglet", "targetCurrentTab": "Onglet actuel", "maxTabsReached": "Impossible de bifurquer : maximum de {count} onglets atteint", "notice": "Bifurqué dans un nouvel onglet", "noticeCurrentTab": "Bifurqué dans l'onglet actuel", "failed": "Échec de la bifurcation : {error}", "unavailableStreaming": "Impossible de bifurquer pendant le streaming", "unavailableNoUuid": "Impossible de bifurquer : identifiants de message manquants", "unavailableNoResponse": "Impossible de bifurquer : aucune réponse pour bifurquer", "errorMessageNotFound": "Message introuvable", "errorNoSession": "Aucun ID de session disponible", "errorNoActiveTab": "Aucun onglet actif", "commandNoMessages": "Impossible de bifurquer : aucun message dans la conversation", "commandNoAssistantUuid": "Impossible de bifurquer : aucune réponse de l’assistant avec des identifiants" }, "bangBash": { "placeholder": "> Exécuter une commande bash...", "commandPanel": "Panneau de commandes", "copyAriaLabel": "Copier la sortie de la dernière commande", "clearAriaLabel": "Effacer la sortie bash", "commandLabel": "{command}", "statusLabel": "Statut : {status}", "collapseOutput": "Réduire la sortie de la commande", "expandOutput": "Développer la sortie de la commande", "running": "Exécution...", "copyFailed": "Échec de la copie dans le presse-papiers" } }, "settings": { "title": "Paramètres Claudian", "customization": "Personnalisation", "userName": { "name": "Comment Claudian doit-il vous appeler ?", "desc": "Votre nom pour les salutations personnalisées (laisser vide pour les salutations génériques)" }, "excludedTags": { "name": "Tags exclus", "desc": "Les notes avec ces tags ne seront pas chargées automatiquement comme contexte (un par ligne, sans #)" }, "mediaFolder": { "name": "Dossier des médias", "desc": "Dossier contenant les pièces jointes/images. Lorsque les notes utilisent ![[image.jpg]], Claude cherchera ici. Laisser vide pour la racine du coffre." }, "systemPrompt": { "name": "Prompt système personnalisé", "desc": "Instructions supplémentaires ajoutées au prompt système par défaut" }, "autoTitle": { "name": "Générer automatiquement les titres de conversation", "desc": "Génère automatiquement les titres de conversation après le premier message de l'utilisateur." }, "titleModel": { "name": "Modèle de génération de titre", "desc": "Modèle utilisé pour générer automatiquement les titres de conversation.", "auto": "Automatique (Haiku)" }, "navMappings": { "name": "Mappages de navigation style Vim", "desc": "Un mappage par ligne. Format : \"map \" (actions : scrollUp, scrollDown, focusInput)." }, "hotkeys": "Raccourcis clavier", "inlineEditHotkey": { "name": "Édition en ligne", "descWithKey": "Raccourci actuel : {hotkey}", "descNoKey": "Aucun raccourci défini", "btnChange": "Modifier", "btnSet": "Définir" }, "openChatHotkey": { "name": "Ouvrir le chat", "descWithKey": "Raccourci actuel : {hotkey}", "descNoKey": "Aucun raccourci défini", "btnChange": "Modifier", "btnSet": "Définir" }, "newSessionHotkey": { "name": "Nouvelle session", "descWithKey": "Raccourci actuel : {hotkey}", "descNoKey": "Aucun raccourci défini", "btnChange": "Modifier", "btnSet": "Définir" }, "newTabHotkey": { "name": "Nouvel onglet", "descWithKey": "Raccourci actuel : {hotkey}", "descNoKey": "Aucun raccourci défini", "btnChange": "Modifier", "btnSet": "Définir" }, "closeTabHotkey": { "name": "Fermer l'onglet", "descWithKey": "Raccourci actuel : {hotkey}", "descNoKey": "Aucun raccourci défini", "btnChange": "Modifier", "btnSet": "Définir" }, "slashCommands": { "name": "Commandes et compétences", "desc": "Définissez des commandes et compétences personnalisées déclenchées par /nom." }, "hiddenSlashCommands": { "name": "Commandes masquées", "desc": "Masquer des commandes slash spécifiques du menu déroulant. Utile pour masquer les commandes Claude Code qui ne sont pas pertinentes pour Claudian. Entrez les noms de commandes sans le slash initial, un par ligne.", "placeholder": "commit\nbuild\ntest" }, "mcpServers": { "name": "Serveurs MCP", "desc": "Configurez les serveurs Model Context Protocol pour étendre les capacités de Claude avec des outils et sources de données externes. Les serveurs avec mode de sauvegarde de contexte nécessitent une @mention pour s'activer." }, "plugins": { "name": "Plugins Claude Code", "desc": "Activez ou désactivez les plugins Claude Code découverts dans ~/.claude/plugins. Les plugins activés sont stockés par coffre." }, "subagents": { "name": "Sous-agents", "desc": "Configurez des sous-agents personnalisés auxquels Claude peut déléguer.", "noAgents": "Aucun sous-agent configuré. Cliquez sur + pour en créer un.", "deleteConfirm": "Supprimer le sous-agent \"{name}\" ?", "saveFailed": "Échec de l’enregistrement du sous-agent : {message}", "refreshFailed": "Échec de l’actualisation des subagents : {message}", "deleteFailed": "Échec de la suppression du sous-agent : {message}", "renameCleanupFailed": "Avertissement : impossible de supprimer l’ancien fichier pour \"{name}\"", "created": "Sous-agent \"{name}\" créé", "updated": "Sous-agent \"{name}\" mis à jour", "deleted": "Sous-agent \"{name}\" supprimé", "duplicateName": "Un agent nommé \"{name}\" existe déjà", "descriptionRequired": "La description est obligatoire", "promptRequired": "Le prompt système est obligatoire", "modal": { "titleEdit": "Modifier le sous-agent", "titleAdd": "Ajouter un sous-agent", "name": "Name", "nameDesc": "Lettres minuscules, chiffres et tirets uniquement", "namePlaceholder": "code-reviewer", "description": "Description", "descriptionDesc": "Brève description de cet agent", "descriptionPlaceholder": "Examine le code pour détecter les bugs et les problèmes de style", "advancedOptions": "Options avancées", "model": "Modèle", "modelDesc": "Modèle à utiliser pour cet agent", "tools": "Outils", "toolsDesc": "Liste des outils autorisés, séparés par des virgules (vide = tous)", "disallowedTools": "Outils non autorisés", "disallowedToolsDesc": "Liste des outils à interdire, séparés par des virgules", "skills": "Compétences", "skillsDesc": "Liste des compétences, séparées par des virgules", "prompt": "Prompt système", "promptDesc": "Instructions pour l’agent", "promptPlaceholder": "Vous êtes un relecteur de code. Analysez le code fourni pour..." } }, "safety": "Sécurité", "loadUserSettings": { "name": "Charger les paramètres utilisateur Claude", "desc": "Charge ~/.claude/settings.json. Lorsqu'activé, les règles de permission de l'utilisateur peuvent contourner le mode sécurisé." }, "enableBlocklist": { "name": "Activer la liste noire de commandes", "desc": "Bloque les commandes bash potentiellement dangereuses" }, "allowExternalAccess": { "name": "Autoriser l'accès externe", "desc": "Autorise l'accès aux fichiers et commandes en dehors du répertoire du coffre. Prend effet immédiatement pour toutes les sessions actives. La désactivation de la restriction du coffre peut exposer des fichiers sensibles à l'injection de prompt." }, "blockedCommands": { "name": "Commandes bloquées ({platform})", "desc": "Modèles à bloquer sur {platform} (un par ligne). Supporte les expressions régulières.", "unixName": "Commandes bloquées (Unix/Git Bash)", "unixDesc": "Les modèles Unix sont également bloqués sur Windows car Git Bash peut les appeler." }, "exportPaths": { "name": "Chemins d'exportation autorisés", "desc": "Destinations en écriture seule hors du coffre lorsque l'accès externe est désactivé (une par ligne). Supporte ~ pour le répertoire personnel.", "disabledDesc": "Ignoré tant que l'accès externe est activé. Désactivez l'accès externe pour appliquer des chemins d'export en écriture seule." }, "environment": "Environnement", "customVariables": { "name": "Variables personnalisées", "desc": "Variables d'environnement pour Claude SDK (format KEY=VALUE, une par ligne). Préfixe export supporté." }, "envSnippets": { "name": "Extraits", "addBtn": "Ajouter un extrait", "noSnippets": "Aucun extrait d'environnement enregistré. Cliquez sur + pour sauvegarder votre configuration actuelle.", "nameRequired": "Veuillez entrer un nom pour l'extrait", "modal": { "titleEdit": "Modifier l'extrait", "titleSave": "Sauvegarder l'extrait", "name": "Nom", "namePlaceholder": "Un nom descriptif pour cette configuration", "description": "Description", "descPlaceholder": "Description optionnelle", "envVars": "Variables d'environnement", "envVarsPlaceholder": "Format KEY=VALUE, une par ligne (préfixe export supporté)", "save": "Enregistrer", "update": "Mettre à jour", "cancel": "Annuler" } }, "customContextLimits": { "name": "Limites de contexte personnalisées", "desc": "Définissez les tailles de fenêtre de contexte pour vos modèles personnalisés. Laissez vide pour utiliser la valeur par défaut (200k tokens).", "invalid": "Format invalide. Utilisez : 256k, 1m ou nombre exact (1000-10000000)." }, "advanced": "Avancé", "enableOpus1M": { "name": "Fenêtre de contexte Opus 1M", "desc": "Afficher Opus 1M dans le sélecteur de modèle. Inclus avec les plans Max, Team et Enterprise. Les utilisateurs API et Pro nécessitent une utilisation supplémentaire." }, "enableSonnet1M": { "name": "Fenêtre de contexte Sonnet 1M", "desc": "Afficher Sonnet 1M dans le sélecteur de modèle. Nécessite une utilisation supplémentaire sur les plans Max, Team et Enterprise. Les utilisateurs API et Pro nécessitent une utilisation supplémentaire." }, "enableChrome": { "name": "Activer l'extension Chrome", "desc": "Permettre à Claude d'interagir avec Chrome via l'extension claude-in-chrome. L'extension doit être installée. Nécessite un redémarrage de session." }, "enableBangBash": { "name": "Activer le mode bash (!)", "desc": "Saisissez ! dans un champ vide pour passer en mode bash. Exécute les commandes directement via le child_process de Node.js. Nécessite de rouvrir la vue.", "validation": { "noNode": "Node.js introuvable dans PATH. Installez Node.js ou vérifiez votre configuration PATH." } }, "maxTabs": { "name": "Maximum d'onglets de chat", "desc": "Nombre maximum d'onglets de chat simultanés (3-10). Chaque onglet utilise une session Claude séparée.", "warning": "Plus de 5 onglets peut affecter les performances et l'utilisation de la mémoire." }, "tabBarPosition": { "name": "Position de la barre d'onglets", "desc": "Choisissez où afficher les badges d'onglets et les boutons d'action", "input": "Au-dessus de la saisie (par défaut)", "header": "Dans l'en-tête" }, "enableAutoScroll": { "name": "Défilement automatique pendant le streaming", "desc": "Défiler automatiquement vers le bas pendant que Claude diffuse les réponses. Désactiver pour rester en haut et lire depuis le début." }, "openInMainTab": { "name": "Ouvrir dans la zone d'éditeur principale", "desc": "Ouvrir le panneau de chat comme un onglet principal dans la zone d'éditeur centrale au lieu de la barre latérale droite" }, "cliPath": { "name": "Chemin CLI Claude", "desc": "Chemin personnalisé vers Claude Code CLI. Laisser vide pour la détection automatique.", "descWindows": "Pour l'installateur natif, utilisez claude.exe. Pour les installations npm/pnpm/yarn ou autres gestionnaires de paquets, utilisez le chemin cli.js (pas claude.cmd).", "descUnix": "Collez la sortie de \"which claude\" — fonctionne pour les installations natives et npm/pnpm/yarn.", "validation": { "notExist": "Le chemin n'existe pas", "isDirectory": "Le chemin est un répertoire, pas un fichier" } }, "language": { "name": "Langue", "desc": "Changer la langue d'affichage de l'interface du plugin" } } } ================================================ FILE: src/i18n/locales/ja.json ================================================ { "common": { "save": "保存", "cancel": "キャンセル", "delete": "削除", "edit": "編集", "add": "追加", "remove": "削除", "clear": "クリア", "clearAll": "すべてクリア", "loading": "読み込み中", "error": "エラー", "success": "成功", "warning": "警告", "confirm": "確認", "settings": "設定", "advanced": "詳細", "enabled": "有効", "disabled": "無効", "platform": "プラットフォーム", "refresh": "更新", "rewind": "巻き戻し" }, "chat": { "rewind": { "confirmMessage": "この時点に巻き戻しますか?このメッセージ以降のファイル変更が元に戻されます。手動またはbashで編集されたファイルには影響しません。", "confirmButton": "巻き戻す", "ariaLabel": "ここに巻き戻す", "notice": "巻き戻し完了:{count} 個のファイルを復元", "noticeSaveFailed": "巻き戻し完了:{count} 個のファイルを復元しましたが、状態を保存できませんでした:{error}", "failed": "巻き戻しに失敗:{error}", "cannot": "巻き戻しできません:{error}", "unavailableStreaming": "ストリーミング中は巻き戻しできません", "unavailableNoUuid": "巻き戻しできません:メッセージ識別子がありません" }, "fork": { "ariaLabel": "会話を分岐", "chooseTarget": "会話を分岐", "targetNewTab": "新しいタブ", "targetCurrentTab": "現在のタブ", "maxTabsReached": "分岐できません:最大 {count} タブに達しました", "notice": "新しいタブに分岐しました", "noticeCurrentTab": "現在のタブで分岐しました", "failed": "分岐に失敗:{error}", "unavailableStreaming": "ストリーミング中は分岐できません", "unavailableNoUuid": "分岐できません:メッセージ識別子がありません", "unavailableNoResponse": "分岐できません:分岐元の応答がありません", "errorMessageNotFound": "メッセージが見つかりません", "errorNoSession": "セッション ID がありません", "errorNoActiveTab": "アクティブなタブがありません", "commandNoMessages": "フォークできません: 会話にメッセージがありません", "commandNoAssistantUuid": "フォークできません: 識別子付きのアシスタント応答がありません" }, "bangBash": { "placeholder": "> bash コマンドを実行...", "commandPanel": "コマンドパネル", "copyAriaLabel": "最新のコマンド出力をコピー", "clearAriaLabel": "bash 出力をクリア", "commandLabel": "{command}", "statusLabel": "状態: {status}", "collapseOutput": "コマンド出力を折りたたむ", "expandOutput": "コマンド出力を展開", "running": "実行中...", "copyFailed": "クリップボードへのコピーに失敗しました" } }, "settings": { "title": "Claudian 設定", "customization": "カスタマイズ", "userName": { "name": "Claudian はどのように呼びますか?", "desc": "パーソナライズされた挨拶に使用する名前(空欄で一般の挨拶)" }, "excludedTags": { "name": "除外タグ", "desc": "これらのタグを含むノートは自動的にコンテキストとして読み込まれません(1行に1つ、#なし)" }, "mediaFolder": { "name": "メディアフォルダ", "desc": "添付ファイル/画像を格納するフォルダ。ノートが ![[image.jpg]] を使用する場合、Claude はここで探します。空欄でリポジトリのルートを使用。" }, "systemPrompt": { "name": "カスタムシステムプロンプト", "desc": "デフォルトのシステムプロンプトに追加される追加指示" }, "autoTitle": { "name": "会話タイトルを自動生成", "desc": "最初のユーザーメッセージ送信後に会話タイトルを自動的に生成します。" }, "titleModel": { "name": "タイトル生成モデル", "desc": "会話タイトルを自動生成するために使用されるモデル。", "auto": "自動 (Haiku)" }, "navMappings": { "name": "Vimスタイルナビゲーションマッピング", "desc": "1行に1つのマッピング。形式:\"map <キー> <アクション>\"(アクション:scrollUp, scrollDown, focusInput)。" }, "hotkeys": "ホットキー", "inlineEditHotkey": { "name": "インライン編集", "descWithKey": "現在のホットキー: {hotkey}", "descNoKey": "ホットキー未設定", "btnChange": "変更", "btnSet": "設定" }, "openChatHotkey": { "name": "チャットを開く", "descWithKey": "現在のホットキー: {hotkey}", "descNoKey": "ホットキー未設定", "btnChange": "変更", "btnSet": "設定" }, "newSessionHotkey": { "name": "新規セッション", "descWithKey": "現在のホットキー: {hotkey}", "descNoKey": "ホットキー未設定", "btnChange": "変更", "btnSet": "設定" }, "newTabHotkey": { "name": "新規タブ", "descWithKey": "現在のホットキー: {hotkey}", "descNoKey": "ホットキー未設定", "btnChange": "変更", "btnSet": "設定" }, "closeTabHotkey": { "name": "タブを閉じる", "descWithKey": "現在のホットキー: {hotkey}", "descNoKey": "ホットキー未設定", "btnChange": "変更", "btnSet": "設定" }, "slashCommands": { "name": "コマンドとスキル", "desc": "/名前 でトリガーされるカスタムコマンドとスキルを定義します。" }, "hiddenSlashCommands": { "name": "非表示コマンド", "desc": "ドロップダウンから特定のスラッシュコマンドを非表示にします。Claudian に関係のない Claude Code コマンドを非表示にするのに便利です。先頭のスラッシュなしでコマンド名を1行に1つ入力してください。", "placeholder": "commit\nbuild\ntest" }, "mcpServers": { "name": "MCP サーバー", "desc": "モデルコンテキストプロトコルサーバーを設定し、外部ツールやデータソースで Claude の機能を拡張します。コンテキスト保存モードのサーバーは @mention でアクティブにする必要があります。" }, "plugins": { "name": "Claude Code プラグイン", "desc": "~/.claude/plugins から検出された Claude Code プラグインを有効化または無効化します。有効化されたプラグインは保管庫ごとに保存されます。" }, "subagents": { "name": "サブエージェント", "desc": "Claude が委任できるカスタムサブエージェントを設定します。", "noAgents": "サブエージェントが設定されていません。+ をクリックして作成してください。", "deleteConfirm": "サブエージェント「{name}」を削除しますか?", "saveFailed": "サブエージェントの保存に失敗しました: {message}", "refreshFailed": "サブエージェントを更新できませんでした: {message}", "deleteFailed": "サブエージェントの削除に失敗しました: {message}", "renameCleanupFailed": "警告: 「{name}」の古いファイルを削除できませんでした", "created": "サブエージェント「{name}」を作成しました", "updated": "サブエージェント「{name}」を更新しました", "deleted": "サブエージェント「{name}」を削除しました", "duplicateName": "「{name}」という名前のエージェントは既に存在します", "descriptionRequired": "説明は必須です", "promptRequired": "システムプロンプトは必須です", "modal": { "titleEdit": "サブエージェントを編集", "titleAdd": "サブエージェントを追加", "name": "名前", "nameDesc": "小文字、数字、ハイフンのみ使用できます", "namePlaceholder": "code-reviewer", "description": "説明", "descriptionDesc": "このエージェントの簡単な説明", "descriptionPlaceholder": "コードのバグやスタイルをレビューします", "advancedOptions": "詳細オプション", "model": "モデル", "modelDesc": "このエージェントのモデル上書き", "tools": "ツール", "toolsDesc": "許可するツールのカンマ区切りリスト(空欄 = すべて)", "disallowedTools": "禁止ツール", "disallowedToolsDesc": "禁止するツールのカンマ区切りリスト", "skills": "スキル", "skillsDesc": "スキルのカンマ区切りリスト", "prompt": "システムプロンプト", "promptDesc": "エージェントへの指示", "promptPlaceholder": "あなたはコードレビュアーです。与えられたコードを分析して..." } }, "safety": "セキュリティ", "loadUserSettings": { "name": "ユーザーClaude設定を読み込む", "desc": "~/.claude/settings.json を読み込みます。有効にすると、ユーザーの Claude Code 許可ルールがセキュリティモードをバイパスする可能性があります。" }, "enableBlocklist": { "name": "コマンドブラックリストを有効化", "desc": "潜在的に危険なbashコマンドをブロック" }, "allowExternalAccess": { "name": "外部アクセスを許可", "desc": "Vault ディレクトリ外のファイルやコマンドへのアクセスを許可します。すべてのアクティブなセッションに即座に反映されます。Vault 制限を無効にすると、機密ファイルがプロンプトインジェクションに対して脆弱になる可能性があります。" }, "blockedCommands": { "name": "ブロックされたコマンド ({platform})", "desc": "{platform} でブロックするパターン(1行に1つ)。正規表現をサポート。", "unixName": "ブロックされたコマンド (Unix/Git Bash)", "unixDesc": "Git Bashが呼び出せるため、UnixパターンもWindows上でブロックされます。" }, "exportPaths": { "name": "許可されたエクスポートパス", "desc": "外部アクセスが無効な場合にのみ有効な、Vault 外部への書き込み専用エクスポート先です(1行に1つ)。~ でホームディレクトリを指定できます。", "disabledDesc": "外部アクセスが有効な間は無視されます。書き込み専用のエクスポートパスを適用するには、外部アクセスを無効にしてください。" }, "environment": "環境", "customVariables": { "name": "カスタム変数", "desc": "Claude SDKの環境変数(KEY=VALUE形式、1行に1つ)。exportプレフィックス対応。" }, "envSnippets": { "name": "スニペット", "addBtn": "スニペットを追加", "noSnippets": "保存された環境変数スニペットはありません。+をクリックして現在の設定を保存してください。", "nameRequired": "スニペットの名前を入力してください", "modal": { "titleEdit": "スニペットを編集", "titleSave": "スニペットを保存", "name": "名前", "namePlaceholder": "この設定のわかりやすい名前", "description": "説明", "descPlaceholder": "任意の説明", "envVars": "環境変数", "envVarsPlaceholder": "KEY=VALUE形式、1行に1つ(exportプレフィックス対応)", "save": "保存", "update": "更新", "cancel": "キャンセル" } }, "customContextLimits": { "name": "カスタムコンテキスト制限", "desc": "カスタムモデルのコンテキストウィンドウサイズを設定します。デフォルト(200kトークン)を使用する場合は空欄のままにしてください。", "invalid": "無効な形式です。使用:256k、1m、または正確な数値(1000-10000000)。" }, "advanced": "詳細設定", "enableOpus1M": { "name": "Opus 1Mコンテキストウィンドウ", "desc": "モデルセレクターにOpus 1Mを表示します。Max、Team、Enterpriseプランに含まれます。APIおよびProユーザーは追加使用量が必要です。" }, "enableSonnet1M": { "name": "Sonnet 1Mコンテキストウィンドウ", "desc": "モデルセレクターにSonnet 1Mを表示します。Max、Team、Enterpriseプランでは追加使用量が必要です。APIおよびProユーザーは追加使用量が必要です。" }, "enableChrome": { "name": "Chrome拡張機能を有効化", "desc": "claude-in-chrome拡張機能を通じてClaudeがChromeと連携できるようにします。拡張機能のインストールが必要です。セッションの再起動が必要です。" }, "enableBangBash": { "name": "bash モード (!) を有効化", "desc": "入力欄が空の状態で ! を入力すると bash モードに入ります。Node.js の child_process 経由でコマンドを直接実行します。ビューの再オープンが必要です。", "validation": { "noNode": "PATH に Node.js が見つかりません。Node.js をインストールするか、PATH 設定を確認してください。" } }, "maxTabs": { "name": "最大チャットタブ数", "desc": "同時に開ける最大チャットタブ数(3-10)。各タブは個別の Claude セッションを使用します。", "warning": "5 タブを超えるとパフォーマンスやメモリ使用量に影響する可能性があります。" }, "tabBarPosition": { "name": "タブバーの位置", "desc": "タブバッジとアクションボタンの表示位置を選択", "input": "入力欄の上(デフォルト)", "header": "ヘッダー内" }, "enableAutoScroll": { "name": "ストリーミング中の自動スクロール", "desc": "Claudeが応答をストリーミングしている間、自動的に下にスクロールします。無効にすると上部に留まり、最初から読むことができます。" }, "openInMainTab": { "name": "メインエディタ領域で開く", "desc": "チャットパネルを右サイドバーではなく、中央エディタ領域のメインタブとして開きます" }, "cliPath": { "name": "Claude CLI パス", "desc": "Claude Code CLI のカスタムパス。空欄で自動検出を使用。", "descWindows": "ネイティブインストーラーの場合は claude.exe を使用。npm/pnpm/yarn やその他のパッケージマネージャーでのインストールの場合は cli.js パスを使用(claude.cmd ではない)。", "descUnix": "\"which claude\" の出力を貼り付けてください - ネイティブと npm/pnpm/yarn インストールの両方で動作します。", "validation": { "notExist": "パスが存在しません", "isDirectory": "パスはディレクトリでファイルではありません" } }, "language": { "name": "言語", "desc": "プラグインインターフェースの表示言語を変更" } } } ================================================ FILE: src/i18n/locales/ko.json ================================================ { "common": { "save": "저장", "cancel": "취소", "delete": "삭제", "edit": "편집", "add": "추가", "remove": "제거", "clear": "지우기", "clearAll": "모두 지우기", "loading": "로딩 중", "error": "오류", "success": "성공", "warning": "경고", "confirm": "확인", "settings": "설정", "advanced": "고급", "enabled": "활성화", "disabled": "비활성화", "platform": "플랫폼", "refresh": "새로고침", "rewind": "되감기" }, "chat": { "rewind": { "confirmMessage": "이 시점으로 되감으시겠습니까? 이 메시지 이후의 파일 변경 사항이 되돌려집니다. 수동으로 또는 bash를 통해 편집된 파일에는 영향을 미치지 않습니다.", "confirmButton": "되감기", "ariaLabel": "여기로 되감기", "notice": "되감기 완료: {count}개 파일 복원됨", "noticeSaveFailed": "되감기 완료: {count}개 파일 복원됨, 하지만 상태를 저장하지 못했습니다: {error}", "failed": "되감기 실패: {error}", "cannot": "되감기 불가: {error}", "unavailableStreaming": "스트리밍 중에는 되감기할 수 없습니다", "unavailableNoUuid": "되감기 불가: 메시지 식별자 누락" }, "fork": { "ariaLabel": "대화 분기", "chooseTarget": "대화 분기", "targetNewTab": "새 탭", "targetCurrentTab": "현재 탭", "maxTabsReached": "분기 불가: 최대 {count}개 탭에 도달했습니다", "notice": "새 탭으로 분기됨", "noticeCurrentTab": "현재 탭에서 분기됨", "failed": "분기 실패: {error}", "unavailableStreaming": "스트리밍 중에는 분기할 수 없습니다", "unavailableNoUuid": "분기 불가: 메시지 식별자 누락", "unavailableNoResponse": "분기 불가: 분기할 응답이 없습니다", "errorMessageNotFound": "메시지를 찾을 수 없습니다", "errorNoSession": "사용 가능한 세션 ID가 없습니다", "errorNoActiveTab": "활성 탭이 없습니다", "commandNoMessages": "포크할 수 없습니다: 대화에 메시지가 없습니다", "commandNoAssistantUuid": "포크할 수 없습니다: 식별자가 있는 어시스턴트 응답이 없습니다" }, "bangBash": { "placeholder": "> bash 명령 실행...", "commandPanel": "명령 패널", "copyAriaLabel": "최신 명령 출력을 복사", "clearAriaLabel": "bash 출력 지우기", "commandLabel": "{command}", "statusLabel": "상태: {status}", "collapseOutput": "명령 출력 접기", "expandOutput": "명령 출력 펼치기", "running": "실행 중...", "copyFailed": "클립보드에 복사하지 못했습니다" } }, "settings": { "title": "Claudian 설정", "customization": "사용자 정의", "userName": { "name": "Claudian이 당신을 어떻게 불러야 합니까?", "desc": "개인화된 인사에 사용할 이름 (비워두면 일반 인사)" }, "excludedTags": { "name": "제외 태그", "desc": "이 태그가 포함된 노트는 자동으로 컨텍스트로 로드되지 않습니다 (한 줄에 하나, # 제외)" }, "mediaFolder": { "name": "미디어 폴더", "desc": "첨부 파일/이미지를 저장할 폴더. 노트가 ![[image.jpg]]를 사용할 때 Claude가 여기서 찾습니다. 비워두면 저장소 루트 사용." }, "systemPrompt": { "name": "커스텀 시스템 프롬프트", "desc": "기본 시스템 프롬프트에 추가되는 추가 지침" }, "autoTitle": { "name": "대화 제목 자동 생성", "desc": "첫 번째 사용자 메시지 전송 후 자동으로 대화 제목을 생성합니다." }, "titleModel": { "name": "제목 생성 모델", "desc": "대화 제목을 자동 생성하는 데 사용되는 모델.", "auto": "자동 (Haiku)" }, "navMappings": { "name": "Vim 스타일 네비게이션 매핑", "desc": "한 줄에 하나의 매핑. 형식: \"map <키> <동작>\" (동작: scrollUp, scrollDown, focusInput)." }, "hotkeys": "단축키", "inlineEditHotkey": { "name": "인라인 편집", "descWithKey": "현재 단축키: {hotkey}", "descNoKey": "단축키 미설정", "btnChange": "변경", "btnSet": "단축키 설정" }, "openChatHotkey": { "name": "채팅 열기", "descWithKey": "현재 단축키: {hotkey}", "descNoKey": "단축키 미설정", "btnChange": "변경", "btnSet": "단축키 설정" }, "newSessionHotkey": { "name": "새 세션", "descWithKey": "현재 단축키: {hotkey}", "descNoKey": "단축키 미설정", "btnChange": "변경", "btnSet": "단축키 설정" }, "newTabHotkey": { "name": "새 탭", "descWithKey": "현재 단축키: {hotkey}", "descNoKey": "단축키 미설정", "btnChange": "변경", "btnSet": "단축키 설정" }, "closeTabHotkey": { "name": "탭 닫기", "descWithKey": "현재 단축키: {hotkey}", "descNoKey": "단축키 미설정", "btnChange": "변경", "btnSet": "단축키 설정" }, "slashCommands": { "name": "명령어와 스킬", "desc": "/이름으로 트리거되는 커스텀 명령어와 스킬을 정의합니다." }, "hiddenSlashCommands": { "name": "숨겨진 명령어", "desc": "드롭다운에서 특정 슬래시 명령어를 숨깁니다. Claudian과 관련 없는 Claude Code 명령어를 숨기는 데 유용합니다. 앞의 슬래시 없이 한 줄에 하나씩 명령어 이름을 입력하세요.", "placeholder": "commit\nbuild\ntest" }, "mcpServers": { "name": "MCP 서버", "desc": "모델 컨텍스트 프로토콜 서버를 설정하여 외부 도구와 데이터 소스로 Claude의 기능을 확장합니다. 컨텍스트 저장 모드 서버는 @mention으로 활성화해야 합니다." }, "plugins": { "name": "Claude Code 플러그인", "desc": "~/.claude/plugins에서 발견된 Claude Code 플러그인을 활성화 또는 비활성화합니다. 활성화된 플러그인은 볼트별로 저장됩니다." }, "subagents": { "name": "서브에이전트", "desc": "Claude가 위임할 수 있는 사용자 정의 서브에이전트를 설정합니다.", "noAgents": "구성된 서브에이전트가 없습니다. +를 클릭해 새로 만드세요.", "deleteConfirm": "서브에이전트 \"{name}\"을 삭제하시겠습니까?", "saveFailed": "서브에이전트를 저장하지 못했습니다: {message}", "refreshFailed": "서브에이전트를 새로고침하지 못했습니다: {message}", "deleteFailed": "서브에이전트를 삭제하지 못했습니다: {message}", "renameCleanupFailed": "경고: \"{name}\"의 이전 파일을 제거하지 못했습니다", "created": "서브에이전트 \"{name}\"를 생성했습니다", "updated": "서브에이전트 \"{name}\"를 업데이트했습니다", "deleted": "서브에이전트 \"{name}\"를 삭제했습니다", "duplicateName": "\"{name}\"이라는 이름의 에이전트가 이미 있습니다", "descriptionRequired": "설명은 필수입니다", "promptRequired": "시스템 프롬프트는 필수입니다", "modal": { "titleEdit": "서브에이전트 편집", "titleAdd": "서브에이전트 추가", "name": "이름", "nameDesc": "소문자, 숫자, 하이픈만 사용할 수 있습니다", "namePlaceholder": "code-reviewer", "description": "설명", "descriptionDesc": "이 에이전트에 대한 간단한 설명", "descriptionPlaceholder": "코드의 버그와 스타일을 검토합니다", "advancedOptions": "고급 옵션", "model": "모델", "modelDesc": "이 에이전트에 사용할 모델 재정의", "tools": "도구", "toolsDesc": "허용할 도구를 쉼표로 구분해 입력하세요 (비워두면 모두 허용)", "disallowedTools": "금지 도구", "disallowedToolsDesc": "금지할 도구를 쉼표로 구분해 입력하세요", "skills": "스킬", "skillsDesc": "스킬 목록을 쉼표로 구분해 입력하세요", "prompt": "시스템 프롬프트", "promptDesc": "에이전트용 지침", "promptPlaceholder": "당신은 코드 리뷰어입니다. 주어진 코드를 분석하여..." } }, "safety": "보안", "loadUserSettings": { "name": "사용자 Claude 설정 로드", "desc": "~/.claude/settings.json을 로드합니다. 활성화하면 사용자의 Claude Code 허용 규칙이 보안 모드를 우회할 수 있습니다." }, "enableBlocklist": { "name": "명령어 블랙리스트 활성화", "desc": "잠재적으로 위험한 bash 명령어 차단" }, "allowExternalAccess": { "name": "외부 접근 허용", "desc": "볼트 디렉토리 외부의 파일 및 명령어 접근을 허용합니다. 모든 활성 세션에 즉시 적용됩니다. 볼트 제한을 비활성화하면 민감한 파일이 프롬프트 인젝션에 노출될 수 있습니다." }, "blockedCommands": { "name": "차단된 명령어 ({platform})", "desc": "{platform}에서 차단할 패턴 (한 줄에 하나). 정규식 지원.", "unixName": "차단된 명령어 (Unix/Git Bash)", "unixDesc": "Git Bash가 호출할 수 있으므로 Unix 패턴도 Windows에서 차단됩니다." }, "exportPaths": { "name": "허용된 내보내기 경로", "desc": "외부 접근이 꺼져 있을 때 적용되는 볼트 외부의 쓰기 전용 내보내기 경로입니다(한 줄에 하나). 홈 디렉토리는 ~를 지원합니다.", "disabledDesc": "외부 접근이 켜져 있는 동안에는 무시됩니다. 쓰기 전용 내보내기 경로를 적용하려면 외부 접근을 끄세요." }, "environment": "환경", "customVariables": { "name": "커스텀 변수", "desc": "Claude SDK 환경 변수 (KEY=VALUE 형식, 한 줄에 하나). export 접두사 지원." }, "envSnippets": { "name": "스니펫", "addBtn": "스니펫 추가", "noSnippets": "저장된 환경 변수 스니펫이 없습니다. +를 클릭하여 현재 구성을 저장하세요.", "nameRequired": "스니펫 이름을 입력하세요", "modal": { "titleEdit": "스니펫 편집", "titleSave": "스니펫 저장", "name": "이름", "namePlaceholder": "이 구성에 대한 설명적인 이름", "description": "설명", "descPlaceholder": "선택적 설명", "envVars": "환경 변수", "envVarsPlaceholder": "KEY=VALUE 형식, 한 줄에 하나 (export 접두사 지원)", "save": "저장", "update": "업데이트", "cancel": "취소" } }, "customContextLimits": { "name": "사용자 정의 컨텍스트 제한", "desc": "사용자 정의 모델의 컨텍스트 창 크기를 설정합니다. 기본값(200k 토큰)을 사용하려면 비워두세요.", "invalid": "잘못된 형식입니다. 사용: 256k, 1m 또는 정확한 숫자(1000-10000000)." }, "advanced": "고급", "enableOpus1M": { "name": "Opus 1M 컨텍스트 윈도우", "desc": "모델 선택기에서 Opus 1M을 표시합니다. Max, Team, Enterprise 플랜에 포함됩니다. API 및 Pro 사용자는 추가 사용량이 필요합니다." }, "enableSonnet1M": { "name": "Sonnet 1M 컨텍스트 윈도우", "desc": "모델 선택기에서 Sonnet 1M을 표시합니다. Max, Team, Enterprise 플랜에서 추가 사용량이 필요합니다. API 및 Pro 사용자는 추가 사용량이 필요합니다." }, "enableChrome": { "name": "Chrome 확장 프로그램 활성화", "desc": "claude-in-chrome 확장 프로그램을 통해 Claude가 Chrome과 상호작용할 수 있도록 합니다. 확장 프로그램이 설치되어 있어야 합니다. 세션 재시작이 필요합니다." }, "enableBangBash": { "name": "bash 모드 (!) 활성화", "desc": "입력창이 비어 있을 때 !를 입력하면 bash 모드로 들어갑니다. Node.js child_process를 통해 명령을 직접 실행합니다. 보기를 다시 열어야 합니다.", "validation": { "noNode": "PATH에서 Node.js를 찾을 수 없습니다. Node.js를 설치하거나 PATH 설정을 확인하세요." } }, "maxTabs": { "name": "최대 채팅 탭 수", "desc": "동시에 열 수 있는 최대 채팅 탭 수(3-10). 각 탭은 별도의 Claude 세션을 사용합니다.", "warning": "5개 탭을 초과하면 성능 및 메모리 사용량에 영향을 줄 수 있습니다." }, "tabBarPosition": { "name": "탭 바 위치", "desc": "탭 배지와 작업 버튼의 표시 위치 선택", "input": "입력창 위(기본값)", "header": "헤더에" }, "enableAutoScroll": { "name": "스트리밍 중 자동 스크롤", "desc": "Claude가 응답을 스트리밍하는 동안 자동으로 아래로 스크롤합니다. 비활성화하면 상단에 머물러 처음부터 읽을 수 있습니다." }, "openInMainTab": { "name": "메인 편집기 영역에서 열기", "desc": "채팅 패널을 오른쪽 사이드바가 아닌 중앙 편집기 영역의 메인 탭으로 엽니다" }, "cliPath": { "name": "Claude CLI 경로", "desc": "Claude Code CLI의 사용자 정의 경로. 비워두면 자동 감지 사용.", "descWindows": "네이티브 설치 프로그램의 경우 claude.exe를 사용하세요. npm/pnpm/yarn 또는 기타 패키지 관리자 설치의 경우 cli.js 경로를 사용하세요 (claude.cmd가 아님).", "descUnix": "\"which claude\"의 출력을 붙여넣으세요 - 네이티브 및 npm/pnpm/yarn 설치 모두에서 작동합니다.", "validation": { "notExist": "경로가 존재하지 않습니다", "isDirectory": "경로가 디렉토리입니다 파일이 아닙니다" } }, "language": { "name": "언어", "desc": "플러그인 인터페이스의 표시 언어 변경" } } } ================================================ FILE: src/i18n/locales/pt.json ================================================ { "common": { "save": "Salvar", "cancel": "Cancelar", "delete": "Excluir", "edit": "Editar", "add": "Adicionar", "remove": "Remover", "clear": "Limpar", "clearAll": "Limpar tudo", "loading": "Carregando", "error": "Erro", "success": "Sucesso", "warning": "Aviso", "confirm": "Confirmar", "settings": "Configurações", "advanced": "Avançado", "enabled": "Ativado", "disabled": "Desativado", "platform": "Plataforma", "refresh": "Atualizar", "rewind": "Retroceder" }, "chat": { "rewind": { "confirmMessage": "Retroceder até este ponto? As alterações de arquivos após esta mensagem serão revertidas. O retrocesso não afeta arquivos editados manualmente ou via bash.", "confirmButton": "Retroceder", "ariaLabel": "Retroceder até aqui", "notice": "Retrocedido: {count} arquivo(s) revertido(s)", "noticeSaveFailed": "Retrocedido: {count} arquivo(s) revertido(s), mas não foi possível salvar o estado: {error}", "failed": "Falha ao retroceder: {error}", "cannot": "Não é possível retroceder: {error}", "unavailableStreaming": "Não é possível retroceder durante a transmissão", "unavailableNoUuid": "Não é possível retroceder: identificadores de mensagem ausentes" }, "fork": { "ariaLabel": "Bifurcar conversa", "chooseTarget": "Bifurcar conversa", "targetNewTab": "Nova aba", "targetCurrentTab": "Aba atual", "maxTabsReached": "Não é possível bifurcar: máximo de {count} abas atingido", "notice": "Bifurcado para nova aba", "noticeCurrentTab": "Bifurcado na aba atual", "failed": "Falha ao bifurcar: {error}", "unavailableStreaming": "Não é possível bifurcar durante a transmissão", "unavailableNoUuid": "Não é possível bifurcar: identificadores de mensagem ausentes", "unavailableNoResponse": "Não é possível bifurcar: nenhuma resposta para bifurcar", "errorMessageNotFound": "Mensagem não encontrada", "errorNoSession": "Nenhum ID de sessão disponível", "errorNoActiveTab": "Nenhuma aba ativa", "commandNoMessages": "Não é possível bifurcar: não há mensagens na conversa", "commandNoAssistantUuid": "Não é possível bifurcar: não há resposta do assistente com identificadores" }, "bangBash": { "placeholder": "> Executar um comando bash...", "commandPanel": "Painel de comandos", "copyAriaLabel": "Copiar a saída do comando mais recente", "clearAriaLabel": "Limpar a saída do bash", "commandLabel": "{command}", "statusLabel": "Estado: {status}", "collapseOutput": "Recolher a saída do comando", "expandOutput": "Expandir a saída do comando", "running": "Executando...", "copyFailed": "Falha ao copiar para a área de transferência" } }, "settings": { "title": "Configurações do Claudian", "customization": "Personalização", "userName": { "name": "Como o Claudian deve chamá-lo?", "desc": "Seu nome para saudações personalizadas (deixe vazio para saudações genéricas)" }, "excludedTags": { "name": "Tags excluídas", "desc": "Notas com estas tags não serão carregadas automaticamente como contexto (uma por linha, sem #)" }, "mediaFolder": { "name": "Pasta de mídia", "desc": "Pasta contendo anexos/imagens. Quando notas usam ![[image.jpg]], Claude procurará aqui. Deixe vazio para a raiz do repositório." }, "systemPrompt": { "name": "Prompt de sistema personalizado", "desc": "Instruções adicionais anexadas ao prompt de sistema padrão" }, "autoTitle": { "name": "Gerar automaticamente títulos de conversa", "desc": "Gera automaticamente títulos de conversa após a primeira mensagem do usuário." }, "titleModel": { "name": "Modelo de geração de título", "desc": "Modelo usado para gerar automaticamente títulos de conversa.", "auto": "Automático (Haiku)" }, "navMappings": { "name": "Mapeamentos de navegação estilo Vim", "desc": "Um mapeamento por linha. Formato: \"map \" (ações: scrollUp, scrollDown, focusInput)." }, "hotkeys": "Atalhos", "inlineEditHotkey": { "name": "Edição em linha", "descWithKey": "Atalho atual: {hotkey}", "descNoKey": "Nenhum atalho definido", "btnChange": "Alterar", "btnSet": "Definir" }, "openChatHotkey": { "name": "Abrir chat", "descWithKey": "Atalho atual: {hotkey}", "descNoKey": "Nenhum atalho definido", "btnChange": "Alterar", "btnSet": "Definir" }, "newSessionHotkey": { "name": "Nova sessão", "descWithKey": "Atalho atual: {hotkey}", "descNoKey": "Nenhum atalho definido", "btnChange": "Alterar", "btnSet": "Definir" }, "newTabHotkey": { "name": "Nova aba", "descWithKey": "Atalho atual: {hotkey}", "descNoKey": "Nenhum atalho definido", "btnChange": "Alterar", "btnSet": "Definir" }, "closeTabHotkey": { "name": "Fechar aba", "descWithKey": "Atalho atual: {hotkey}", "descNoKey": "Nenhum atalho definido", "btnChange": "Alterar", "btnSet": "Definir" }, "slashCommands": { "name": "Comandos e habilidades", "desc": "Defina comandos e habilidades personalizados acionados por /nome." }, "hiddenSlashCommands": { "name": "Comandos ocultos", "desc": "Ocultar comandos slash específicos do menu suspenso. Útil para ocultar comandos do Claude Code que não são relevantes para o Claudian. Digite os nomes dos comandos sem a barra inicial, um por linha.", "placeholder": "commit\nbuild\ntest" }, "mcpServers": { "name": "Servidores MCP", "desc": "Configure servidores Model Context Protocol para estender as capacidades do Claude com ferramentas e fontes de dados externas. Servidores com modo de salvamento de contexto exigem @mention para ativar." }, "plugins": { "name": "Plugins do Claude Code", "desc": "Ative ou desative plugins do Claude Code descobertos em ~/.claude/plugins. Plugins ativados são armazenados por cofre." }, "subagents": { "name": "Subagentes", "desc": "Configure subagentes personalizados para os quais Claude pode delegar.", "noAgents": "Nenhum subagente configurado. Clique em + para criar um.", "deleteConfirm": "Excluir o subagente \"{name}\"?", "saveFailed": "Falha ao salvar o subagente: {message}", "refreshFailed": "Falha ao atualizar subagentes: {message}", "deleteFailed": "Falha ao excluir o subagente: {message}", "renameCleanupFailed": "Aviso: não foi possível remover o arquivo antigo de \"{name}\"", "created": "Subagente \"{name}\" criado", "updated": "Subagente \"{name}\" atualizado", "deleted": "Subagente \"{name}\" excluído", "duplicateName": "Já existe um agente chamado \"{name}\"", "descriptionRequired": "A descrição é obrigatória", "promptRequired": "O prompt do sistema é obrigatório", "modal": { "titleEdit": "Editar subagente", "titleAdd": "Adicionar subagente", "name": "Nome", "nameDesc": "Use apenas letras minúsculas, números e hífens", "namePlaceholder": "code-reviewer", "description": "Descrição", "descriptionDesc": "Breve descrição deste agente", "descriptionPlaceholder": "Revisa código em busca de bugs e estilo", "advancedOptions": "Opções avançadas", "model": "Modelo", "modelDesc": "Substituir o modelo deste agente", "tools": "Ferramentas", "toolsDesc": "Lista de ferramentas permitidas separadas por vírgula (vazio = todas)", "disallowedTools": "Ferramentas não permitidas", "disallowedToolsDesc": "Lista de ferramentas a desativar separadas por vírgula", "skills": "Habilidades", "skillsDesc": "Lista de habilidades separadas por vírgula", "prompt": "Prompt do sistema", "promptDesc": "Instruções para o agente", "promptPlaceholder": "Você é um revisor de código. Analise o código fornecido para..." } }, "safety": "Segurança", "loadUserSettings": { "name": "Carregar configurações do usuário Claude", "desc": "Carrega ~/.claude/settings.json. Quando habilitado, as regras de permissão do usuário podem ignorar o modo seguro." }, "enableBlocklist": { "name": "Habilitar lista negra de comandos", "desc": "Bloqueia comandos bash potencialmente perigosos" }, "allowExternalAccess": { "name": "Permitir acesso externo", "desc": "Permite acesso a arquivos e comandos fora do diretório do vault. Aplica-se imediatamente a todas as sessões ativas. Desativar a restrição do vault pode expor arquivos sensíveis a injeção de prompt." }, "blockedCommands": { "name": "Comandos bloqueados ({platform})", "desc": "Padrões para bloquear em {platform} (um por linha). Suporta expressões regulares.", "unixName": "Comandos bloqueados (Unix/Git Bash)", "unixDesc": "Padrões Unix também bloqueados no Windows porque Git Bash pode invocá-los." }, "exportPaths": { "name": "Caminhos de exportação permitidos", "desc": "Destinos somente para escrita fora do vault quando o acesso externo está desativado (um por linha). Suporta ~ para o diretório pessoal.", "disabledDesc": "Ignorado enquanto o acesso externo estiver ativado. Desative o acesso externo para aplicar caminhos de exportação somente para escrita." }, "environment": "Ambiente", "customVariables": { "name": "Variáveis personalizadas", "desc": "Variáveis de ambiente para Claude SDK (formato KEY=VALUE, uma por linha). Prefixo export suportado." }, "envSnippets": { "name": "Snippets", "addBtn": "Adicionar snippet", "noSnippets": "Nenhum snippet de ambiente salvo. Clique em + para salvar sua configuração atual.", "nameRequired": "Por favor, insira um nome para o snippet", "modal": { "titleEdit": "Editar snippet", "titleSave": "Salvar snippet", "name": "Nome", "namePlaceholder": "Um nome descritivo para esta configuração", "description": "Descrição", "descPlaceholder": "Descrição opcional", "envVars": "Variáveis de ambiente", "envVarsPlaceholder": "Formato KEY=VALUE, uma por linha (prefixo export suportado)", "save": "Salvar", "update": "Atualizar", "cancel": "Cancelar" } }, "customContextLimits": { "name": "Limites de contexto personalizados", "desc": "Defina tamanhos de janela de contexto para seus modelos personalizados. Deixe vazio para usar o padrão (200k tokens).", "invalid": "Formato inválido. Use: 256k, 1m ou número exato (1000-10000000)." }, "advanced": "Avançado", "enableOpus1M": { "name": "Janela de contexto Opus 1M", "desc": "Mostrar Opus 1M no seletor de modelos. Incluído nos planos Max, Team e Enterprise. Usuários de API e Pro precisam de uso adicional." }, "enableSonnet1M": { "name": "Janela de contexto Sonnet 1M", "desc": "Mostrar Sonnet 1M no seletor de modelos. Requer uso adicional nos planos Max, Team e Enterprise. Usuários de API e Pro precisam de uso adicional." }, "enableChrome": { "name": "Habilitar extensão do Chrome", "desc": "Permitir que o Claude interaja com o Chrome através da extensão claude-in-chrome. Requer que a extensão esteja instalada. Requer reinício de sessão." }, "enableBangBash": { "name": "Ativar modo bash (!)", "desc": "Digite ! com a entrada vazia para entrar no modo bash. Executa comandos diretamente via Node.js child_process. Requer reabrir a visualização.", "validation": { "noNode": "Node.js não encontrado no PATH. Instale o Node.js ou verifique a configuração do PATH." } }, "maxTabs": { "name": "Máximo de abas de chat", "desc": "Número máximo de abas de chat simultâneas (3-10). Cada aba usa uma sessão Claude separada.", "warning": "Mais de 5 abas pode afetar o desempenho e o uso de memória." }, "tabBarPosition": { "name": "Posição da barra de abas", "desc": "Escolha onde exibir os emblemas de abas e botões de ação", "input": "Acima da entrada (padrão)", "header": "No cabeçalho" }, "enableAutoScroll": { "name": "Rolagem automática durante streaming", "desc": "Rolar automaticamente para baixo enquanto o Claude transmite respostas. Desativar para ficar no topo e ler desde o início." }, "openInMainTab": { "name": "Abrir na área do editor principal", "desc": "Abrir o painel de chat como uma aba principal na área do editor central em vez da barra lateral direita" }, "cliPath": { "name": "Caminho CLI Claude", "desc": "Caminho personalizado para Claude Code CLI. Deixe vazio para detecção automática.", "descWindows": "Para o instalador nativo, use claude.exe. Para instalações com npm/pnpm/yarn ou outros gerenciadores de pacotes, use o caminho cli.js (não claude.cmd).", "descUnix": "Cole a saída de \"which claude\" — funciona tanto para instalações nativas quanto npm/pnpm/yarn.", "validation": { "notExist": "Caminho não existe", "isDirectory": "Caminho é um diretório, não um arquivo" } }, "language": { "name": "Idioma", "desc": "Alterar o idioma de exibição da interface do plugin" } } } ================================================ FILE: src/i18n/locales/ru.json ================================================ { "common": { "save": "Сохранить", "cancel": "Отмена", "delete": "Удалить", "edit": "Редактировать", "add": "Добавить", "remove": "Удалить", "clear": "Очистить", "clearAll": "Очистить всё", "loading": "Загрузка", "error": "Ошибка", "success": "Успех", "warning": "Предупреждение", "confirm": "Подтвердить", "settings": "Настройки", "advanced": "Дополнительно", "enabled": "Включено", "disabled": "Отключено", "platform": "Платформа", "refresh": "Обновить", "rewind": "Откатить" }, "chat": { "rewind": { "confirmMessage": "Откатить до этой точки? Изменения файлов после этого сообщения будут отменены. Откат не затрагивает файлы, отредактированные вручную или через bash.", "confirmButton": "Откатить", "ariaLabel": "Откатить сюда", "notice": "Откачено: восстановлено файлов — {count}", "noticeSaveFailed": "Откат выполнен: восстановлено файлов — {count}, но не удалось сохранить состояние: {error}", "failed": "Ошибка отката: {error}", "cannot": "Невозможно откатить: {error}", "unavailableStreaming": "Невозможно откатить во время потоковой передачи", "unavailableNoUuid": "Невозможно откатить: отсутствуют идентификаторы сообщений" }, "fork": { "ariaLabel": "Ответвить разговор", "chooseTarget": "Ответвить разговор", "targetNewTab": "Новая вкладка", "targetCurrentTab": "Текущая вкладка", "maxTabsReached": "Невозможно ответвить: достигнут максимум {count} вкладок", "notice": "Ответвлено в новую вкладку", "noticeCurrentTab": "Ответвлено в текущей вкладке", "failed": "Ошибка ответвления: {error}", "unavailableStreaming": "Невозможно ответвить во время потоковой передачи", "unavailableNoUuid": "Невозможно ответвить: отсутствуют идентификаторы сообщений", "unavailableNoResponse": "Невозможно ответвить: нет ответа для ответвления", "errorMessageNotFound": "Сообщение не найдено", "errorNoSession": "Идентификатор сессии недоступен", "errorNoActiveTab": "Нет активной вкладки", "commandNoMessages": "Нельзя форкнуть: в диалоге нет сообщений", "commandNoAssistantUuid": "Нельзя форкнуть: нет ответа ассистента с идентификаторами" }, "bangBash": { "placeholder": "> Выполнить команду bash...", "commandPanel": "Панель команд", "copyAriaLabel": "Скопировать вывод последней команды", "clearAriaLabel": "Очистить вывод bash", "commandLabel": "{command}", "statusLabel": "Статус: {status}", "collapseOutput": "Свернуть вывод команды", "expandOutput": "Развернуть вывод команды", "running": "Выполняется...", "copyFailed": "Не удалось скопировать в буфер обмена" } }, "settings": { "title": "Настройки Claudian", "customization": "Персонализация", "userName": { "name": "Как Claudian должен обращаться к вам?", "desc": "Ваше имя для персонализированных приветствий (оставьте пустым для общих приветствий)" }, "excludedTags": { "name": "Исключенные теги", "desc": "Заметки с этими тегами не будут автоматически загружаться как контекст (по одному в строке, без #)" }, "mediaFolder": { "name": "Папка медиафайлов", "desc": "Папка с вложениями/изображениями. Когда заметки используют ![[image.jpg]], Claude будет искать здесь. Оставьте пустым для корня хранилища." }, "systemPrompt": { "name": "Пользовательский системный промпт", "desc": "Дополнительные инструкции, добавляемые к системному промпту по умолчанию" }, "autoTitle": { "name": "Автоматически генерировать заголовки бесед", "desc": "Автоматически генерировать заголовки бесед после первого сообщения пользователя." }, "titleModel": { "name": "Модель генерации заголовков", "desc": "Модель, используемая для автоматической генерации заголовков бесед.", "auto": "Авто (Haiku)" }, "navMappings": { "name": "Сопоставления навигации в стиле Vim", "desc": "По одному сопоставлению в строке. Формат: \"map <ключ> <действие>\" (действия: scrollUp, scrollDown, focusInput)." }, "hotkeys": "Горячие клавиши", "inlineEditHotkey": { "name": "Инлайн-редактирование", "descWithKey": "Текущая клавиша: {hotkey}", "descNoKey": "Клавиша не назначена", "btnChange": "Изменить", "btnSet": "Назначить" }, "openChatHotkey": { "name": "Открыть чат", "descWithKey": "Текущая клавиша: {hotkey}", "descNoKey": "Клавиша не назначена", "btnChange": "Изменить", "btnSet": "Назначить" }, "newSessionHotkey": { "name": "Новая сессия", "descWithKey": "Текущая клавиша: {hotkey}", "descNoKey": "Клавиша не назначена", "btnChange": "Изменить", "btnSet": "Назначить" }, "newTabHotkey": { "name": "Новая вкладка", "descWithKey": "Текущая клавиша: {hotkey}", "descNoKey": "Клавиша не назначена", "btnChange": "Изменить", "btnSet": "Назначить" }, "closeTabHotkey": { "name": "Закрыть вкладку", "descWithKey": "Текущая клавиша: {hotkey}", "descNoKey": "Клавиша не назначена", "btnChange": "Изменить", "btnSet": "Назначить" }, "slashCommands": { "name": "Команды и навыки", "desc": "Определяйте пользовательские команды и навыки, запускаемые через /имя." }, "hiddenSlashCommands": { "name": "Скрытые команды", "desc": "Скрыть определённые команды со слэшем из выпадающего списка. Полезно для скрытия команд Claude Code, которые не актуальны для Claudian. Вводите имена команд без начального слэша, по одной на строку.", "placeholder": "commit\nbuild\ntest" }, "mcpServers": { "name": "MCP серверы", "desc": "Настройте серверы Model Context Protocol для расширения возможностей Claude с помощью внешних инструментов и источников данных. Серверы с режимом сохранения контекста требуют @mention для активации." }, "plugins": { "name": "Плагины Claude Code", "desc": "Включите или отключите плагины Claude Code из ~/.claude/plugins. Включенные плагины сохраняются для каждого хранилища." }, "subagents": { "name": "Субагенты", "desc": "Настройте пользовательских субагентов, которым Claude может делегировать задачи.", "noAgents": "Субагенты не настроены. Нажмите +, чтобы создать одного.", "deleteConfirm": "Удалить субагента \"{name}\"?", "saveFailed": "Не удалось сохранить субагента: {message}", "refreshFailed": "Не удалось обновить субагентов: {message}", "deleteFailed": "Не удалось удалить субагента: {message}", "renameCleanupFailed": "Предупреждение: не удалось удалить старый файл для \"{name}\"", "created": "Субагент \"{name}\" создан", "updated": "Субагент \"{name}\" обновлён", "deleted": "Субагент \"{name}\" удалён", "duplicateName": "Агент с именем \"{name}\" уже существует", "descriptionRequired": "Описание обязательно", "promptRequired": "Системный промпт обязателен", "modal": { "titleEdit": "Редактировать субагента", "titleAdd": "Добавить субагента", "name": "Имя", "nameDesc": "Только строчные буквы, цифры и дефисы", "namePlaceholder": "code-reviewer", "description": "Описание", "descriptionDesc": "Краткое описание этого агента", "descriptionPlaceholder": "Проверяет код на ошибки и стиль", "advancedOptions": "Дополнительные параметры", "model": "Модель", "modelDesc": "Переопределение модели для этого агента", "tools": "Инструменты", "toolsDesc": "Список разрешённых инструментов через запятую (пусто = все)", "disallowedTools": "Запрещённые инструменты", "disallowedToolsDesc": "Список инструментов, которые нужно запретить, через запятую", "skills": "Навыки", "skillsDesc": "Список навыков через запятую", "prompt": "Системный промпт", "promptDesc": "Инструкции для агента", "promptPlaceholder": "Вы специалист по проверке кода. Проанализируйте предоставленный код на предмет..." } }, "safety": "Безопасность", "loadUserSettings": { "name": "Загружать пользовательские настройки Claude", "desc": "Загружает ~/.claude/settings.json. При включении пользовательские правила разрешений Claude Code могут обходить безопасный режим." }, "enableBlocklist": { "name": "Включить черный список команд", "desc": "Блокировать потенциально опасные bash команды" }, "allowExternalAccess": { "name": "Разрешить внешний доступ", "desc": "Разрешает доступ к файлам и командам вне каталога vault. Вступает в силу немедленно для всех активных сессий. Отключение ограничения vault может подвергнуть конфиденциальные файлы инъекции промптов." }, "blockedCommands": { "name": "Заблокированные команды ({platform})", "desc": "Шаблоны для блокировки на {platform} (по одному в строке). Поддерживаются регулярные выражения.", "unixName": "Заблокированные команды (Unix/Git Bash)", "unixDesc": "Unix шаблоны также блокируются на Windows, так как Git Bash может их вызывать." }, "exportPaths": { "name": "Разрешенные пути экспорта", "desc": "Пути вне vault только для записи, когда внешний доступ отключён (по одному в строке). Поддерживается ~ для домашнего каталога.", "disabledDesc": "Игнорируется, пока внешний доступ включён. Отключите внешний доступ, чтобы снова применять пути экспорта только для записи." }, "environment": "Окружение", "customVariables": { "name": "Пользовательские переменные", "desc": "Переменные окружения для Claude SDK (формат KEY=VALUE, по одной в строке). Префикс export поддерживается." }, "envSnippets": { "name": "Сниппеты", "addBtn": "Добавить сниппет", "noSnippets": "Нет сохраненных сниппетов окружения. Нажмите +, чтобы сохранить текущую конфигурацию.", "nameRequired": "Пожалуйста, введите название сниппета", "modal": { "titleEdit": "Редактировать сниппет", "titleSave": "Сохранить сниппет", "name": "Имя", "namePlaceholder": "Описательное название для этой конфигурации", "description": "Описание", "descPlaceholder": "Опциональное описание", "envVars": "Переменные окружения", "envVarsPlaceholder": "Формат KEY=VALUE, по одной в строке (префикс export поддерживается)", "save": "Сохранить", "update": "Обновить", "cancel": "Отмена" } }, "customContextLimits": { "name": "Пользовательские лимиты контекста", "desc": "Установите размеры окна контекста для ваших пользовательских моделей. Оставьте пустым для использования значения по умолчанию (200k токенов).", "invalid": "Неверный формат. Используйте: 256k, 1m или точное число (1000-10000000)." }, "advanced": "Дополнительно", "enableOpus1M": { "name": "Контекстное окно Opus 1M", "desc": "Показать Opus 1M в селекторе моделей. Включено в планы Max, Team и Enterprise. Пользователям API и Pro требуется дополнительное использование." }, "enableSonnet1M": { "name": "Контекстное окно Sonnet 1M", "desc": "Показать Sonnet 1M в селекторе моделей. Требуется дополнительное использование в планах Max, Team и Enterprise. Пользователям API и Pro требуется дополнительное использование." }, "enableChrome": { "name": "Включить расширение Chrome", "desc": "Разрешить Claude взаимодействовать с Chrome через расширение claude-in-chrome. Требуется установка расширения. Требуется перезапуск сессии." }, "enableBangBash": { "name": "Включить режим bash (!)", "desc": "Введите ! в пустом поле ввода, чтобы перейти в режим bash. Команды выполняются напрямую через Node.js child_process. Требуется повторно открыть представление.", "validation": { "noNode": "Node.js не найден в PATH. Установите Node.js или проверьте настройку PATH." } }, "maxTabs": { "name": "Максимум вкладок чата", "desc": "Максимальное количество одновременных вкладок чата (3-10). Каждая вкладка использует отдельную сессию Claude.", "warning": "Более 5 вкладок может повлиять на производительность и использование памяти." }, "tabBarPosition": { "name": "Положение панели вкладок", "desc": "Выберите, где отображать значки вкладок и кнопки действий", "input": "Над полем ввода (по умолчанию)", "header": "В заголовке" }, "enableAutoScroll": { "name": "Автопрокрутка во время потоковой передачи", "desc": "Автоматически прокручивать вниз, пока Claude передает ответы. Отключите, чтобы оставаться наверху и читать с начала." }, "openInMainTab": { "name": "Открывать в основной области редактора", "desc": "Открывать панель чата в виде основной вкладки в центральной области редактора вместо правой боковой панели" }, "cliPath": { "name": "Путь к CLI Claude", "desc": "Пользовательский путь к Claude Code CLI. Оставьте пустым для автоматического определения.", "descWindows": "Для нативного установщика используйте claude.exe. Для установок через npm/pnpm/yarn или другие менеджеры пакетов используйте путь к cli.js (не claude.cmd).", "descUnix": "Вставьте вывод команды \"which claude\" — работает как для нативных установок, так и для npm/pnpm/yarn.", "validation": { "notExist": "Путь не существует", "isDirectory": "Путь является директорией, а не файлом" } }, "language": { "name": "Язык", "desc": "Изменить язык интерфейса плагина" } } } ================================================ FILE: src/i18n/locales/zh-CN.json ================================================ { "common": { "save": "保存", "cancel": "取消", "delete": "删除", "edit": "编辑", "add": "添加", "remove": "移除", "clear": "清除", "clearAll": "清除全部", "loading": "加载中", "error": "错误", "success": "成功", "warning": "警告", "confirm": "确认", "settings": "设置", "advanced": "高级", "enabled": "已启用", "disabled": "已禁用", "platform": "平台", "refresh": "刷新", "rewind": "回退" }, "chat": { "rewind": { "confirmMessage": "回退到此处?此消息之后的文件更改将被还原。回退不会影响手动或通过 bash 编辑的文件。", "confirmButton": "回退", "ariaLabel": "回退到此处", "notice": "已回退:还原了 {count} 个文件", "noticeSaveFailed": "已回退:还原了 {count} 个文件,但无法保存状态:{error}", "failed": "回退失败:{error}", "cannot": "无法回退:{error}", "unavailableStreaming": "流式响应中无法回退", "unavailableNoUuid": "无法回退:缺少消息标识符" }, "fork": { "ariaLabel": "分叉对话", "chooseTarget": "分叉对话", "targetNewTab": "新标签页", "targetCurrentTab": "当前标签页", "maxTabsReached": "无法分叉:已达到最大 {count} 个标签页", "notice": "已分叉到新标签页", "noticeCurrentTab": "已在当前标签页分叉", "failed": "分叉失败:{error}", "unavailableStreaming": "流式响应中无法分叉", "unavailableNoUuid": "无法分叉:缺少消息标识符", "unavailableNoResponse": "无法分叉:没有可分叉的响应", "errorMessageNotFound": "未找到消息", "errorNoSession": "没有可用的会话 ID", "errorNoActiveTab": "没有活动标签页", "commandNoMessages": "无法分叉:对话中没有消息", "commandNoAssistantUuid": "无法分叉:没有带标识符的助手回复" }, "bangBash": { "placeholder": "> 运行命令...", "commandPanel": "命令面板", "copyAriaLabel": "复制最新命令输出", "clearAriaLabel": "清除命令输出", "commandLabel": "{command}", "statusLabel": "状态: {status}", "collapseOutput": "折叠命令输出", "expandOutput": "展开命令输出", "running": "运行中...", "copyFailed": "复制到剪贴板失败" } }, "settings": { "title": "Claudian 设置", "customization": "个性化设置", "userName": { "name": "Claudian 应该如何称呼你?", "desc": "用于个性化问候的用户名(留空使用通用问候)" }, "excludedTags": { "name": "排除的标签", "desc": "包含这些标签的笔记不会自动加载为上下文(每行一个,不带 #)" }, "mediaFolder": { "name": "媒体文件夹", "desc": "存放附件/图片的文件夹。当笔记使用 ![[image.jpg]] 时,Claude 会在此查找。留空使用仓库根目录。" }, "systemPrompt": { "name": "自定义系统提示词", "desc": "附加到默认系统提示词的额外指令" }, "autoTitle": { "name": "自动生成对话标题", "desc": "在用户发送首条消息后自动生成对话标题。" }, "titleModel": { "name": "标题生成模型", "desc": "用于自动生成对话标题的模型。", "auto": "自动 (Haiku)" }, "navMappings": { "name": "Vim 风格导航映射", "desc": "每行一个映射。格式:\"map <键> <动作>\"(动作:scrollUp, scrollDown, focusInput)。" }, "hotkeys": "快捷键", "inlineEditHotkey": { "name": "内联编辑", "descWithKey": "当前快捷键:{hotkey}", "descNoKey": "未设置快捷键", "btnChange": "更改", "btnSet": "设置快捷键" }, "openChatHotkey": { "name": "打开聊天", "descWithKey": "当前快捷键:{hotkey}", "descNoKey": "未设置快捷键", "btnChange": "更改", "btnSet": "设置快捷键" }, "newSessionHotkey": { "name": "新会话", "descWithKey": "当前快捷键:{hotkey}", "descNoKey": "未设置快捷键", "btnChange": "更改", "btnSet": "设置快捷键" }, "newTabHotkey": { "name": "新标签页", "descWithKey": "当前快捷键:{hotkey}", "descNoKey": "未设置快捷键", "btnChange": "更改", "btnSet": "设置快捷键" }, "closeTabHotkey": { "name": "关闭标签页", "descWithKey": "当前快捷键:{hotkey}", "descNoKey": "未设置快捷键", "btnChange": "更改", "btnSet": "设置快捷键" }, "slashCommands": { "name": "命令与技能", "desc": "定义由 /名称 触发的自定义命令与技能。" }, "hiddenSlashCommands": { "name": "隐藏命令", "desc": "从下拉菜单中隐藏特定的斜杠命令。适用于隐藏与 Claudian 无关的 Claude Code 命令。每行输入一个命令名称,无需前导斜杠。", "placeholder": "commit\nbuild\ntest" }, "mcpServers": { "name": "MCP 服务器", "desc": "配置模型上下文协议服务器,通过外部工具和数据源扩展 Claude 的能力。启用上下文保存模式的服务器需要 @ 提及才能激活。" }, "plugins": { "name": "Claude Code 插件", "desc": "启用或禁用从 ~/.claude/plugins 发现的 Claude Code 插件。启用的插件按 Vault 存储。" }, "subagents": { "name": "子代理", "desc": "配置 Claude 可委派给其处理的自定义子代理。", "noAgents": "尚未配置子代理。点击 + 创建一个。", "deleteConfirm": "删除子代理“{name}”?", "saveFailed": "保存子代理失败:{message}", "refreshFailed": "刷新子代理失败:{message}", "deleteFailed": "删除子代理失败:{message}", "renameCleanupFailed": "警告:无法删除“{name}”的旧文件", "created": "已创建子代理“{name}”", "updated": "已更新子代理“{name}”", "deleted": "已删除子代理“{name}”", "duplicateName": "名为“{name}”的代理已存在", "descriptionRequired": "描述为必填项", "promptRequired": "系统提示词为必填项", "modal": { "titleEdit": "编辑子代理", "titleAdd": "添加子代理", "name": "名称", "nameDesc": "仅允许小写字母、数字和连字符", "namePlaceholder": "code-reviewer", "description": "描述", "descriptionDesc": "该代理的简要描述", "descriptionPlaceholder": "审查代码中的错误和风格问题", "advancedOptions": "高级选项", "model": "模型", "modelDesc": "该代理的模型覆盖", "tools": "工具", "toolsDesc": "允许使用的工具列表,用逗号分隔(留空 = 全部)", "disallowedTools": "禁用工具", "disallowedToolsDesc": "要禁用的工具列表,用逗号分隔", "skills": "技能", "skillsDesc": "技能列表,用逗号分隔", "prompt": "系统提示词", "promptDesc": "给代理的指令", "promptPlaceholder": "你是一名代码审查员。请分析给定代码中的..." } }, "safety": "安全", "loadUserSettings": { "name": "加载用户 Claude 设置", "desc": "加载 ~/.claude/settings.json。启用后,用户的 Claude Code 权限规则可能绕过安全模式。" }, "enableBlocklist": { "name": "启用命令黑名单", "desc": "阻止潜在危险的 bash 命令" }, "allowExternalAccess": { "name": "允许外部访问", "desc": "允许访问仓库目录之外的文件和命令。对所有活动会话立即生效。关闭仓库限制可能使敏感文件暴露于提示注入攻击。" }, "blockedCommands": { "name": "阻止的命令 ({platform})", "desc": "在 {platform} 上阻止的模式(每行一个)。支持正则表达式。", "unixName": "阻止的命令 (Unix/Git Bash)", "unixDesc": "Unix 模式在 Windows 上也会被阻止,因为 Git Bash 可以调用它们。" }, "exportPaths": { "name": "允许的导出路径", "desc": "当外部访问关闭时,允许作为仅写导出目标的仓库外路径(每行一个)。支持 ~ 表示主目录。", "disabledDesc": "启用外部访问时将忽略此设置。关闭外部访问后才会重新强制执行仅写导出路径。" }, "environment": "环境", "customVariables": { "name": "自定义变量", "desc": "Claude SDK 的环境变量(KEY=VALUE 格式,每行一个)。支持 export 前缀。" }, "envSnippets": { "name": "片段", "addBtn": "添加片段", "noSnippets": "尚无保存的环境变量片段。点击 + 保存当前配置。", "nameRequired": "请输入片段名称", "modal": { "titleEdit": "编辑片段", "titleSave": "保存片段", "name": "名称", "namePlaceholder": "此配置的描述性名称", "description": "描述", "descPlaceholder": "可选描述", "envVars": "环境变量", "envVarsPlaceholder": "KEY=VALUE 格式,每行一个(支持 export 前缀)", "save": "保存", "update": "更新", "cancel": "取消" } }, "customContextLimits": { "name": "自定义上下文限制", "desc": "为您的自定义模型设置上下文窗口大小。留空使用默认值(200k 令牌)。", "invalid": "格式无效。使用:256k、1m 或精确数量(1000-10000000)。" }, "advanced": "高级", "enableOpus1M": { "name": "Opus 1M 上下文窗口", "desc": "在模型选择器中显示 Opus 1M。Max、Team 和 Enterprise 计划已包含。API 和 Pro 用户需要额外用量。" }, "enableSonnet1M": { "name": "Sonnet 1M 上下文窗口", "desc": "在模型选择器中显示 Sonnet 1M。Max、Team 和 Enterprise 计划需要额外用量。API 和 Pro 用户需要额外用量。" }, "enableChrome": { "name": "启用 Chrome 扩展", "desc": "允许 Claude 通过 claude-in-chrome 扩展与 Chrome 交互。需要安装该扩展。需要重启会话。" }, "enableBangBash": { "name": "启用命令模式 (!)", "desc": "在空输入框中输入 ! 进入命令模式。通过 Node.js child_process 直接运行命令。需要重新打开视图。", "validation": { "noNode": "未在 PATH 中找到 Node.js。请安装 Node.js 或检查 PATH 配置。" } }, "maxTabs": { "name": "最大聊天标签数", "desc": "同时开启的最大聊天标签数(3-10)。每个标签使用独立的 Claude 会话。", "warning": "超过 5 个标签可能会影响性能和内存使用。" }, "tabBarPosition": { "name": "标签栏位置", "desc": "选择标签徽章和操作按钮的显示位置", "input": "输入框上方(默认)", "header": "在标题栏" }, "enableAutoScroll": { "name": "流式传输时自动滚动", "desc": "在 Claude 流式传输响应时自动滚动到底部。禁用后将停留在顶部,从头开始阅读。" }, "openInMainTab": { "name": "在主编辑器区域打开", "desc": "在中央编辑器区域以主标签页形式打开聊天面板,而不是在右侧边栏" }, "cliPath": { "name": "Claude CLI 路径", "desc": "Claude Code CLI 的自定义路径。留空使用自动检测。", "descWindows": "对于原生安装程序,使用 claude.exe。对于 npm/pnpm/yarn 或其他包管理器安装,使用 cli.js 路径(不是 claude.cmd)。", "descUnix": "粘贴 \"which claude\" 的输出 - 适用于原生安装和 npm/pnpm/yarn 安装。", "validation": { "notExist": "路径不存在", "isDirectory": "路径是目录,不是文件" } }, "language": { "name": "语言", "desc": "更改插件界面的显示语言" } } } ================================================ FILE: src/i18n/locales/zh-TW.json ================================================ { "common": { "save": "保存", "cancel": "取消", "delete": "刪除", "edit": "編輯", "add": "添加", "remove": "移除", "clear": "清除", "clearAll": "清除全部", "loading": "加載中", "error": "錯誤", "success": "成功", "warning": "警告", "confirm": "確認", "settings": "設置", "advanced": "高級", "enabled": "已啟用", "disabled": "已禁用", "platform": "平台", "refresh": "重新整理", "rewind": "回退" }, "chat": { "rewind": { "confirmMessage": "回退到此處?此訊息之後的檔案變更將被還原。回退不會影響手動或透過 bash 編輯的檔案。", "confirmButton": "回退", "ariaLabel": "回退到此處", "notice": "已回退:還原了 {count} 個檔案", "noticeSaveFailed": "已回退:還原了 {count} 個檔案,但無法儲存狀態:{error}", "failed": "回退失敗:{error}", "cannot": "無法回退:{error}", "unavailableStreaming": "串流回應中無法回退", "unavailableNoUuid": "無法回退:缺少訊息識別碼" }, "fork": { "ariaLabel": "分叉對話", "chooseTarget": "分叉對話", "targetNewTab": "新分頁", "targetCurrentTab": "目前分頁", "maxTabsReached": "無法分叉:已達到最大 {count} 個分頁", "notice": "已分叉到新分頁", "noticeCurrentTab": "已在目前分頁分叉", "failed": "分叉失敗:{error}", "unavailableStreaming": "串流回應中無法分叉", "unavailableNoUuid": "無法分叉:缺少訊息識別碼", "unavailableNoResponse": "無法分叉:沒有可分叉的回應", "errorMessageNotFound": "找不到訊息", "errorNoSession": "沒有可用的工作階段 ID", "errorNoActiveTab": "沒有使用中的分頁", "commandNoMessages": "無法分叉:對話中沒有訊息", "commandNoAssistantUuid": "無法分叉:沒有帶識別碼的助手回覆" }, "bangBash": { "placeholder": "> 執行 bash 指令...", "commandPanel": "指令面板", "copyAriaLabel": "複製最新的指令輸出", "clearAriaLabel": "清除 bash 輸出", "commandLabel": "{command}", "statusLabel": "狀態:{status}", "collapseOutput": "摺疊指令輸出", "expandOutput": "展開指令輸出", "running": "執行中...", "copyFailed": "複製到剪貼簿失敗" } }, "settings": { "title": "Claudian 設定", "customization": "個人化設定", "userName": { "name": "Claudian 應該如何稱呼您?", "desc": "用於個人化問候的使用者名稱(留空使用通用問候)" }, "excludedTags": { "name": "排除的標籤", "desc": "包含這些標籤的筆記不會自動載入為上下文(每行一個,不帶 #)" }, "mediaFolder": { "name": "媒體資料夾", "desc": "存放附件/圖片的資料夾。當筆記使用 ![[image.jpg]] 時,Claude 會在此查找。留空使用儲存庫根目錄。" }, "systemPrompt": { "name": "自訂系統提示詞", "desc": "附加到預設系統提示詞的額外指令" }, "autoTitle": { "name": "自動生成對話標題", "desc": "在使用者送出第一則訊息後自動生成對話標題。" }, "titleModel": { "name": "標題生成模型", "desc": "用於自動生成對話標題的模型。", "auto": "自動 (Haiku)" }, "navMappings": { "name": "Vim 風格導航映射", "desc": "每行一個映射。格式:\"map <鍵> <動作>\"(動作:scrollUp, scrollDown, focusInput)。" }, "hotkeys": "快捷鍵", "inlineEditHotkey": { "name": "內嵌編輯", "descWithKey": "目前快捷鍵:{hotkey}", "descNoKey": "未設定快捷鍵", "btnChange": "變更", "btnSet": "設定快捷鍵" }, "openChatHotkey": { "name": "開啟聊天", "descWithKey": "目前快捷鍵:{hotkey}", "descNoKey": "未設定快捷鍵", "btnChange": "變更", "btnSet": "設定快捷鍵" }, "newSessionHotkey": { "name": "新工作階段", "descWithKey": "目前快捷鍵:{hotkey}", "descNoKey": "未設定快捷鍵", "btnChange": "變更", "btnSet": "設定快捷鍵" }, "newTabHotkey": { "name": "新分頁", "descWithKey": "目前快捷鍵:{hotkey}", "descNoKey": "未設定快捷鍵", "btnChange": "變更", "btnSet": "設定快捷鍵" }, "closeTabHotkey": { "name": "關閉分頁", "descWithKey": "目前快捷鍵:{hotkey}", "descNoKey": "未設定快捷鍵", "btnChange": "變更", "btnSet": "設定快捷鍵" }, "slashCommands": { "name": "命令與技能", "desc": "定義由 /名稱 觸發的自訂命令與技能。" }, "hiddenSlashCommands": { "name": "隱藏命令", "desc": "從下拉選單中隱藏特定的斜線命令。適用於隱藏與 Claudian 無關的 Claude Code 命令。每行輸入一個命令名稱,無需前導斜線。", "placeholder": "commit\nbuild\ntest" }, "mcpServers": { "name": "MCP 伺服器", "desc": "設定模型上下文協定伺服器,透過外部工具和資料來源擴展 Claude 的能力。啟用上下文保存模式的伺服器需要 @ 提及才能啟用。" }, "plugins": { "name": "Claude Code 外掛程式", "desc": "啟用或停用從 ~/.claude/plugins 發現的 Claude Code 外掛程式。已啟用的外掛程式按儲存庫儲存。" }, "subagents": { "name": "子代理", "desc": "設定 Claude 可委派的自訂子代理。", "noAgents": "尚未設定子代理。點擊 + 建立一個。", "deleteConfirm": "刪除子代理「{name}」?", "saveFailed": "儲存子代理失敗:{message}", "refreshFailed": "重新整理子代理失敗:{message}", "deleteFailed": "刪除子代理失敗:{message}", "renameCleanupFailed": "警告:無法移除「{name}」的舊檔案", "created": "已建立子代理「{name}」", "updated": "已更新子代理「{name}」", "deleted": "已刪除子代理「{name}」", "duplicateName": "已存在名為「{name}」的代理", "descriptionRequired": "描述為必填", "promptRequired": "系統提示詞為必填", "modal": { "titleEdit": "編輯子代理", "titleAdd": "新增子代理", "name": "名稱", "nameDesc": "僅限小寫字母、數字與連字號", "namePlaceholder": "code-reviewer", "description": "描述", "descriptionDesc": "此代理的簡短描述", "descriptionPlaceholder": "檢查程式碼中的錯誤與風格問題", "advancedOptions": "進階選項", "model": "模型", "modelDesc": "此代理的模型覆寫", "tools": "工具", "toolsDesc": "允許工具的逗號分隔清單(留空 = 全部)", "disallowedTools": "禁用工具", "disallowedToolsDesc": "要禁用的工具清單,以逗號分隔", "skills": "技能", "skillsDesc": "技能清單,以逗號分隔", "prompt": "系統提示詞", "promptDesc": "給代理的指示", "promptPlaceholder": "你是一名程式碼審查員。請分析給定的程式碼..." } }, "safety": "安全", "loadUserSettings": { "name": "載入使用者 Claude 設定", "desc": "載入 ~/.claude/settings.json。啟用後,使用者的 Claude Code 權限規則可能繞過安全模式。" }, "enableBlocklist": { "name": "啟用命令黑名單", "desc": "阻止潛在危險的 bash 命令" }, "allowExternalAccess": { "name": "允許外部存取", "desc": "允許存取倉庫目錄之外的檔案與命令。對所有活動工作階段立即生效。關閉倉庫限制可能使敏感檔案暴露於提示注入攻擊。" }, "blockedCommands": { "name": "阻止的命令 ({platform})", "desc": "在 {platform} 上阻止的模式(每行一個)。支援正則表示式。", "unixName": "阻止的命令 (Unix/Git Bash)", "unixDesc": "Unix 模式在 Windows 上也會被阻止,因為 Git Bash 可以呼叫它們。" }, "exportPaths": { "name": "允許的匯出路徑", "desc": "當外部存取關閉時,允許作為僅寫匯出目標的儲存庫外路徑(每行一個)。支援 ~ 表示主目錄。", "disabledDesc": "啟用外部存取時會忽略此設定。關閉外部存取後才會重新強制執行僅寫匯出路徑。" }, "environment": "環境", "customVariables": { "name": "自訂變數", "desc": "Claude SDK 的環境變數(KEY=VALUE 格式,每行一個)。支援 export 前綴。" }, "envSnippets": { "name": "片段", "addBtn": "新增片段", "noSnippets": "尚無保存的環境變數片段。點擊 + 保存當前配置。", "nameRequired": "請輸入片段名稱", "modal": { "titleEdit": "編輯片段", "titleSave": "保存片段", "name": "名稱", "namePlaceholder": "此配置的描述性名稱", "description": "描述", "descPlaceholder": "可選描述", "envVars": "環境變數", "envVarsPlaceholder": "KEY=VALUE 格式,每行一個(支援 export 前綴)", "save": "保存", "update": "更新", "cancel": "取消" } }, "customContextLimits": { "name": "自訂上下文限制", "desc": "為您的自訂模型設定上下文視窗大小。留空使用預設值(200k 權杖)。", "invalid": "格式無效。使用:256k、1m 或精確數量(1000-10000000)。" }, "advanced": "進階", "enableOpus1M": { "name": "Opus 1M 上下文視窗", "desc": "在模型選擇器中顯示 Opus 1M。Max、Team 和 Enterprise 方案已包含。API 和 Pro 使用者需要額外用量。" }, "enableSonnet1M": { "name": "Sonnet 1M 上下文視窗", "desc": "在模型選擇器中顯示 Sonnet 1M。Max、Team 和 Enterprise 方案需要額外用量。API 和 Pro 使用者需要額外用量。" }, "enableChrome": { "name": "啟用 Chrome 擴充功能", "desc": "允許 Claude 透過 claude-in-chrome 擴充功能與 Chrome 互動。需要安裝該擴充功能。需要重新啟動工作階段。" }, "enableBangBash": { "name": "啟用 bash 模式 (!)", "desc": "在空白輸入框中輸入 ! 以進入 bash 模式。透過 Node.js child_process 直接執行指令。需要重新開啟檢視。", "validation": { "noNode": "在 PATH 中找不到 Node.js。請安裝 Node.js 或檢查 PATH 設定。" } }, "maxTabs": { "name": "最大聊天標籤數", "desc": "同時開啟的最大聊天標籤數(3-10)。每個標籤使用獨立的 Claude 對話。", "warning": "超過 5 個標籤可能會影響效能和記憶體使用。" }, "tabBarPosition": { "name": "標籤列位置", "desc": "選擇標籤徽章和操作按鈕的顯示位置", "input": "輸入框上方(預設)", "header": "在標題列" }, "enableAutoScroll": { "name": "串流傳輸時自動捲動", "desc": "在 Claude 串流傳輸回應時自動捲動到底部。停用後將停留在頂部,從頭開始閱讀。" }, "openInMainTab": { "name": "在主編輯器區域開啟", "desc": "在中央編輯器區域以主分頁形式開啟聊天面板,而不是在右側邊欄" }, "cliPath": { "name": "Claude CLI 路徑", "desc": "Claude Code CLI 的自訂路徑。留空使用自動檢測。", "descWindows": "對於原生安裝程式,使用 claude.exe。對於 npm/pnpm/yarn 或其他套件管理器安裝,使用 cli.js 路徑(不是 claude.cmd)。", "descUnix": "貼上 \"which claude\" 的輸出 - 適用於原生安裝和 npm/pnpm/yarn 安裝。", "validation": { "notExist": "路徑不存在", "isDirectory": "路徑是目錄,不是檔案" } }, "language": { "name": "語言", "desc": "更改插件介面的顯示語言" } } } ================================================ FILE: src/i18n/types.ts ================================================ /** * i18n type definitions */ export type Locale = 'en' | 'zh-CN' | 'zh-TW' | 'ja' | 'ko' | 'de' | 'fr' | 'es' | 'ru' | 'pt'; /** * Translation key type - represents all valid translation keys * This is a union of all possible dot-notation keys from the translation files */ export type TranslationKey = // Common UI elements | 'common.save' | 'common.cancel' | 'common.delete' | 'common.edit' | 'common.add' | 'common.remove' | 'common.clear' | 'common.clearAll' | 'common.loading' | 'common.error' | 'common.success' | 'common.warning' | 'common.confirm' | 'common.settings' | 'common.advanced' | 'common.enabled' | 'common.disabled' | 'common.platform' | 'common.refresh' | 'common.rewind' // Chat - Rewind | 'chat.rewind.confirmMessage' | 'chat.rewind.confirmButton' | 'chat.rewind.ariaLabel' | 'chat.rewind.notice' | 'chat.rewind.noticeSaveFailed' | 'chat.rewind.failed' | 'chat.rewind.cannot' | 'chat.rewind.unavailableStreaming' | 'chat.rewind.unavailableNoUuid' | 'chat.bangBash.placeholder' | 'chat.bangBash.commandPanel' | 'chat.bangBash.copyAriaLabel' | 'chat.bangBash.clearAriaLabel' | 'chat.bangBash.commandLabel' | 'chat.bangBash.statusLabel' | 'chat.bangBash.collapseOutput' | 'chat.bangBash.expandOutput' | 'chat.bangBash.running' | 'chat.bangBash.copyFailed' // Chat - Fork | 'chat.fork.ariaLabel' | 'chat.fork.chooseTarget' | 'chat.fork.targetNewTab' | 'chat.fork.targetCurrentTab' | 'chat.fork.maxTabsReached' | 'chat.fork.notice' | 'chat.fork.noticeCurrentTab' | 'chat.fork.failed' | 'chat.fork.unavailableStreaming' | 'chat.fork.unavailableNoUuid' | 'chat.fork.unavailableNoResponse' | 'chat.fork.errorMessageNotFound' | 'chat.fork.errorNoSession' | 'chat.fork.errorNoActiveTab' | 'chat.fork.commandNoMessages' | 'chat.fork.commandNoAssistantUuid' // Settings - Customization | 'settings.title' | 'settings.customization' | 'settings.userName.name' | 'settings.userName.desc' | 'settings.excludedTags.name' | 'settings.excludedTags.desc' | 'settings.mediaFolder.name' | 'settings.mediaFolder.desc' | 'settings.systemPrompt.name' | 'settings.systemPrompt.desc' | 'settings.autoTitle.name' | 'settings.autoTitle.desc' | 'settings.titleModel.name' | 'settings.titleModel.desc' | 'settings.titleModel.auto' | 'settings.navMappings.name' | 'settings.navMappings.desc' // Settings - Hotkeys | 'settings.hotkeys' | 'settings.inlineEditHotkey.name' | 'settings.inlineEditHotkey.descWithKey' | 'settings.inlineEditHotkey.descNoKey' | 'settings.inlineEditHotkey.btnChange' | 'settings.inlineEditHotkey.btnSet' | 'settings.openChatHotkey.name' | 'settings.openChatHotkey.descWithKey' | 'settings.openChatHotkey.descNoKey' | 'settings.openChatHotkey.btnChange' | 'settings.openChatHotkey.btnSet' | 'settings.newSessionHotkey.name' | 'settings.newSessionHotkey.descWithKey' | 'settings.newSessionHotkey.descNoKey' | 'settings.newSessionHotkey.btnChange' | 'settings.newSessionHotkey.btnSet' | 'settings.newTabHotkey.name' | 'settings.newTabHotkey.descWithKey' | 'settings.newTabHotkey.descNoKey' | 'settings.newTabHotkey.btnChange' | 'settings.newTabHotkey.btnSet' | 'settings.closeTabHotkey.name' | 'settings.closeTabHotkey.descWithKey' | 'settings.closeTabHotkey.descNoKey' | 'settings.closeTabHotkey.btnChange' | 'settings.closeTabHotkey.btnSet' // Settings - Commands and Skills | 'settings.slashCommands.name' | 'settings.slashCommands.desc' | 'settings.hiddenSlashCommands.name' | 'settings.hiddenSlashCommands.desc' | 'settings.hiddenSlashCommands.placeholder' // Settings - MCP Servers | 'settings.mcpServers.name' | 'settings.mcpServers.desc' // Settings - Plugins | 'settings.plugins.name' | 'settings.plugins.desc' // Settings - Subagents | 'settings.subagents.name' | 'settings.subagents.desc' | 'settings.subagents.noAgents' | 'settings.subagents.deleteConfirm' | 'settings.subagents.saveFailed' | 'settings.subagents.refreshFailed' | 'settings.subagents.deleteFailed' | 'settings.subagents.renameCleanupFailed' | 'settings.subagents.created' | 'settings.subagents.updated' | 'settings.subagents.deleted' | 'settings.subagents.duplicateName' | 'settings.subagents.descriptionRequired' | 'settings.subagents.promptRequired' | 'settings.subagents.modal.titleEdit' | 'settings.subagents.modal.titleAdd' | 'settings.subagents.modal.name' | 'settings.subagents.modal.nameDesc' | 'settings.subagents.modal.namePlaceholder' | 'settings.subagents.modal.description' | 'settings.subagents.modal.descriptionDesc' | 'settings.subagents.modal.descriptionPlaceholder' | 'settings.subagents.modal.advancedOptions' | 'settings.subagents.modal.model' | 'settings.subagents.modal.modelDesc' | 'settings.subagents.modal.tools' | 'settings.subagents.modal.toolsDesc' | 'settings.subagents.modal.disallowedTools' | 'settings.subagents.modal.disallowedToolsDesc' | 'settings.subagents.modal.skills' | 'settings.subagents.modal.skillsDesc' | 'settings.subagents.modal.prompt' | 'settings.subagents.modal.promptDesc' | 'settings.subagents.modal.promptPlaceholder' // Settings - Safety | 'settings.safety' | 'settings.loadUserSettings.name' | 'settings.loadUserSettings.desc' | 'settings.enableBlocklist.name' | 'settings.enableBlocklist.desc' | 'settings.allowExternalAccess.name' | 'settings.allowExternalAccess.desc' | 'settings.blockedCommands.name' | 'settings.blockedCommands.desc' | 'settings.blockedCommands.unixName' | 'settings.blockedCommands.unixDesc' | 'settings.exportPaths.name' | 'settings.exportPaths.desc' | 'settings.exportPaths.disabledDesc' // Settings - Environment | 'settings.environment' | 'settings.customVariables.name' | 'settings.customVariables.desc' | 'settings.envSnippets.name' | 'settings.envSnippets.addBtn' | 'settings.envSnippets.noSnippets' | 'settings.envSnippets.nameRequired' | 'settings.envSnippets.modal.titleEdit' | 'settings.envSnippets.modal.titleSave' | 'settings.envSnippets.modal.name' | 'settings.envSnippets.modal.namePlaceholder' | 'settings.envSnippets.modal.description' | 'settings.envSnippets.modal.descPlaceholder' | 'settings.envSnippets.modal.envVars' | 'settings.envSnippets.modal.envVarsPlaceholder' | 'settings.envSnippets.modal.save' | 'settings.envSnippets.modal.update' | 'settings.envSnippets.modal.cancel' // Settings - Custom Context Limits | 'settings.customContextLimits.name' | 'settings.customContextLimits.desc' | 'settings.customContextLimits.invalid' // Settings - Advanced | 'settings.advanced' | 'settings.enableOpus1M.name' | 'settings.enableOpus1M.desc' | 'settings.enableSonnet1M.name' | 'settings.enableSonnet1M.desc' | 'settings.enableChrome.name' | 'settings.enableChrome.desc' | 'settings.enableBangBash.name' | 'settings.enableBangBash.desc' | 'settings.enableBangBash.validation.noNode' | 'settings.maxTabs.name' | 'settings.maxTabs.desc' | 'settings.maxTabs.warning' | 'settings.tabBarPosition.name' | 'settings.tabBarPosition.desc' | 'settings.tabBarPosition.input' | 'settings.tabBarPosition.header' | 'settings.enableAutoScroll.name' | 'settings.enableAutoScroll.desc' | 'settings.openInMainTab.name' | 'settings.openInMainTab.desc' | 'settings.cliPath.name' | 'settings.cliPath.desc' | 'settings.cliPath.descWindows' | 'settings.cliPath.descUnix' | 'settings.cliPath.validation.notExist' | 'settings.cliPath.validation.isDirectory' // Settings - Language | 'settings.language.name' | 'settings.language.desc'; ================================================ FILE: src/main.ts ================================================ /** * Claudian - Obsidian plugin entry point * * Registers the sidebar chat view, settings tab, and commands. * Manages conversation persistence and environment variable configuration. */ import type { Editor, MarkdownView } from 'obsidian'; import { Notice, Plugin } from 'obsidian'; import { AgentManager } from './core/agents'; import { McpServerManager } from './core/mcp'; import { PluginManager } from './core/plugins'; import { StorageService } from './core/storage'; import { isSubagentToolName, TOOL_TASK } from './core/tools/toolNames'; import type { AsyncSubagentStatus, ChatMessage, ClaudianSettings, Conversation, ConversationMeta, SlashCommand, SubagentInfo, ToolCallInfo, } from './core/types'; import { DEFAULT_CLAUDE_MODELS, DEFAULT_SETTINGS, getCliPlatformKey, getHostnameKey, normalizeVisibleModelVariant, VIEW_TYPE_CLAUDIAN, } from './core/types'; import { ClaudianView } from './features/chat/ClaudianView'; import { type InlineEditContext, InlineEditModal } from './features/inline-edit/ui/InlineEditModal'; import { ClaudianSettingTab } from './features/settings/ClaudianSettings'; import { setLocale } from './i18n'; import { ClaudeCliResolver } from './utils/claudeCli'; import { buildCursorContext } from './utils/editor'; import { getCurrentModelFromEnvironment, getModelsFromEnvironment, parseEnvironmentVariables } from './utils/env'; import { getVaultPath } from './utils/path'; import { deleteSDKSession, loadSDKSessionMessages, loadSubagentToolCalls, sdkSessionExists, type SDKSessionLoadResult, } from './utils/sdkSession'; // ============================================ // Subagent data merge helpers (pure functions) // ============================================ function chooseRicherResult(sdkResult?: string, cachedResult?: string): string | undefined { const sdkText = typeof sdkResult === 'string' ? sdkResult.trim() : ''; const cachedText = typeof cachedResult === 'string' ? cachedResult.trim() : ''; if (sdkText.length === 0 && cachedText.length === 0) return undefined; if (sdkText.length === 0) return cachedResult; if (cachedText.length === 0) return sdkResult; return sdkText.length >= cachedText.length ? sdkResult : cachedResult; } function chooseRicherToolCalls( sdkToolCalls: ToolCallInfo[] = [], cachedToolCalls: ToolCallInfo[] = [] ): ToolCallInfo[] { if (sdkToolCalls.length >= cachedToolCalls.length) return sdkToolCalls; return cachedToolCalls; } function normalizeAsyncStatus( subagent: SubagentInfo | undefined, modeOverride?: SubagentInfo['mode'] ): AsyncSubagentStatus | undefined { if (!subagent) return undefined; const mode = modeOverride ?? subagent.mode; if (mode === 'sync') return undefined; if (mode === 'async') return subagent.asyncStatus ?? subagent.status; return subagent.asyncStatus; } function isTerminalAsyncStatus(status: AsyncSubagentStatus | undefined): boolean { return status === 'completed' || status === 'error' || status === 'orphaned'; } function mergeSubagentInfo( taskToolCall: ToolCallInfo, cachedSubagent: SubagentInfo ): SubagentInfo { const sdkSubagent = taskToolCall.subagent; const cachedAsyncStatus = normalizeAsyncStatus(cachedSubagent); if (!sdkSubagent) { return { ...cachedSubagent, asyncStatus: cachedAsyncStatus, result: chooseRicherResult(taskToolCall.result, cachedSubagent.result), }; } const sdkAsyncStatus = normalizeAsyncStatus(sdkSubagent); const sdkIsTerminal = isTerminalAsyncStatus(sdkAsyncStatus); const cachedIsTerminal = isTerminalAsyncStatus(cachedAsyncStatus); const sdkResult = taskToolCall.result ?? sdkSubagent.result; // Prefer cached data only when it reached a terminal state but SDK hasn't yet const preferred = (!sdkIsTerminal && cachedIsTerminal) ? cachedSubagent : sdkSubagent; const mergedMode = sdkSubagent.mode ?? cachedSubagent.mode ?? (taskToolCall.input?.run_in_background === true ? 'async' : undefined); const fallbackResult = chooseRicherResult(sdkResult, cachedSubagent.result); const mergedResult = preferred === cachedSubagent ? (cachedSubagent.result ?? fallbackResult) : fallbackResult; const mergedAsyncStatus = normalizeAsyncStatus(preferred, mergedMode); return { ...cachedSubagent, ...sdkSubagent, description: sdkSubagent.description || cachedSubagent.description, prompt: sdkSubagent.prompt || cachedSubagent.prompt, mode: mergedMode, status: preferred.status, asyncStatus: mergedAsyncStatus, result: mergedResult, toolCalls: chooseRicherToolCalls(sdkSubagent.toolCalls, cachedSubagent.toolCalls), agentId: sdkSubagent.agentId || cachedSubagent.agentId, outputToolId: sdkSubagent.outputToolId || cachedSubagent.outputToolId, startedAt: sdkSubagent.startedAt ?? cachedSubagent.startedAt, completedAt: sdkSubagent.completedAt ?? cachedSubagent.completedAt, isExpanded: sdkSubagent.isExpanded ?? cachedSubagent.isExpanded, }; } function ensureTaskToolCall( msg: ChatMessage, subagentId: string, subagent: SubagentInfo ): ToolCallInfo { msg.toolCalls = msg.toolCalls || []; let taskToolCall = msg.toolCalls.find( tc => tc.id === subagentId && isSubagentToolName(tc.name) ); if (!taskToolCall) { taskToolCall = { id: subagentId, name: TOOL_TASK, input: { description: subagent.description, prompt: subagent.prompt || '', ...(subagent.mode === 'async' ? { run_in_background: true } : {}), }, status: subagent.status, result: subagent.result, isExpanded: false, subagent, }; msg.toolCalls.push(taskToolCall); return taskToolCall; } if (!taskToolCall.input.description) taskToolCall.input.description = subagent.description; if (!taskToolCall.input.prompt) taskToolCall.input.prompt = subagent.prompt || ''; if (subagent.mode === 'async') taskToolCall.input.run_in_background = true; const mergedSubagent = mergeSubagentInfo(taskToolCall, subagent); taskToolCall.status = mergedSubagent.status; if (mergedSubagent.mode === 'async') { taskToolCall.input.run_in_background = true; } if (mergedSubagent.result !== undefined) { taskToolCall.result = mergedSubagent.result; } taskToolCall.subagent = mergedSubagent; return taskToolCall; } /** * Main plugin class for Claudian. * Handles plugin lifecycle, settings persistence, and conversation management. */ export default class ClaudianPlugin extends Plugin { settings: ClaudianSettings; mcpManager: McpServerManager; pluginManager: PluginManager; agentManager: AgentManager; storage: StorageService; cliResolver: ClaudeCliResolver; private conversations: Conversation[] = []; private runtimeEnvironmentVariables = ''; async onload() { await this.loadSettings(); this.cliResolver = new ClaudeCliResolver(); // Initialize MCP manager (shared for agent + UI) this.mcpManager = new McpServerManager(this.storage.mcp); await this.mcpManager.loadServers(); // Initialize plugin manager (reads from installed_plugins.json + settings.json) const vaultPath = (this.app.vault.adapter as any).basePath; this.pluginManager = new PluginManager(vaultPath, this.storage.ccSettings); await this.pluginManager.loadPlugins(); // Initialize agent manager (loads plugin agents from plugin install paths) this.agentManager = new AgentManager(vaultPath, this.pluginManager); await this.agentManager.loadAgents(); this.registerView( VIEW_TYPE_CLAUDIAN, (leaf) => new ClaudianView(leaf, this) ); this.addRibbonIcon('bot', 'Open Claudian', () => { this.activateView(); }); this.addCommand({ id: 'open-view', name: 'Open chat view', callback: () => { this.activateView(); }, }); this.addCommand({ id: 'inline-edit', name: 'Inline edit', editorCallback: async (editor: Editor, view: MarkdownView) => { const selectedText = editor.getSelection(); const notePath = view.file?.path || 'unknown'; let editContext: InlineEditContext; if (selectedText.trim()) { editContext = { mode: 'selection', selectedText }; } else { const cursor = editor.getCursor(); const cursorContext = buildCursorContext( (line) => editor.getLine(line), editor.lineCount(), cursor.line, cursor.ch ); editContext = { mode: 'cursor', cursorContext }; } const modal = new InlineEditModal( this.app, this, editor, view, editContext, notePath, () => this.getView()?.getActiveTab()?.ui.externalContextSelector?.getExternalContexts() ?? [] ); const result = await modal.openAndWait(); if (result.decision === 'accept' && result.editedText !== undefined) { new Notice(editContext.mode === 'cursor' ? 'Inserted' : 'Edit applied'); } }, }); this.addCommand({ id: 'new-tab', name: 'New tab', checkCallback: (checking: boolean) => { const leaf = this.app.workspace.getLeavesOfType(VIEW_TYPE_CLAUDIAN)[0]; if (!leaf) return false; const view = leaf.view as ClaudianView; const tabManager = view.getTabManager(); if (!tabManager) return false; if (!tabManager.canCreateTab()) return false; if (!checking) { tabManager.createTab(); } return true; }, }); this.addCommand({ id: 'new-session', name: 'New session (in current tab)', checkCallback: (checking: boolean) => { const leaf = this.app.workspace.getLeavesOfType(VIEW_TYPE_CLAUDIAN)[0]; if (!leaf) return false; const view = leaf.view as ClaudianView; const tabManager = view.getTabManager(); if (!tabManager) return false; const activeTab = tabManager.getActiveTab(); if (!activeTab) return false; if (activeTab.state.isStreaming) return false; if (!checking) { tabManager.createNewConversation(); } return true; }, }); this.addCommand({ id: 'close-current-tab', name: 'Close current tab', checkCallback: (checking: boolean) => { const leaf = this.app.workspace.getLeavesOfType(VIEW_TYPE_CLAUDIAN)[0]; if (!leaf) return false; const view = leaf.view as ClaudianView; const tabManager = view.getTabManager(); if (!tabManager) return false; if (!checking) { const activeTabId = tabManager.getActiveTabId(); if (activeTabId) { // When closing the last tab, TabManager will create a new empty one tabManager.closeTab(activeTabId); } } return true; }, }); this.addSettingTab(new ClaudianSettingTab(this.app, this)); } async onunload() { // Ensures state is saved even if Obsidian quits without calling onClose() for (const view of this.getAllViews()) { const tabManager = view.getTabManager(); if (tabManager) { const state = tabManager.getPersistedState(); await this.storage.setTabManagerState(state); } } } async activateView() { const { workspace } = this.app; let leaf = workspace.getLeavesOfType(VIEW_TYPE_CLAUDIAN)[0]; if (!leaf) { const newLeaf = this.settings.openInMainTab ? workspace.getLeaf('tab') : workspace.getRightLeaf(false); if (newLeaf) { await newLeaf.setViewState({ type: VIEW_TYPE_CLAUDIAN, active: true, }); leaf = newLeaf; } } if (leaf) { workspace.revealLeaf(leaf); } } /** Loads settings and conversations from persistent storage. */ async loadSettings() { // Initialize storage service (handles migration if needed) this.storage = new StorageService(this); const { claudian } = await this.storage.initialize(); const slashCommands = await this.storage.loadAllSlashCommands(); this.settings = { ...DEFAULT_SETTINGS, ...claudian, slashCommands, }; // Plan mode is ephemeral — normalize back to normal on load so the app // doesn't start stuck in plan mode after a restart (prePlanPermissionMode is lost) if (this.settings.permissionMode === 'plan') { this.settings.permissionMode = 'normal'; } const didNormalizeModelVariants = this.normalizeModelVariantSettings(); // Initialize and migrate legacy CLI paths to hostname-based paths this.settings.claudeCliPathsByHost ??= {}; const hostname = getHostnameKey(); let didMigrateCliPath = false; if (!this.settings.claudeCliPathsByHost[hostname]) { // eslint-disable-next-line @typescript-eslint/no-explicit-any const platformPaths = (this.settings as any).claudeCliPaths as Record | undefined; const migratedPath = platformPaths?.[getCliPlatformKey()]?.trim() || this.settings.claudeCliPath?.trim(); if (migratedPath) { this.settings.claudeCliPathsByHost[hostname] = migratedPath; this.settings.claudeCliPath = ''; didMigrateCliPath = true; } } // eslint-disable-next-line @typescript-eslint/no-explicit-any delete (this.settings as any).claudeCliPaths; // Load all conversations from session files (legacy JSONL + native metadata) const { conversations: legacyConversations, failedCount } = await this.storage.sessions.loadAllConversations(); const legacyIds = new Set(legacyConversations.map(c => c.id)); // Overlay native metadata onto legacy conversations if present for (const conversation of legacyConversations) { const meta = await this.storage.sessions.loadMetadata(conversation.id); if (!meta) continue; conversation.isNative = true; conversation.title = meta.title ?? conversation.title; conversation.titleGenerationStatus = meta.titleGenerationStatus ?? conversation.titleGenerationStatus; conversation.createdAt = meta.createdAt ?? conversation.createdAt; conversation.updatedAt = meta.updatedAt ?? conversation.updatedAt; conversation.lastResponseAt = meta.lastResponseAt ?? conversation.lastResponseAt; if (meta.sessionId !== undefined) { conversation.sessionId = meta.sessionId; } conversation.currentNote = meta.currentNote ?? conversation.currentNote; conversation.externalContextPaths = meta.externalContextPaths ?? conversation.externalContextPaths; conversation.enabledMcpServers = meta.enabledMcpServers ?? conversation.enabledMcpServers; conversation.usage = meta.usage ?? conversation.usage; if (meta.sdkSessionId !== undefined) { conversation.sdkSessionId = meta.sdkSessionId; } else if (conversation.sdkSessionId === undefined && conversation.sessionId) { conversation.sdkSessionId = conversation.sessionId; } conversation.previousSdkSessionIds = meta.previousSdkSessionIds ?? conversation.previousSdkSessionIds; conversation.legacyCutoffAt = meta.legacyCutoffAt ?? conversation.legacyCutoffAt; conversation.subagentData = meta.subagentData ?? conversation.subagentData; conversation.resumeSessionAt = meta.resumeSessionAt ?? conversation.resumeSessionAt; conversation.forkSource = meta.forkSource ?? conversation.forkSource; } // Also load native session metadata (no legacy JSONL) const nativeMetadata = await this.storage.sessions.listNativeMetadata(); const nativeConversations: Conversation[] = nativeMetadata .filter(meta => !legacyIds.has(meta.id)) .map(meta => { const resumeSessionId = meta.sessionId !== undefined ? meta.sessionId : meta.id; const sdkSessionId = meta.sdkSessionId !== undefined ? meta.sdkSessionId : (resumeSessionId ?? undefined); return { id: meta.id, title: meta.title, createdAt: meta.createdAt, updatedAt: meta.updatedAt, lastResponseAt: meta.lastResponseAt, sessionId: resumeSessionId, sdkSessionId, previousSdkSessionIds: meta.previousSdkSessionIds, messages: [], // Messages are in SDK storage, loaded on demand currentNote: meta.currentNote, externalContextPaths: meta.externalContextPaths, enabledMcpServers: meta.enabledMcpServers, usage: meta.usage, titleGenerationStatus: meta.titleGenerationStatus, legacyCutoffAt: meta.legacyCutoffAt, isNative: true, subagentData: meta.subagentData, // Preserve for applying to loaded messages resumeSessionAt: meta.resumeSessionAt, forkSource: meta.forkSource, }; }); this.conversations = [...legacyConversations, ...nativeConversations].sort( (a, b) => (b.lastResponseAt ?? b.updatedAt) - (a.lastResponseAt ?? a.updatedAt) ); if (failedCount > 0) { new Notice(`Failed to load ${failedCount} conversation${failedCount > 1 ? 's' : ''}`); } setLocale(this.settings.locale); const backfilledConversations = this.backfillConversationResponseTimestamps(); this.runtimeEnvironmentVariables = this.settings.environmentVariables || ''; const { changed, invalidatedConversations } = this.reconcileModelWithEnvironment(this.runtimeEnvironmentVariables); if (changed || didMigrateCliPath || didNormalizeModelVariants) { await this.saveSettings(); } // Persist backfilled and invalidated conversations to their session files const conversationsToSave = new Set([...backfilledConversations, ...invalidatedConversations]); for (const conv of conversationsToSave) { if (conv.isNative) { // Native session: save metadata only await this.storage.sessions.saveMetadata( this.storage.sessions.toSessionMetadata(conv) ); } else { // Legacy session: save full JSONL await this.storage.sessions.saveConversation(conv); } } } private backfillConversationResponseTimestamps(): Conversation[] { const updated: Conversation[] = []; for (const conv of this.conversations) { if (conv.lastResponseAt != null) continue; if (!conv.messages || conv.messages.length === 0) continue; for (let i = conv.messages.length - 1; i >= 0; i--) { const msg = conv.messages[i]; if (msg.role === 'assistant') { conv.lastResponseAt = msg.timestamp; updated.push(conv); break; } } } return updated; } normalizeModelVariantSettings(): boolean { const { enableOpus1M, enableSonnet1M } = this.settings; let changed = false; const normalize = (model: string): string => normalizeVisibleModelVariant(model, enableOpus1M, enableSonnet1M); const normalizedModel = normalize(this.settings.model); if (this.settings.model !== normalizedModel) { this.settings.model = normalizedModel; changed = true; } const normalizedTitleModel = normalize(this.settings.titleGenerationModel); if (this.settings.titleGenerationModel !== normalizedTitleModel) { this.settings.titleGenerationModel = normalizedTitleModel; changed = true; } if (this.settings.lastClaudeModel) { const normalizedLastClaudeModel = normalize(this.settings.lastClaudeModel); if (this.settings.lastClaudeModel !== normalizedLastClaudeModel) { this.settings.lastClaudeModel = normalizedLastClaudeModel; changed = true; } } return changed; } /** Persists settings to storage. */ async saveSettings() { // Save settings (excluding slashCommands which are stored separately) const { slashCommands: _, ...settingsToSave } = this.settings; await this.storage.saveClaudianSettings(settingsToSave); } /** Updates and persists environment variables, restarting processes to apply changes. */ async applyEnvironmentVariables(envText: string): Promise { const envChanged = envText !== this.runtimeEnvironmentVariables; this.settings.environmentVariables = envText; if (!envChanged) { await this.saveSettings(); return; } // Update runtime env vars so new processes use them this.runtimeEnvironmentVariables = envText; const { changed, invalidatedConversations } = this.reconcileModelWithEnvironment(envText); await this.saveSettings(); if (invalidatedConversations.length > 0) { for (const conv of invalidatedConversations) { if (conv.isNative) { await this.storage.sessions.saveMetadata( this.storage.sessions.toSessionMetadata(conv) ); } else { await this.storage.sessions.saveConversation(conv); } } } const view = this.getView(); const tabManager = view?.getTabManager(); if (tabManager) { for (const tab of tabManager.getAllTabs()) { if (tab.state.isStreaming) { tab.controllers.inputController?.cancelStreaming(); } } let failedTabs = 0; if (changed) { for (const tab of tabManager.getAllTabs()) { if (!tab.service || !tab.serviceInitialized) { continue; } try { const externalContextPaths = tab.ui.externalContextSelector?.getExternalContexts() ?? []; tab.service.resetSession(); await tab.service.ensureReady({ externalContextPaths }); } catch { failedTabs++; } } } else { // Restart initialized tabs to pick up env changes try { await tabManager.broadcastToAllTabs( async (service) => { await service.ensureReady({ force: true }); } ); } catch { failedTabs++; } } if (failedTabs > 0) { new Notice(`Environment changes applied, but ${failedTabs} tab(s) failed to restart.`); } } view?.refreshModelSelector(); const noticeText = changed ? 'Environment variables applied. Sessions will be rebuilt on next message.' : 'Environment variables applied.'; new Notice(noticeText); } /** Returns the runtime environment variables (fixed at plugin load). */ getActiveEnvironmentVariables(): string { return this.runtimeEnvironmentVariables; } getResolvedClaudeCliPath(): string | null { return this.cliResolver.resolve( this.settings.claudeCliPathsByHost, // Per-device paths (preferred) this.settings.claudeCliPath, // Legacy path (fallback) this.getActiveEnvironmentVariables() ); } private getDefaultModelValues(): string[] { return DEFAULT_CLAUDE_MODELS.map((m) => m.value); } private getPreferredCustomModel(envVars: Record, customModels: { value: string }[]): string { const envPreferred = getCurrentModelFromEnvironment(envVars); if (envPreferred && customModels.some((m) => m.value === envPreferred)) { return envPreferred; } return customModels[0].value; } /** Computes a hash of model and provider base URL environment variables for change detection. */ private computeEnvHash(envText: string): string { const envVars = parseEnvironmentVariables(envText || ''); const modelKeys = [ 'ANTHROPIC_MODEL', 'ANTHROPIC_DEFAULT_OPUS_MODEL', 'ANTHROPIC_DEFAULT_SONNET_MODEL', 'ANTHROPIC_DEFAULT_HAIKU_MODEL', ]; const providerKeys = [ 'ANTHROPIC_BASE_URL', ]; const allKeys = [...modelKeys, ...providerKeys]; const relevantPairs = allKeys .filter(key => envVars[key]) .map(key => `${key}=${envVars[key]}`) .sort() .join('|'); return relevantPairs; } /** * Reconciles model with environment. * Returns { changed, invalidatedConversations } where changed indicates if * settings were modified (requiring save), and invalidatedConversations lists * conversations that had their sessionId cleared (also requiring save). */ private reconcileModelWithEnvironment(envText: string): { changed: boolean; invalidatedConversations: Conversation[]; } { const currentHash = this.computeEnvHash(envText); const savedHash = this.settings.lastEnvHash || ''; if (currentHash === savedHash) { return { changed: false, invalidatedConversations: [] }; } // Hash changed - model or provider may have changed. // Session invalidation is now handled per-tab by TabManager. // Clear resume sessionId from all conversations since they belong to the old provider. // Sessions are provider-specific (contain signed thinking blocks, etc.). // NOTE: sdkSessionId is retained for loading SDK-stored history. const invalidatedConversations: Conversation[] = []; for (const conv of this.conversations) { if (conv.sessionId) { conv.sessionId = null; invalidatedConversations.push(conv); } } const envVars = parseEnvironmentVariables(envText || ''); const customModels = getModelsFromEnvironment(envVars); if (customModels.length > 0) { this.settings.model = this.getPreferredCustomModel(envVars, customModels); } else { this.settings.model = DEFAULT_CLAUDE_MODELS[0].value; } this.settings.lastEnvHash = currentHash; return { changed: true, invalidatedConversations }; } private generateConversationId(): string { return `conv-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`; } private generateDefaultTitle(): string { const now = new Date(); return now.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', }); } private getConversationPreview(conv: Conversation): string { const firstUserMsg = conv.messages.find(m => m.role === 'user'); if (!firstUserMsg) { // For native sessions without loaded messages, indicate it's a persisted session // rather than "New conversation" which implies no content exists return conv.isNative ? 'SDK session' : 'New conversation'; } return firstUserMsg.content.substring(0, 50) + (firstUserMsg.content.length > 50 ? '...' : ''); } /** Fork has no owned session yet; still referencing the source session for resume. */ private isPendingFork(conversation: Conversation): boolean { return !!conversation.forkSource && !conversation.sdkSessionId && !conversation.sessionId; } private async loadSdkMessagesForConversation(conversation: Conversation): Promise { if (!conversation.isNative || conversation.sdkMessagesLoaded) return; const vaultPath = getVaultPath(this.app); if (!vaultPath) return; const isPendingFork = this.isPendingFork(conversation); const allSessionIds: string[] = isPendingFork ? [conversation.forkSource!.sessionId] : [ ...(conversation.previousSdkSessionIds || []), conversation.sdkSessionId ?? conversation.sessionId, ].filter((id): id is string => !!id); if (allSessionIds.length === 0) return; const allSdkMessages: ChatMessage[] = []; let missingSessionCount = 0; let errorCount = 0; let successCount = 0; const currentSessionId = isPendingFork ? conversation.forkSource!.sessionId : (conversation.sdkSessionId ?? conversation.sessionId); for (const sessionId of allSessionIds) { if (!sdkSessionExists(vaultPath, sessionId)) { missingSessionCount++; continue; } const isCurrentSession = sessionId === currentSessionId; const truncateAt = isCurrentSession ? (isPendingFork ? conversation.forkSource!.resumeAt : conversation.resumeSessionAt) : undefined; const result: SDKSessionLoadResult = await loadSDKSessionMessages( vaultPath, sessionId, truncateAt ); if (result.error) { errorCount++; continue; } successCount++; allSdkMessages.push(...result.messages); } // Note: We intentionally don't notify users about missing session files. // Session files may be missing due to path encoding differences (special characters // in vault path) or external deletion. Showing a notification every restart is // too intrusive and not actionable for users. // Only mark as loaded if at least one session was successfully loaded, // or if all sessions were missing (no point retrying non-existent files). // If sessions exist but ALL failed to load, allow retry on next view. const allSessionsMissing = missingSessionCount === allSessionIds.length; const hasLoadErrors = errorCount > 0 && successCount === 0 && !allSessionsMissing; if (hasLoadErrors) { // Don't mark as loaded - allow retry on next view return; } // Filter out rebuilt context messages (history blobs sent on session reset) const filteredSdkMessages = allSdkMessages.filter(msg => !msg.isRebuiltContext); // Apply legacy cutoff filter if needed const afterCutoff = conversation.legacyCutoffAt != null ? filteredSdkMessages.filter(msg => msg.timestamp > conversation.legacyCutoffAt!) : filteredSdkMessages; const merged = this.dedupeMessages([ ...conversation.messages, ...afterCutoff, ]).sort((a, b) => a.timestamp - b.timestamp); // Apply cached subagentData to loaded messages (for Agent tool count and status) if (conversation.subagentData) { await this.enrichAsyncSubagentToolCalls( conversation.subagentData, vaultPath, allSessionIds ); this.applySubagentData(merged, conversation.subagentData); } conversation.messages = merged; conversation.sdkMessagesLoaded = true; } private async enrichAsyncSubagentToolCalls( subagentData: Record, vaultPath: string, sessionIds: string[] ): Promise { const uniqueSessionIds = [...new Set(sessionIds)]; if (uniqueSessionIds.length === 0) return; const loaderCache = new Map>(); for (const subagent of Object.values(subagentData)) { if (subagent.mode !== 'async') continue; if (!subagent.agentId) continue; if ((subagent.toolCalls?.length ?? 0) > 0) continue; for (const sessionId of uniqueSessionIds) { const cacheKey = `${sessionId}:${subagent.agentId}`; let loader = loaderCache.get(cacheKey); if (!loader) { loader = loadSubagentToolCalls(vaultPath, sessionId, subagent.agentId); loaderCache.set(cacheKey, loader); } const recoveredToolCalls = await loader; if (recoveredToolCalls.length === 0) continue; subagent.toolCalls = recoveredToolCalls.map(toolCall => ({ ...toolCall, input: { ...toolCall.input }, })); break; } } } /** * Applies cached subagentData to messages. * Restores subagent info so Agent tools can show tool count and status. * Also updates contentBlocks to properly identify Agent tools as subagents. */ private applySubagentData(messages: ChatMessage[], subagentData: Record): void { const attachedSubagentIds = new Set(); for (const msg of messages) { if (msg.role !== 'assistant') continue; // Apply subagent data to the message for (const [subagentId, subagent] of Object.entries(subagentData)) { const hasSubagentBlock = msg.contentBlocks?.some( b => (b.type === 'subagent' && b.subagentId === subagentId) || (b.type === 'tool_use' && b.toolId === subagentId) ); const hasTaskToolCall = msg.toolCalls?.some(tc => tc.id === subagentId) ?? false; if (!hasSubagentBlock && !hasTaskToolCall) continue; ensureTaskToolCall(msg, subagentId, subagent); // Update contentBlock from tool_use to subagent, or update existing subagent block with mode if (!msg.contentBlocks) { msg.contentBlocks = []; } let hasNormalizedSubagentBlock = false; for (let i = 0; i < msg.contentBlocks.length; i++) { const block = msg.contentBlocks[i]; if (block.type === 'tool_use' && block.toolId === subagentId) { msg.contentBlocks[i] = { type: 'subagent', subagentId, mode: subagent.mode, }; hasNormalizedSubagentBlock = true; } else if (block.type === 'subagent' && block.subagentId === subagentId && !block.mode) { block.mode = subagent.mode; hasNormalizedSubagentBlock = true; } else if (block.type === 'subagent' && block.subagentId === subagentId) { hasNormalizedSubagentBlock = true; } } if (!hasNormalizedSubagentBlock && hasTaskToolCall) { msg.contentBlocks.push({ type: 'subagent', subagentId, mode: subagent.mode, }); } attachedSubagentIds.add(subagentId); } } for (const [subagentId, subagent] of Object.entries(subagentData)) { if (attachedSubagentIds.has(subagentId)) continue; let anchor = [...messages].reverse().find((msg): msg is ChatMessage => msg.role === 'assistant'); if (!anchor) { anchor = { id: `subagent-recovery-${subagentId}`, role: 'assistant', content: '', timestamp: subagent.completedAt ?? subagent.startedAt ?? Date.now(), contentBlocks: [], }; messages.push(anchor); } ensureTaskToolCall(anchor, subagentId, subagent); anchor.contentBlocks = anchor.contentBlocks || []; const hasSubagentBlock = anchor.contentBlocks.some( block => block.type === 'subagent' && block.subagentId === subagentId ); if (!hasSubagentBlock) { anchor.contentBlocks.push({ type: 'subagent', subagentId, mode: subagent.mode, }); } } } private dedupeMessages(messages: ChatMessage[]): ChatMessage[] { const seen = new Set(); const result: ChatMessage[] = []; for (const message of messages) { // Use message.id as primary key - more reliable than content-based deduplication // especially for tool-only messages or messages with identical content if (seen.has(message.id)) continue; seen.add(message.id); result.push(message); } return result; } /** * Creates a new conversation and sets it as active. * * New conversations always use SDK-native storage. * The session ID may be captured after the first SDK response. */ async createConversation(sessionId?: string): Promise { const conversationId = sessionId ?? this.generateConversationId(); const conversation: Conversation = { id: conversationId, title: this.generateDefaultTitle(), createdAt: Date.now(), updatedAt: Date.now(), sessionId: sessionId ?? null, sdkSessionId: sessionId ?? undefined, messages: [], isNative: true, }; this.conversations.unshift(conversation); // Save new conversation (metadata only - SDK handles messages) await this.storage.sessions.saveMetadata( this.storage.sessions.toSessionMetadata(conversation) ); return conversation; } /** * Switches to an existing conversation by ID. * * For native sessions, loads messages from SDK storage if not already loaded. */ async switchConversation(id: string): Promise { const conversation = this.conversations.find(c => c.id === id); if (!conversation) return null; await this.loadSdkMessagesForConversation(conversation); return conversation; } /** * Deletes a conversation and resets any tabs using it. * * For native sessions, deletes the metadata file and SDK session file. * For legacy sessions, deletes the JSONL file. */ async deleteConversation(id: string): Promise { const index = this.conversations.findIndex(c => c.id === id); if (index === -1) return; const conversation = this.conversations[index]; this.conversations.splice(index, 1); const vaultPath = getVaultPath(this.app); const sdkSessionId = conversation.sdkSessionId ?? conversation.sessionId; if (vaultPath && sdkSessionId) { await deleteSDKSession(vaultPath, sdkSessionId); } if (conversation.isNative) { // Native session: delete metadata file await this.storage.sessions.deleteMetadata(id); } else { // Legacy session: delete JSONL file await this.storage.sessions.deleteConversation(id); } // Notify all views/tabs that have this conversation open for (const view of this.getAllViews()) { const tabManager = view.getTabManager(); if (!tabManager) continue; for (const tab of tabManager.getAllTabs()) { if (tab.conversationId === id) { tab.controllers.inputController?.cancelStreaming(); await tab.controllers.conversationController?.createNew({ force: true }); } } } } /** Renames a conversation. */ async renameConversation(id: string, title: string): Promise { const conversation = this.conversations.find(c => c.id === id); if (!conversation) return; conversation.title = title.trim() || this.generateDefaultTitle(); conversation.updatedAt = Date.now(); if (conversation.isNative) { // Native session: save metadata only await this.storage.sessions.saveMetadata( this.storage.sessions.toSessionMetadata(conversation) ); } else { // Legacy session: save full JSONL await this.storage.sessions.saveConversation(conversation); } } /** * Updates conversation properties. * * For native sessions, saves metadata only (SDK handles messages including images). * For legacy sessions, saves full JSONL. * * Image data is cleared from memory after save (SDK/JSONL has persisted it), * except for pending fork conversations whose images aren't yet in SDK storage. */ async updateConversation(id: string, updates: Partial): Promise { const conversation = this.conversations.find(c => c.id === id); if (!conversation) return; Object.assign(conversation, updates, { updatedAt: Date.now() }); if (conversation.isNative) { // Native session: save metadata only (SDK handles messages including images) await this.storage.sessions.saveMetadata( this.storage.sessions.toSessionMetadata(conversation) ); } else { // Legacy session: save full JSONL await this.storage.sessions.saveConversation(conversation); } // Clear image data from memory after save (data is persisted by SDK or JSONL). // Skip for pending forks: their deep-cloned images aren't in SDK storage yet. if (!this.isPendingFork(conversation)) { for (const msg of conversation.messages) { if (msg.images) { for (const img of msg.images) { img.data = ''; } } } } } /** * Gets a conversation by ID from the in-memory cache. * * For native sessions, loads messages from SDK storage if not already loaded. */ async getConversationById(id: string): Promise { const conversation = this.conversations.find(c => c.id === id) || null; if (conversation) { await this.loadSdkMessagesForConversation(conversation); } return conversation; } /** * Gets a conversation by ID without loading SDK messages. * Use this for UI code that only needs metadata (title, etc.). */ getConversationSync(id: string): Conversation | null { return this.conversations.find(c => c.id === id) || null; } /** Finds an existing empty conversation (no messages). */ findEmptyConversation(): Conversation | null { return this.conversations.find(c => c.messages.length === 0) || null; } /** Returns conversation metadata list for the history dropdown. */ getConversationList(): ConversationMeta[] { return this.conversations.map(c => ({ id: c.id, title: c.title, createdAt: c.createdAt, updatedAt: c.updatedAt, lastResponseAt: c.lastResponseAt, messageCount: c.messages.length, preview: this.getConversationPreview(c), titleGenerationStatus: c.titleGenerationStatus, isNative: c.isNative, })); } /** Returns the active Claudian view from workspace, if open. */ getView(): ClaudianView | null { const leaves = this.app.workspace.getLeavesOfType(VIEW_TYPE_CLAUDIAN); if (leaves.length > 0) { return leaves[0].view as ClaudianView; } return null; } /** Returns all open Claudian views in the workspace. */ getAllViews(): ClaudianView[] { const leaves = this.app.workspace.getLeavesOfType(VIEW_TYPE_CLAUDIAN); return leaves.map(leaf => leaf.view as ClaudianView); } /** * Checks if a conversation is open in any Claudian view. * Returns the view and tab if found, null otherwise. */ findConversationAcrossViews(conversationId: string): { view: ClaudianView; tabId: string } | null { for (const view of this.getAllViews()) { const tabManager = view.getTabManager(); if (!tabManager) continue; const tabs = tabManager.getAllTabs(); for (const tab of tabs) { if (tab.conversationId === conversationId) { return { view, tabId: tab.id }; } } } return null; } /** * Gets SDK supported commands from any ready service. * The command list is the same for all services, so we just need one ready. * Used by inline edit and other contexts that don't have direct TabManager access. */ async getSdkCommands(): Promise { for (const view of this.getAllViews()) { const tabManager = view.getTabManager(); if (tabManager) { const commands = await tabManager.getSdkCommands(); if (commands.length > 0) { return commands; } } } return []; } } ================================================ FILE: src/shared/components/ResumeSessionDropdown.ts ================================================ /** * Claudian - Resume session dropdown * * Dropup UI for selecting a previous conversation to resume. * Shown when the /resume built-in command is executed. */ import { setIcon } from 'obsidian'; import type { ConversationMeta } from '../../core/types'; export interface ResumeSessionDropdownCallbacks { onSelect: (conversationId: string) => void; onDismiss: () => void; } export class ResumeSessionDropdown { private containerEl: HTMLElement; private inputEl: HTMLTextAreaElement; private dropdownEl: HTMLElement; private callbacks: ResumeSessionDropdownCallbacks; private conversations: ConversationMeta[]; private currentConversationId: string | null; private selectedIndex = 0; private onInput: () => void; constructor( containerEl: HTMLElement, inputEl: HTMLTextAreaElement, conversations: ConversationMeta[], currentConversationId: string | null, callbacks: ResumeSessionDropdownCallbacks ) { this.containerEl = containerEl; this.inputEl = inputEl; this.conversations = this.sortConversations(conversations); this.currentConversationId = currentConversationId; this.callbacks = callbacks; this.dropdownEl = this.containerEl.createDiv({ cls: 'claudian-resume-dropdown' }); this.render(); this.dropdownEl.addClass('visible'); // Auto-dismiss when user starts typing this.onInput = () => this.dismiss(); this.inputEl.addEventListener('input', this.onInput); } handleKeydown(e: KeyboardEvent): boolean { if (!this.isVisible()) return false; switch (e.key) { case 'ArrowDown': e.preventDefault(); this.navigate(1); return true; case 'ArrowUp': e.preventDefault(); this.navigate(-1); return true; case 'Enter': case 'Tab': if (this.conversations.length > 0) { e.preventDefault(); this.selectItem(); return true; } return false; case 'Escape': e.preventDefault(); this.dismiss(); return true; } return false; } isVisible(): boolean { return this.dropdownEl?.hasClass('visible') ?? false; } destroy(): void { this.inputEl.removeEventListener('input', this.onInput); this.dropdownEl?.remove(); } private dismiss(): void { this.dropdownEl.removeClass('visible'); this.callbacks.onDismiss(); } private selectItem(): void { if (this.conversations.length === 0) return; const selected = this.conversations[this.selectedIndex]; if (!selected) return; // Dismiss without switching if selecting the current conversation if (selected.id === this.currentConversationId) { this.dismiss(); return; } this.callbacks.onSelect(selected.id); } private navigate(direction: number): void { const maxIndex = this.conversations.length - 1; this.selectedIndex = Math.max(0, Math.min(maxIndex, this.selectedIndex + direction)); this.updateSelection(); } private updateSelection(): void { const items = this.dropdownEl.querySelectorAll('.claudian-resume-item'); items?.forEach((item, index) => { if (index === this.selectedIndex) { item.addClass('selected'); (item as HTMLElement).scrollIntoView({ block: 'nearest' }); } else { item.removeClass('selected'); } }); } private sortConversations(conversations: ConversationMeta[]): ConversationMeta[] { return [...conversations].sort((a, b) => { return (b.lastResponseAt ?? b.createdAt) - (a.lastResponseAt ?? a.createdAt); }); } private render(): void { this.dropdownEl.empty(); const header = this.dropdownEl.createDiv({ cls: 'claudian-resume-header' }); header.createSpan({ text: 'Resume conversation' }); if (this.conversations.length === 0) { this.dropdownEl.createDiv({ cls: 'claudian-resume-empty', text: 'No conversations' }); return; } const list = this.dropdownEl.createDiv({ cls: 'claudian-resume-list' }); for (let i = 0; i < this.conversations.length; i++) { const conv = this.conversations[i]; const isCurrent = conv.id === this.currentConversationId; const item = list.createDiv({ cls: 'claudian-resume-item' }); if (isCurrent) item.addClass('current'); if (i === this.selectedIndex) item.addClass('selected'); const iconEl = item.createDiv({ cls: 'claudian-resume-item-icon' }); setIcon(iconEl, isCurrent ? 'message-square-dot' : 'message-square'); const content = item.createDiv({ cls: 'claudian-resume-item-content' }); const titleEl = content.createDiv({ cls: 'claudian-resume-item-title', text: conv.title }); titleEl.setAttribute('title', conv.title); content.createDiv({ cls: 'claudian-resume-item-date', text: isCurrent ? 'Current session' : this.formatDate(conv.lastResponseAt ?? conv.createdAt), }); item.addEventListener('click', () => { if (isCurrent) { this.dismiss(); return; } this.callbacks.onSelect(conv.id); }); item.addEventListener('mouseenter', () => { this.selectedIndex = i; this.updateSelection(); }); } } private formatDate(timestamp: number): string { const date = new Date(timestamp); const now = new Date(); if (date.toDateString() === now.toDateString()) { return date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', hour12: false }); } return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); } } ================================================ FILE: src/shared/components/SelectableDropdown.ts ================================================ export interface SelectableDropdownOptions { listClassName: string; itemClassName: string; emptyClassName: string; fixed?: boolean; fixedClassName?: string; } export interface SelectableDropdownRenderOptions { items: T[]; selectedIndex: number; emptyText: string; renderItem: (item: T, itemEl: HTMLElement) => void; getItemClass?: (item: T) => string | string[] | undefined; onItemClick?: (item: T, index: number, e: MouseEvent) => void; onItemHover?: (item: T, index: number) => void; } export class SelectableDropdown { private containerEl: HTMLElement; private dropdownEl: HTMLElement | null = null; private options: SelectableDropdownOptions; private items: T[] = []; private itemEls: HTMLElement[] = []; private selectedIndex = 0; constructor(containerEl: HTMLElement, options: SelectableDropdownOptions) { this.containerEl = containerEl; this.options = options; } isVisible(): boolean { return this.dropdownEl?.hasClass('visible') ?? false; } getElement(): HTMLElement | null { return this.dropdownEl; } getSelectedIndex(): number { return this.selectedIndex; } getSelectedItem(): T | null { return this.items[this.selectedIndex] ?? null; } getItems(): T[] { return this.items; } hide(): void { if (this.dropdownEl) { this.dropdownEl.removeClass('visible'); } } destroy(): void { if (this.dropdownEl) { this.dropdownEl.remove(); this.dropdownEl = null; } } render(options: SelectableDropdownRenderOptions): void { this.items = options.items; this.selectedIndex = options.selectedIndex; if (!this.dropdownEl) { this.dropdownEl = this.createDropdownElement(); } this.dropdownEl.empty(); this.itemEls = []; if (options.items.length === 0) { const emptyEl = this.dropdownEl.createDiv({ cls: this.options.emptyClassName }); emptyEl.setText(options.emptyText); } else { for (let i = 0; i < options.items.length; i++) { const item = options.items[i]; const itemEl = this.dropdownEl.createDiv({ cls: this.options.itemClassName }); const extraClass = options.getItemClass?.(item); if (Array.isArray(extraClass)) { extraClass.forEach(cls => itemEl.addClass(cls)); } else if (extraClass) { itemEl.addClass(extraClass); } if (i === this.selectedIndex) { itemEl.addClass('selected'); } options.renderItem(item, itemEl); itemEl.addEventListener('click', (e) => { this.selectedIndex = i; this.updateSelection(); options.onItemClick?.(item, i, e); }); itemEl.addEventListener('mouseenter', () => { this.selectedIndex = i; this.updateSelection(); options.onItemHover?.(item, i); }); this.itemEls.push(itemEl); } } this.dropdownEl.addClass('visible'); } updateSelection(): void { this.itemEls.forEach((itemEl, index) => { if (index === this.selectedIndex) { itemEl.addClass('selected'); itemEl.scrollIntoView({ block: 'nearest' }); } else { itemEl.removeClass('selected'); } }); } moveSelection(delta: number): void { const maxIndex = this.items.length - 1; this.selectedIndex = Math.max(0, Math.min(maxIndex, this.selectedIndex + delta)); this.updateSelection(); } private createDropdownElement(): HTMLElement { const className = this.options.fixed && this.options.fixedClassName ? `${this.options.listClassName} ${this.options.fixedClassName}` : this.options.listClassName; return this.containerEl.createDiv({ cls: className }); } } ================================================ FILE: src/shared/components/SelectionHighlight.ts ================================================ /** * SelectionHighlight - Shared CM6 selection highlight for chat and inline edit * * Provides a reusable mechanism to highlight selected text in the editor * when focus moves elsewhere (e.g., to an input field). */ import { RangeSetBuilder, StateEffect, StateField } from '@codemirror/state'; import type { DecorationSet } from '@codemirror/view'; import { Decoration, EditorView } from '@codemirror/view'; export interface SelectionHighlighter { show: (editorView: EditorView, from: number, to: number) => void; hide: (editorView: EditorView) => void; } function createSelectionHighlighter(): SelectionHighlighter { const showHighlight = StateEffect.define<{ from: number; to: number }>(); const hideHighlight = StateEffect.define(); const selectionHighlightField = StateField.define({ create: () => Decoration.none, update: (deco, tr) => { for (const e of tr.effects) { if (e.is(showHighlight)) { const builder = new RangeSetBuilder(); builder.add(e.value.from, e.value.to, Decoration.mark({ class: 'claudian-selection-highlight', })); return builder.finish(); } else if (e.is(hideHighlight)) { return Decoration.none; } } return deco.map(tr.changes); }, provide: (f) => EditorView.decorations.from(f), }); const installedEditors = new WeakSet(); function ensureHighlightField(editorView: EditorView): void { if (!installedEditors.has(editorView)) { editorView.dispatch({ effects: StateEffect.appendConfig.of(selectionHighlightField), }); installedEditors.add(editorView); } } function show(editorView: EditorView, from: number, to: number): void { ensureHighlightField(editorView); editorView.dispatch({ effects: showHighlight.of({ from, to }), }); } function hide(editorView: EditorView): void { if (installedEditors.has(editorView)) { editorView.dispatch({ effects: hideHighlight.of(null), }); } } return { show, hide }; } const defaultHighlighter = createSelectionHighlighter(); export function showSelectionHighlight(editorView: EditorView, from: number, to: number): void { defaultHighlighter.show(editorView, from, to); } export function hideSelectionHighlight(editorView: EditorView): void { defaultHighlighter.hide(editorView); } ================================================ FILE: src/shared/components/SlashCommandDropdown.ts ================================================ /** * Claudian - Slash command dropdown * * Dropdown UI for selecting slash commands when typing /. * Follows the FileContext.ts pattern for input detection and keyboard navigation. */ import { getBuiltInCommandsForDropdown } from '../../core/commands'; import type { SlashCommand } from '../../core/types'; import { normalizeArgumentHint } from '../../utils/slashCommand'; /** * SDK commands to filter out from the dropdown. * These are either handled differently in Claudian or don't apply. */ const FILTERED_SDK_COMMANDS = new Set([ 'context', 'cost', 'init', 'keybindings-help', 'release-notes', 'security-review', ]); export interface SlashCommandDropdownCallbacks { onSelect: (command: SlashCommand) => void; onHide: () => void; /** * Callback to fetch SDK supported commands. * SDK is the single source of truth for slash commands. * Only available after the service is initialized (first message sent). */ getSdkCommands?: () => Promise; } export interface SlashCommandDropdownOptions { fixed?: boolean; hiddenCommands?: Set; } export class SlashCommandDropdown { private containerEl: HTMLElement; private dropdownEl: HTMLElement | null = null; private inputEl: HTMLTextAreaElement | HTMLInputElement; private callbacks: SlashCommandDropdownCallbacks; private enabled = true; private onInput: () => void; private slashStartIndex = -1; private selectedIndex = 0; private filteredCommands: SlashCommand[] = []; private isFixed: boolean; private hiddenCommands: Set; // SDK skills cache private cachedSdkSkills: SlashCommand[] = []; private sdkSkillsFetched = false; // Race condition guard for async dropdown rendering private requestId = 0; constructor( containerEl: HTMLElement, inputEl: HTMLTextAreaElement | HTMLInputElement, callbacks: SlashCommandDropdownCallbacks, options: SlashCommandDropdownOptions = {} ) { this.containerEl = containerEl; this.inputEl = inputEl; this.callbacks = callbacks; this.isFixed = options.fixed ?? false; this.hiddenCommands = options.hiddenCommands ?? new Set(); this.onInput = () => this.handleInputChange(); this.inputEl.addEventListener('input', this.onInput); } setEnabled(enabled: boolean): void { this.enabled = enabled; if (!enabled) { this.hide(); } } setHiddenCommands(commands: Set): void { this.hiddenCommands = commands; } handleInputChange(): void { if (!this.enabled) return; const text = this.getInputValue(); const cursorPos = this.getCursorPosition(); const textBeforeCursor = text.substring(0, cursorPos); // Only show dropdown if / is at position 0 if (text.charAt(0) !== '/') { this.hide(); return; } const slashIndex = 0; const searchText = textBeforeCursor.substring(slashIndex + 1); // Hide if there's whitespace in the search text (command already selected) if (/\s/.test(searchText)) { this.hide(); return; } this.slashStartIndex = slashIndex; this.showDropdown(searchText); } handleKeydown(e: KeyboardEvent): boolean { if (!this.enabled || !this.isVisible()) return false; switch (e.key) { case 'ArrowDown': e.preventDefault(); this.navigate(1); return true; case 'ArrowUp': e.preventDefault(); this.navigate(-1); return true; case 'Enter': case 'Tab': if (this.filteredCommands.length > 0) { e.preventDefault(); this.selectItem(); return true; } return false; case 'Escape': e.preventDefault(); this.hide(); return true; } return false; } isVisible(): boolean { return this.dropdownEl?.hasClass('visible') ?? false; } hide(): void { if (this.dropdownEl) { this.dropdownEl.removeClass('visible'); } this.slashStartIndex = -1; this.callbacks.onHide(); } destroy(): void { this.inputEl.removeEventListener('input', this.onInput); if (this.dropdownEl) { this.dropdownEl.remove(); this.dropdownEl = null; } } /** * Resets the SDK skills cache. * Call this when switching conversations or creating a new chat. */ resetSdkSkillsCache(): void { this.cachedSdkSkills = []; this.sdkSkillsFetched = false; this.requestId = 0; } private getInputValue(): string { return this.inputEl.value; } private getCursorPosition(): number { return this.inputEl.selectionStart || 0; } private setInputValue(value: string): void { this.inputEl.value = value; } private setCursorPosition(pos: number): void { this.inputEl.selectionStart = pos; this.inputEl.selectionEnd = pos; } private async showDropdown(searchText: string): Promise { const currentRequest = ++this.requestId; const builtInCommands = getBuiltInCommandsForDropdown(); const searchLower = searchText.toLowerCase(); // Fetch SDK commands if not cached and callback is available // SDK is the single source of truth for slash commands // Only mark as fetched when we get non-empty results (service is ready) // This allows retries when service isn't ready yet or on transient errors if (!this.sdkSkillsFetched && this.callbacks.getSdkCommands) { try { const sdkCommands = await this.callbacks.getSdkCommands(); // Discard results if a newer request was made during await if (currentRequest !== this.requestId) return; if (sdkCommands.length > 0) { this.cachedSdkSkills = sdkCommands; this.sdkSkillsFetched = true; } // Keep sdkSkillsFetched false to allow retry on empty results } catch { // Keep sdkSkillsFetched false to allow retry on error if (currentRequest !== this.requestId) return; } } const allCommands = this.buildCommandList(builtInCommands); this.filteredCommands = allCommands .filter(cmd => cmd.name.toLowerCase().includes(searchLower) || cmd.description?.toLowerCase().includes(searchLower) ) .sort((a, b) => a.name.localeCompare(b.name)); // Final race condition check before rendering if (currentRequest !== this.requestId) return; if (searchText.length > 0 && this.filteredCommands.length === 0) { this.hide(); return; } this.selectedIndex = 0; this.render(); } /** * Builds the merged command list from built-in and SDK commands. * Built-in commands have highest priority and are not subject to hiding. * SDK commands are deduplicated, filtered, and respect user hiding. */ private buildCommandList(builtInCommands: SlashCommand[]): SlashCommand[] { const seenNames = new Set(); const allCommands: SlashCommand[] = []; // Add Claudian built-in commands first (highest priority) // Built-in commands are not subject to user hiding (they are essential UI actions) for (const cmd of builtInCommands) { const nameLower = cmd.name.toLowerCase(); if (!seenNames.has(nameLower)) { seenNames.add(nameLower); allCommands.push(cmd); } } for (const cmd of this.cachedSdkSkills) { const nameLower = cmd.name.toLowerCase(); if ( FILTERED_SDK_COMMANDS.has(nameLower) || seenNames.has(nameLower) || this.hiddenCommands.has(nameLower) ) { continue; } seenNames.add(nameLower); allCommands.push(cmd); } return allCommands; } private render(): void { if (!this.dropdownEl) { this.dropdownEl = this.createDropdownElement(); } this.dropdownEl.empty(); if (this.filteredCommands.length === 0) { const emptyEl = this.dropdownEl.createDiv({ cls: 'claudian-slash-empty' }); emptyEl.setText('No matching commands'); } else { for (let i = 0; i < this.filteredCommands.length; i++) { const cmd = this.filteredCommands[i]; const itemEl = this.dropdownEl.createDiv({ cls: 'claudian-slash-item' }); if (i === this.selectedIndex) { itemEl.addClass('selected'); } const nameEl = itemEl.createSpan({ cls: 'claudian-slash-name' }); nameEl.setText(`/${cmd.name}`); if (cmd.argumentHint) { const hintEl = itemEl.createSpan({ cls: 'claudian-slash-hint' }); hintEl.setText(normalizeArgumentHint(cmd.argumentHint)); } if (cmd.description) { const descEl = itemEl.createDiv({ cls: 'claudian-slash-desc' }); descEl.setText(cmd.description); } itemEl.addEventListener('click', () => { this.selectedIndex = i; this.selectItem(); }); itemEl.addEventListener('mouseenter', () => { this.selectedIndex = i; this.updateSelection(); }); } } this.dropdownEl.addClass('visible'); // Position for fixed mode (inline editor) if (this.isFixed) { this.positionFixed(); } } private createDropdownElement(): HTMLElement { if (this.isFixed) { // For inline editor: append to containerEl with fixed positioning const dropdown = this.containerEl.createDiv({ cls: 'claudian-slash-dropdown claudian-slash-dropdown-fixed', }); return dropdown; } else { // For chat panel: append to container with absolute positioning return this.containerEl.createDiv({ cls: 'claudian-slash-dropdown' }); } } private positionFixed(): void { if (!this.dropdownEl || !this.isFixed) return; const inputRect = this.inputEl.getBoundingClientRect(); this.dropdownEl.style.position = 'fixed'; this.dropdownEl.style.bottom = `${window.innerHeight - inputRect.top + 4}px`; this.dropdownEl.style.left = `${inputRect.left}px`; this.dropdownEl.style.right = 'auto'; this.dropdownEl.style.width = `${Math.max(inputRect.width, 280)}px`; this.dropdownEl.style.zIndex = '10001'; // Above CM6 widgets } private navigate(direction: number): void { const maxIndex = this.filteredCommands.length - 1; this.selectedIndex = Math.max(0, Math.min(maxIndex, this.selectedIndex + direction)); this.updateSelection(); } private updateSelection(): void { const items = this.dropdownEl?.querySelectorAll('.claudian-slash-item'); items?.forEach((item, index) => { if (index === this.selectedIndex) { item.addClass('selected'); (item as HTMLElement).scrollIntoView({ block: 'nearest' }); } else { item.removeClass('selected'); } }); } private selectItem(): void { if (this.filteredCommands.length === 0) return; const selected = this.filteredCommands[this.selectedIndex]; if (!selected) return; const text = this.getInputValue(); const beforeSlash = text.substring(0, this.slashStartIndex); const afterCursor = text.substring(this.getCursorPosition()); const replacement = `/${selected.name} `; this.setInputValue(beforeSlash + replacement + afterCursor); this.setCursorPosition(beforeSlash.length + replacement.length); this.hide(); this.callbacks.onSelect(selected); this.inputEl.focus(); } } ================================================ FILE: src/shared/icons.ts ================================================ export const MCP_ICON_SVG = `MCP`; export const CHECK_ICON_SVG = ``; ================================================ FILE: src/shared/index.ts ================================================ export { SelectableDropdown, type SelectableDropdownOptions, type SelectableDropdownRenderOptions, } from './components/SelectableDropdown'; export { hideSelectionHighlight, type SelectionHighlighter, showSelectionHighlight, } from './components/SelectionHighlight'; export { SlashCommandDropdown, type SlashCommandDropdownCallbacks, type SlashCommandDropdownOptions, } from './components/SlashCommandDropdown'; export { CHECK_ICON_SVG, MCP_ICON_SVG } from './icons'; export { type McpMentionProvider, type MentionDropdownCallbacks, MentionDropdownController, type MentionDropdownOptions, } from './mention/MentionDropdownController'; export { type InstructionDecision, InstructionModal, type InstructionModalCallbacks, } from './modals/InstructionConfirmModal'; ================================================ FILE: src/shared/mention/MentionDropdownController.ts ================================================ import type { TFile } from 'obsidian'; import { setIcon } from 'obsidian'; import { buildExternalContextDisplayEntries } from '../../utils/externalContext'; import { type ExternalContextFile, externalContextScanner } from '../../utils/externalContextScanner'; import { extractMcpMentions } from '../../utils/mcp'; import { SelectableDropdown } from '../components/SelectableDropdown'; import { MCP_ICON_SVG } from '../icons'; import { type AgentMentionProvider, type FolderMentionItem, type MentionItem, } from './types'; export type { AgentMentionProvider }; export interface MentionDropdownOptions { fixed?: boolean; } export interface MentionDropdownCallbacks { onAttachFile: (path: string) => void; onMcpMentionChange?: (servers: Set) => void; onAgentMentionSelect?: (agentId: string) => void; getMentionedMcpServers: () => Set; setMentionedMcpServers: (mentions: Set) => boolean; addMentionedMcpServer: (name: string) => void; getExternalContexts: () => string[]; getCachedVaultFolders: () => Array>; getCachedVaultFiles: () => TFile[]; normalizePathForVault: (path: string | undefined | null) => string | null; } export interface McpMentionProvider { getContextSavingServers: () => Array<{ name: string }>; } export class MentionDropdownController { private containerEl: HTMLElement; private inputEl: HTMLTextAreaElement | HTMLInputElement; private callbacks: MentionDropdownCallbacks; private dropdown: SelectableDropdown; private mentionStartIndex = -1; private selectedMentionIndex = 0; private filteredMentionItems: MentionItem[] = []; private filteredContextFiles: ExternalContextFile[] = []; private activeContextFilter: { folderName: string; contextRoot: string } | null = null; private activeAgentFilter = false; private mcpManager: McpMentionProvider | null = null; private agentService: AgentMentionProvider | null = null; private fixed: boolean; private debounceTimer: ReturnType | null = null; constructor( containerEl: HTMLElement, inputEl: HTMLTextAreaElement | HTMLInputElement, callbacks: MentionDropdownCallbacks, options: MentionDropdownOptions = {} ) { this.containerEl = containerEl; this.inputEl = inputEl; this.callbacks = callbacks; this.fixed = options.fixed ?? false; this.dropdown = new SelectableDropdown(this.containerEl, { listClassName: 'claudian-mention-dropdown', itemClassName: 'claudian-mention-item', emptyClassName: 'claudian-mention-empty', fixed: this.fixed, fixedClassName: 'claudian-mention-dropdown-fixed', }); } setMcpManager(manager: McpMentionProvider | null): void { this.mcpManager = manager; } setAgentService(service: AgentMentionProvider | null): void { this.agentService = service; } preScanExternalContexts(): void { const externalContexts = this.callbacks.getExternalContexts() || []; if (externalContexts.length === 0) return; setTimeout(() => { try { externalContextScanner.scanPaths(externalContexts); } catch { // Pre-scan is best-effort, ignore failures } }, 0); } isVisible(): boolean { return this.dropdown.isVisible(); } hide(): void { this.dropdown.hide(); this.mentionStartIndex = -1; } containsElement(el: Node): boolean { return this.dropdown.getElement()?.contains(el) ?? false; } destroy(): void { if (this.debounceTimer !== null) { clearTimeout(this.debounceTimer); } this.dropdown.destroy(); } updateMcpMentionsFromText(text: string): void { if (!this.mcpManager) return; const validNames = new Set( this.mcpManager.getContextSavingServers().map(s => s.name) ); const newMentions = extractMcpMentions(text, validNames); const changed = this.callbacks.setMentionedMcpServers(newMentions); if (changed) { this.callbacks.onMcpMentionChange?.(newMentions); } } handleInputChange(): void { if (this.debounceTimer !== null) { clearTimeout(this.debounceTimer); } this.debounceTimer = setTimeout(() => { const text = this.inputEl.value; this.updateMcpMentionsFromText(text); const cursorPos = this.inputEl.selectionStart || 0; const textBeforeCursor = text.substring(0, cursorPos); const lastAtIndex = textBeforeCursor.lastIndexOf('@'); if (lastAtIndex === -1) { this.hide(); return; } const charBeforeAt = lastAtIndex > 0 ? textBeforeCursor[lastAtIndex - 1] : ' '; if (!/\s/.test(charBeforeAt) && lastAtIndex !== 0) { this.hide(); return; } const searchText = textBeforeCursor.substring(lastAtIndex + 1); if (/\s/.test(searchText)) { this.hide(); return; } this.mentionStartIndex = lastAtIndex; this.showMentionDropdown(searchText); }, 200); } handleKeydown(e: KeyboardEvent): boolean { if (!this.dropdown.isVisible()) return false; if (e.key === 'ArrowDown') { e.preventDefault(); this.dropdown.moveSelection(1); this.selectedMentionIndex = this.dropdown.getSelectedIndex(); return true; } if (e.key === 'ArrowUp') { e.preventDefault(); this.dropdown.moveSelection(-1); this.selectedMentionIndex = this.dropdown.getSelectedIndex(); return true; } // Check !e.isComposing for IME support (Chinese, Japanese, Korean, etc.) if ((e.key === 'Enter' || e.key === 'Tab') && !e.isComposing) { e.preventDefault(); this.selectMentionItem(); return true; } if (e.key === 'Escape' && !e.isComposing) { e.preventDefault(); // If in secondary menu, return to first level instead of closing if (this.activeContextFilter || this.activeAgentFilter) { this.returnToFirstLevel(); return true; } this.hide(); return true; } return false; } private showMentionDropdown(searchText: string): void { const searchLower = searchText.toLowerCase(); this.filteredMentionItems = []; this.filteredContextFiles = []; const externalContexts = this.callbacks.getExternalContexts() || []; const contextEntries = buildExternalContextDisplayEntries(externalContexts); const isFilterSearch = searchText.includes('/'); let fileSearchText = searchLower; if (isFilterSearch && searchLower.startsWith('agents/')) { this.activeAgentFilter = true; this.activeContextFilter = null; const agentSearchText = searchText.substring('agents/'.length).toLowerCase(); if (this.agentService) { const matchingAgents = this.agentService.searchAgents(agentSearchText); for (const agent of matchingAgents) { this.filteredMentionItems.push({ type: 'agent', id: agent.id, name: agent.name, description: agent.description, source: agent.source, }); } } this.selectedMentionIndex = 0; this.renderMentionDropdown(); return; } if (isFilterSearch) { const matchingContext = contextEntries .filter(entry => searchLower.startsWith(`${entry.displayNameLower}/`)) .sort((a, b) => b.displayNameLower.length - a.displayNameLower.length)[0]; if (matchingContext) { const prefixLength = matchingContext.displayName.length + 1; fileSearchText = searchText.substring(prefixLength).toLowerCase(); this.activeContextFilter = { folderName: matchingContext.displayName, contextRoot: matchingContext.contextRoot, }; } else { this.activeContextFilter = null; } } if (this.activeContextFilter && isFilterSearch) { const contextFiles = externalContextScanner.scanPaths([this.activeContextFilter.contextRoot]); this.filteredContextFiles = contextFiles .filter(file => { const relativePath = file.relativePath.replace(/\\/g, '/'); const pathLower = relativePath.toLowerCase(); const nameLower = file.name.toLowerCase(); return pathLower.includes(fileSearchText) || nameLower.includes(fileSearchText); }) .sort((a, b) => { const aNameMatch = a.name.toLowerCase().startsWith(fileSearchText); const bNameMatch = b.name.toLowerCase().startsWith(fileSearchText); if (aNameMatch && !bNameMatch) return -1; if (!aNameMatch && bNameMatch) return 1; return b.mtime - a.mtime; }); for (const file of this.filteredContextFiles) { const relativePath = file.relativePath.replace(/\\/g, '/'); this.filteredMentionItems.push({ type: 'context-file', name: relativePath, absolutePath: file.path, contextRoot: file.contextRoot, folderName: this.activeContextFilter.folderName, }); } const firstVaultItemIndex = this.filteredMentionItems.length; const vaultItemCount = this.appendVaultItems(searchLower); if (this.filteredContextFiles.length === 0 && vaultItemCount > 0) { this.selectedMentionIndex = firstVaultItemIndex; } else { this.selectedMentionIndex = 0; } this.renderMentionDropdown(); return; } this.activeContextFilter = null; this.activeAgentFilter = false; if (this.mcpManager) { const mcpServers = this.mcpManager.getContextSavingServers(); for (const server of mcpServers) { if (server.name.toLowerCase().includes(searchLower)) { this.filteredMentionItems.push({ type: 'mcp-server', name: server.name, }); } } } if (this.agentService) { const hasAgents = this.agentService.searchAgents('').length > 0; if (hasAgents && 'agents'.includes(searchLower)) { this.filteredMentionItems.push({ type: 'agent-folder', name: 'Agents', }); } } if (contextEntries.length > 0) { const matchingFolders = new Set(); for (const entry of contextEntries) { if (entry.displayNameLower.includes(searchLower) && !matchingFolders.has(entry.displayName)) { matchingFolders.add(entry.displayName); this.filteredMentionItems.push({ type: 'context-folder', name: entry.displayName, contextRoot: entry.contextRoot, folderName: entry.displayName, }); } } } const firstVaultItemIndex = this.filteredMentionItems.length; const vaultItemCount = this.appendVaultItems(searchLower); this.selectedMentionIndex = vaultItemCount > 0 ? firstVaultItemIndex : 0; this.renderMentionDropdown(); } private appendVaultItems(searchLower: string): number { type ScoredItem = | { type: 'folder'; name: string; path: string; startsWithQuery: boolean; mtime: number } | { type: 'file'; name: string; path: string; file: TFile; startsWithQuery: boolean; mtime: number }; const compare = (a: ScoredItem, b: ScoredItem): number => { if (a.startsWithQuery !== b.startsWithQuery) return a.startsWithQuery ? -1 : 1; if (a.mtime !== b.mtime) return b.mtime - a.mtime; if (a.type !== b.type) return a.type === 'file' ? -1 : 1; return a.path.localeCompare(b.path); }; const allFiles = this.callbacks.getCachedVaultFiles(); // Derive folder mtime from the most recently modified file within each folder const folderMtimeMap = new Map(); for (const f of allFiles) { const parts = f.path.split('/'); for (let i = 1; i < parts.length; i++) { const folderPath = parts.slice(0, i).join('/'); const existing = folderMtimeMap.get(folderPath) ?? 0; if (f.stat.mtime > existing) { folderMtimeMap.set(folderPath, f.stat.mtime); } } } const scoredFolders: ScoredItem[] = this.callbacks.getCachedVaultFolders() .map(f => ({ name: f.name, path: f.path.replace(/\\/g, '/').replace(/\/+$/, ''), })) .filter(f => f.path.length > 0 && (f.path.toLowerCase().includes(searchLower) || f.name.toLowerCase().includes(searchLower)) ) .map(f => ({ type: 'folder' as const, name: f.name, path: f.path, startsWithQuery: f.name.toLowerCase().startsWith(searchLower), mtime: folderMtimeMap.get(f.path) ?? 0, })) .sort(compare) .slice(0, 50); const scoredFiles: ScoredItem[] = allFiles .filter(f => f.path.toLowerCase().includes(searchLower) || f.name.toLowerCase().includes(searchLower) ) .map(f => ({ type: 'file' as const, name: f.name, path: f.path, file: f, startsWithQuery: f.name.toLowerCase().startsWith(searchLower), mtime: f.stat.mtime, })) .sort(compare) .slice(0, 100); const merged = [...scoredFolders, ...scoredFiles].sort(compare); for (const item of merged) { if (item.type === 'folder') { this.filteredMentionItems.push({ type: 'folder', name: item.name, path: item.path }); } else { this.filteredMentionItems.push({ type: 'file', name: item.name, path: item.path, file: item.file }); } } return merged.length; } private renderMentionDropdown(): void { this.dropdown.render({ items: this.filteredMentionItems, selectedIndex: this.selectedMentionIndex, emptyText: 'No matches', getItemClass: (item) => { switch (item.type) { case 'mcp-server': return 'mcp-server'; case 'folder': return 'vault-folder'; case 'agent': return 'agent'; case 'agent-folder': return 'agent-folder'; case 'context-file': return 'context-file'; case 'context-folder': return 'context-folder'; default: return undefined; } }, renderItem: (item, itemEl) => { const iconEl = itemEl.createSpan({ cls: 'claudian-mention-icon' }); switch (item.type) { case 'mcp-server': iconEl.innerHTML = MCP_ICON_SVG; break; case 'agent': case 'agent-folder': setIcon(iconEl, 'bot'); break; case 'context-file': setIcon(iconEl, 'folder-open'); break; case 'folder': case 'context-folder': setIcon(iconEl, 'folder'); break; default: setIcon(iconEl, 'file-text'); } const textEl = itemEl.createSpan({ cls: 'claudian-mention-text' }); switch (item.type) { case 'mcp-server': textEl.createSpan({ cls: 'claudian-mention-name' }).setText(`@${item.name}`); break; case 'agent-folder': textEl.createSpan({ cls: 'claudian-mention-name claudian-mention-name-agent-folder', }).setText(`@${item.name}/`); break; case 'agent': { // Show ID (which is namespaced for plugin agents) for consistency with inserted text textEl.createSpan({ cls: 'claudian-mention-name claudian-mention-name-agent', }).setText(`@${item.id}`); if (item.description) { textEl.createSpan({ cls: 'claudian-mention-agent-desc' }).setText(item.description); } break; } case 'context-folder': textEl.createSpan({ cls: 'claudian-mention-name claudian-mention-name-folder', }).setText(`@${item.name}/`); break; case 'context-file': textEl.createSpan({ cls: 'claudian-mention-name claudian-mention-name-context', }).setText(item.name); break; case 'folder': textEl.createSpan({ cls: 'claudian-mention-name claudian-mention-name-folder', }).setText(`@${item.path}/`); break; default: textEl.createSpan({ cls: 'claudian-mention-path' }).setText(item.path || item.name); } }, onItemClick: (item, index, e) => { // Stop propagation for folder items to prevent document click handler // from hiding dropdown (since dropdown is re-rendered with new DOM) if (item.type === 'context-folder' || item.type === 'agent-folder') { e.stopPropagation(); } this.selectedMentionIndex = index; this.selectMentionItem(); }, onItemHover: (_item, index) => { this.selectedMentionIndex = index; }, }); if (this.fixed) { this.positionFixed(); } } private positionFixed(): void { const dropdownEl = this.dropdown.getElement(); if (!dropdownEl) return; const inputRect = this.inputEl.getBoundingClientRect(); dropdownEl.style.position = 'fixed'; dropdownEl.style.bottom = `${window.innerHeight - inputRect.top + 4}px`; dropdownEl.style.left = `${inputRect.left}px`; dropdownEl.style.right = 'auto'; dropdownEl.style.width = `${Math.max(inputRect.width, 280)}px`; dropdownEl.style.zIndex = '10001'; } private insertReplacement(beforeAt: string, replacement: string, afterCursor: string): void { this.inputEl.value = beforeAt + replacement + afterCursor; this.inputEl.selectionStart = this.inputEl.selectionEnd = beforeAt.length + replacement.length; } private returnToFirstLevel(): void { const text = this.inputEl.value; const beforeAt = text.substring(0, this.mentionStartIndex); const cursorPos = this.inputEl.selectionStart || 0; const afterCursor = text.substring(cursorPos); this.inputEl.value = beforeAt + '@' + afterCursor; this.inputEl.selectionStart = this.inputEl.selectionEnd = beforeAt.length + 1; this.activeContextFilter = null; this.activeAgentFilter = false; this.showMentionDropdown(''); } private selectMentionItem(): void { if (this.filteredMentionItems.length === 0) return; const selectedIndex = this.dropdown.getSelectedIndex(); this.selectedMentionIndex = selectedIndex; const selectedItem = this.filteredMentionItems[selectedIndex]; if (!selectedItem) return; const text = this.inputEl.value; const beforeAt = text.substring(0, this.mentionStartIndex); const cursorPos = this.inputEl.selectionStart || 0; const afterCursor = text.substring(cursorPos); switch (selectedItem.type) { case 'mcp-server': { const replacement = `@${selectedItem.name} `; this.insertReplacement(beforeAt, replacement, afterCursor); this.callbacks.addMentionedMcpServer(selectedItem.name); this.callbacks.onMcpMentionChange?.(this.callbacks.getMentionedMcpServers()); break; } case 'agent-folder': // Don't modify input text - just show agents submenu this.activeAgentFilter = true; this.inputEl.focus(); this.showMentionDropdown('Agents/'); return; case 'agent': { const replacement = `@${selectedItem.id} (agent) `; this.insertReplacement(beforeAt, replacement, afterCursor); this.callbacks.onAgentMentionSelect?.(selectedItem.id); break; } case 'context-folder': { const replacement = `@${selectedItem.name}/`; this.insertReplacement(beforeAt, replacement, afterCursor); this.inputEl.focus(); this.handleInputChange(); return; } case 'context-file': { // Display friendly name in input; absolute path resolution happens at send time. const displayName = selectedItem.folderName ? `@${selectedItem.folderName}/${selectedItem.name}` : `@${selectedItem.name}`; if (selectedItem.absolutePath) { this.callbacks.onAttachFile(selectedItem.absolutePath); } this.insertReplacement(beforeAt, `${displayName} `, afterCursor); break; } case 'folder': { const normalizedPath = this.callbacks.normalizePathForVault(selectedItem.path); this.insertReplacement(beforeAt, `@${normalizedPath ?? selectedItem.path}/ `, afterCursor); break; } default: { const rawPath = selectedItem.file?.path ?? selectedItem.path; const normalizedPath = this.callbacks.normalizePathForVault(rawPath); if (normalizedPath) { this.callbacks.onAttachFile(normalizedPath); } this.insertReplacement(beforeAt, `@${normalizedPath ?? selectedItem.name} `, afterCursor); break; } } this.hide(); this.inputEl.focus(); } } ================================================ FILE: src/shared/mention/VaultMentionCache.ts ================================================ import type { App, TFile } from 'obsidian'; import { TFolder } from 'obsidian'; export interface VaultFileCacheOptions { onLoadError?: (error: unknown) => void; } export class VaultFileCache { private cachedFiles: TFile[] = []; private dirty = true; private isInitialized = false; constructor( private app: App, private options: VaultFileCacheOptions = {} ) {} initializeInBackground(): void { if (this.isInitialized) return; setTimeout(() => { this.tryRefreshFiles(); }, 0); } markDirty(): void { this.dirty = true; } getFiles(): TFile[] { if (this.dirty || !this.isInitialized) { this.tryRefreshFiles(); } return this.cachedFiles; } private tryRefreshFiles(): void { try { this.cachedFiles = this.app.vault.getFiles(); this.dirty = false; } catch (error) { this.options.onLoadError?.(error); // Keep stale cache on failure. If data exists, avoid retrying each call. if (this.cachedFiles.length > 0) { this.dirty = false; } } finally { this.isInitialized = true; } } } function isVisibleFolder(folder: TFolder): boolean { const normalizedPath = folder.path .replace(/\\/g, '/') .replace(/\/+$/, ''); if (!normalizedPath) return false; return !normalizedPath.split('/').some(segment => segment.startsWith('.')); } export class VaultFolderCache { private cachedFolders: TFolder[] = []; private dirty = true; private isInitialized = false; constructor(private app: App) {} initializeInBackground(): void { if (this.isInitialized) return; setTimeout(() => { this.tryRefreshFolders(); }, 0); } markDirty(): void { this.dirty = true; } getFolders(): TFolder[] { if (this.dirty || !this.isInitialized) { this.tryRefreshFolders(); } return this.cachedFolders; } private tryRefreshFolders(): void { try { this.cachedFolders = this.loadFolders(); this.dirty = false; } catch { // Keep stale cache on failure. If data exists, avoid retrying each call. if (this.cachedFolders.length > 0) { this.dirty = false; } } finally { this.isInitialized = true; } } private loadFolders(): TFolder[] { return this.app.vault .getAllLoadedFiles() .filter((file): file is TFolder => file instanceof TFolder && isVisibleFolder(file)); } } ================================================ FILE: src/shared/mention/VaultMentionDataProvider.ts ================================================ import type { App, TFile } from 'obsidian'; import { VaultFileCache, VaultFolderCache } from './VaultMentionCache'; export interface VaultMentionDataProviderOptions { onFileLoadError?: () => void; } export class VaultMentionDataProvider { private fileCache: VaultFileCache; private folderCache: VaultFolderCache; private hasReportedFileLoadError = false; constructor( app: App, options: VaultMentionDataProviderOptions = {} ) { this.fileCache = new VaultFileCache(app, { onLoadError: () => { if (this.hasReportedFileLoadError) return; this.hasReportedFileLoadError = true; options.onFileLoadError?.(); }, }); this.folderCache = new VaultFolderCache(app); } initializeInBackground(): void { this.fileCache.initializeInBackground(); this.folderCache.initializeInBackground(); } markFilesDirty(): void { this.fileCache.markDirty(); } markFoldersDirty(): void { this.folderCache.markDirty(); } getCachedVaultFiles(): TFile[] { return this.fileCache.getFiles(); } getCachedVaultFolders(): Array<{ name: string; path: string }> { return this.folderCache.getFolders().map(folder => ({ name: folder.name, path: folder.path, })); } } ================================================ FILE: src/shared/mention/types.ts ================================================ import type { TFile } from 'obsidian'; export interface FileMentionItem { type: 'file'; name: string; path: string; file: TFile; } export interface FolderMentionItem { type: 'folder'; name: string; path: string; } export interface McpServerMentionItem { type: 'mcp-server'; name: string; } export interface ContextFileMentionItem { type: 'context-file'; name: string; absolutePath: string; contextRoot: string; folderName: string; } export interface ContextFolderMentionItem { type: 'context-folder'; name: string; contextRoot: string; folderName: string; } export interface AgentMentionItem { type: 'agent'; /** Display name */ name: string; /** Full ID (namespaced for plugins) */ id: string; /** Brief description */ description?: string; /** Source of the agent */ source: 'plugin' | 'vault' | 'global' | 'builtin'; } export interface AgentFolderMentionItem { type: 'agent-folder'; name: string; } export interface AgentMentionProvider { searchAgents: (query: string) => Array<{ id: string; name: string; description?: string; source: 'plugin' | 'vault' | 'global' | 'builtin'; }>; } export type MentionItem = | FileMentionItem | FolderMentionItem | McpServerMentionItem | ContextFileMentionItem | ContextFolderMentionItem | AgentMentionItem | AgentFolderMentionItem; ================================================ FILE: src/shared/modals/ConfirmModal.ts ================================================ import { type App,Modal, Setting } from 'obsidian'; import { t } from '../../i18n'; export function confirmDelete(app: App, message: string): Promise { return new Promise(resolve => { new ConfirmModal(app, message, resolve).open(); }); } export function confirm(app: App, message: string, confirmText: string): Promise { return new Promise(resolve => { new ConfirmModal(app, message, resolve, confirmText).open(); }); } class ConfirmModal extends Modal { private message: string; private resolve: (confirmed: boolean) => void; private resolved = false; private confirmText: string; constructor(app: App, message: string, resolve: (confirmed: boolean) => void, confirmText?: string) { super(app); this.message = message; this.resolve = resolve; this.confirmText = confirmText ?? t('common.delete'); } onOpen() { this.setTitle(t('common.confirm')); this.modalEl.addClass('claudian-confirm-modal'); this.contentEl.createEl('p', { text: this.message }); new Setting(this.contentEl) .addButton(btn => btn .setButtonText(t('common.cancel')) .onClick(() => this.close()) ) .addButton(btn => btn .setButtonText(this.confirmText) .setWarning() .onClick(() => { this.resolved = true; this.resolve(true); this.close(); }) ); } onClose() { if (!this.resolved) { this.resolve(false); } this.contentEl.empty(); } } ================================================ FILE: src/shared/modals/ForkTargetModal.ts ================================================ import { type App, Modal } from 'obsidian'; import { t } from '../../i18n'; export type ForkTarget = 'new-tab' | 'current-tab'; export function chooseForkTarget(app: App): Promise { return new Promise(resolve => { new ForkTargetModal(app, resolve).open(); }); } class ForkTargetModal extends Modal { private resolve: (target: ForkTarget | null) => void; private resolved = false; constructor(app: App, resolve: (target: ForkTarget | null) => void) { super(app); this.resolve = resolve; } onOpen() { this.setTitle(t('chat.fork.chooseTarget')); this.modalEl.addClass('claudian-fork-target-modal'); const list = this.contentEl.createDiv({ cls: 'claudian-fork-target-list' }); this.createOption(list, 'current-tab', t('chat.fork.targetCurrentTab')); this.createOption(list, 'new-tab', t('chat.fork.targetNewTab')); } private createOption(container: HTMLElement, target: ForkTarget, label: string): void { const item = container.createDiv({ cls: 'claudian-fork-target-option', text: label }); item.addEventListener('click', () => { this.resolved = true; this.resolve(target); this.close(); }); } onClose() { if (!this.resolved) { this.resolve(null); } this.contentEl.empty(); } } ================================================ FILE: src/shared/modals/InstructionConfirmModal.ts ================================================ /** * Claudian - Instruction modal * * Unified modal that handles all instruction mode states: * - Loading (initial processing) * - Clarification (agent asks question) * - Confirmation (final instruction review) */ import type { App } from 'obsidian'; import { Modal, TextAreaComponent } from 'obsidian'; export type InstructionDecision = 'accept' | 'reject'; type ModalState = 'loading' | 'clarification' | 'confirmation'; export interface InstructionModalCallbacks { onAccept: (finalInstruction: string) => void; onReject: () => void; onClarificationSubmit: (response: string) => Promise; } export class InstructionModal extends Modal { private rawInstruction: string; private callbacks: InstructionModalCallbacks; private state: ModalState = 'loading'; private resolved = false; // UI elements private contentSectionEl: HTMLElement | null = null; private loadingEl: HTMLElement | null = null; private clarificationEl: HTMLElement | null = null; private confirmationEl: HTMLElement | null = null; private buttonsEl: HTMLElement | null = null; // Clarification state private clarificationTextEl: HTMLElement | null = null; private responseTextarea: TextAreaComponent | null = null; private isSubmitting = false; // Confirmation state private refinedInstruction: string = ''; private editTextarea: TextAreaComponent | null = null; private isEditing = false; private refinedDisplayEl: HTMLElement | null = null; private editContainerEl: HTMLElement | null = null; private editBtnEl: HTMLButtonElement | null = null; constructor( app: App, rawInstruction: string, callbacks: InstructionModalCallbacks ) { super(app); this.rawInstruction = rawInstruction; this.callbacks = callbacks; } onOpen() { const { contentEl } = this; contentEl.addClass('claudian-instruction-modal'); this.setTitle('Add Custom Instruction'); // User input section (always visible) const inputSection = contentEl.createDiv({ cls: 'claudian-instruction-section' }); const inputLabel = inputSection.createDiv({ cls: 'claudian-instruction-label' }); inputLabel.setText('Your input:'); const inputText = inputSection.createDiv({ cls: 'claudian-instruction-original' }); inputText.setText(this.rawInstruction); // Main content section (changes based on state) this.contentSectionEl = contentEl.createDiv({ cls: 'claudian-instruction-content-section' }); // Loading state this.loadingEl = this.contentSectionEl.createDiv({ cls: 'claudian-instruction-loading' }); this.loadingEl.createDiv({ cls: 'claudian-instruction-spinner' }); this.loadingEl.createSpan({ text: 'Processing your instruction...' }); // Clarification state (hidden initially) this.clarificationEl = this.contentSectionEl.createDiv({ cls: 'claudian-instruction-clarification-section' }); this.clarificationEl.style.display = 'none'; this.clarificationTextEl = this.clarificationEl.createDiv({ cls: 'claudian-instruction-clarification' }); const responseSection = this.clarificationEl.createDiv({ cls: 'claudian-instruction-section' }); const responseLabel = responseSection.createDiv({ cls: 'claudian-instruction-label' }); responseLabel.setText('Your response:'); this.responseTextarea = new TextAreaComponent(responseSection); this.responseTextarea.inputEl.addClass('claudian-instruction-response-textarea'); this.responseTextarea.inputEl.rows = 3; this.responseTextarea.inputEl.placeholder = 'Provide more details...'; this.responseTextarea.inputEl.addEventListener('keydown', (e) => { // Check !e.isComposing for IME support (Chinese, Japanese, Korean, etc.) if (e.key === 'Enter' && !e.shiftKey && !e.isComposing && !this.isSubmitting) { e.preventDefault(); this.submitClarification(); } }); // Confirmation state (hidden initially) this.confirmationEl = this.contentSectionEl.createDiv({ cls: 'claudian-instruction-confirmation-section' }); this.confirmationEl.style.display = 'none'; // Refined instruction display/edit const refinedSection = this.confirmationEl.createDiv({ cls: 'claudian-instruction-section' }); const refinedLabel = refinedSection.createDiv({ cls: 'claudian-instruction-label' }); refinedLabel.setText('Refined snippet:'); this.refinedDisplayEl = refinedSection.createDiv({ cls: 'claudian-instruction-refined' }); this.editContainerEl = refinedSection.createDiv({ cls: 'claudian-instruction-edit-container' }); this.editContainerEl.style.display = 'none'; this.editTextarea = new TextAreaComponent(this.editContainerEl); this.editTextarea.inputEl.addClass('claudian-instruction-edit-textarea'); this.editTextarea.inputEl.rows = 4; // Buttons (changes based on state) this.buttonsEl = contentEl.createDiv({ cls: 'claudian-instruction-buttons' }); this.updateButtons(); this.showState('loading'); } showClarification(clarification: string) { if (this.clarificationTextEl) { this.clarificationTextEl.setText(clarification); } if (this.responseTextarea) { this.responseTextarea.setValue(''); } this.isSubmitting = false; this.showState('clarification'); this.responseTextarea?.inputEl.focus(); } showConfirmation(refinedInstruction: string) { this.refinedInstruction = refinedInstruction; if (this.refinedDisplayEl) { this.refinedDisplayEl.setText(refinedInstruction); } if (this.editTextarea) { this.editTextarea.setValue(refinedInstruction); } this.showState('confirmation'); } showError(error: string) { // Just close - the error notice will be shown by caller this.resolved = true; this.close(); } showClarificationLoading() { this.isSubmitting = true; if (this.loadingEl) { this.loadingEl.querySelector('.claudian-instruction-spinner'); const text = this.loadingEl.querySelector('span'); if (text) text.textContent = 'Processing...'; } this.showState('loading'); } private showState(state: ModalState) { this.state = state; if (this.loadingEl) { this.loadingEl.style.display = state === 'loading' ? 'flex' : 'none'; } if (this.clarificationEl) { this.clarificationEl.style.display = state === 'clarification' ? 'block' : 'none'; } if (this.confirmationEl) { this.confirmationEl.style.display = state === 'confirmation' ? 'block' : 'none'; } this.updateButtons(); } private updateButtons() { if (!this.buttonsEl) return; this.buttonsEl.empty(); const cancelBtn = this.buttonsEl.createEl('button', { text: 'Cancel', cls: 'claudian-instruction-btn claudian-instruction-reject-btn', attr: { 'aria-label': 'Cancel' } }); cancelBtn.addEventListener('click', () => this.handleReject()); if (this.state === 'clarification') { const submitBtn = this.buttonsEl.createEl('button', { text: 'Submit', cls: 'claudian-instruction-btn claudian-instruction-accept-btn', attr: { 'aria-label': 'Submit response' } }); submitBtn.addEventListener('click', () => this.submitClarification()); } else if (this.state === 'confirmation') { this.editBtnEl = this.buttonsEl.createEl('button', { text: 'Edit', cls: 'claudian-instruction-btn claudian-instruction-edit-btn', attr: { 'aria-label': 'Edit instruction' } }); this.editBtnEl.addEventListener('click', () => this.toggleEdit()); const acceptBtn = this.buttonsEl.createEl('button', { text: 'Accept', cls: 'claudian-instruction-btn claudian-instruction-accept-btn', attr: { 'aria-label': 'Accept instruction' } }); acceptBtn.addEventListener('click', () => this.handleAccept()); acceptBtn.focus(); } } private async submitClarification() { const response = this.responseTextarea?.getValue().trim(); if (!response || this.isSubmitting) return; this.showClarificationLoading(); try { await this.callbacks.onClarificationSubmit(response); } catch { // On error, go back to clarification state this.isSubmitting = false; this.showState('clarification'); } } private toggleEdit() { this.isEditing = !this.isEditing; if (this.isEditing) { if (this.refinedDisplayEl) this.refinedDisplayEl.style.display = 'none'; if (this.editContainerEl) this.editContainerEl.style.display = 'block'; if (this.editBtnEl) this.editBtnEl.setText('Preview'); this.editTextarea?.inputEl.focus(); } else { const edited = this.editTextarea?.getValue() || this.refinedInstruction; this.refinedInstruction = edited; if (this.refinedDisplayEl) { this.refinedDisplayEl.setText(edited); this.refinedDisplayEl.style.display = 'block'; } if (this.editContainerEl) this.editContainerEl.style.display = 'none'; if (this.editBtnEl) this.editBtnEl.setText('Edit'); } } private handleAccept() { if (this.resolved) return; this.resolved = true; const finalInstruction = this.isEditing ? (this.editTextarea?.getValue() || this.refinedInstruction) : this.refinedInstruction; this.callbacks.onAccept(finalInstruction); this.close(); } private handleReject() { if (this.resolved) return; this.resolved = true; this.callbacks.onReject(); this.close(); } onClose() { if (!this.resolved) { this.resolved = true; this.callbacks.onReject(); } this.contentEl.empty(); } } ================================================ FILE: src/style/CLAUDE.md ================================================ # CSS Style Guide ## Structure ``` src/style/ ├── base/ # container, animations (@keyframes), variables ├── components/ # header, history, messages, code, thinking, toolcalls, status-panel, subagent, input, context-footer, tabs, scroll-to-bottom ├── toolbar/ # model-selector, thinking-selector, permission-toggle, external-context, mcp-selector ├── features/ # file-context, image-context, image-modal, inline-edit, diff, slash-commands, file-link, image-embed, plan-mode, ask-user-question ├── modals/ # instruction, mcp-modal, fork-target ├── settings/ # base (shared .claudian-sp-* panel layout), env-snippets, slash-settings, mcp-settings, plugin-settings, agent-settings ├── accessibility.css └── index.css # Build order (@import list) ``` ## Build CSS is built into root `styles.css` via `npm run build:css` (also runs in `npm run dev`). **Adding new modules**: Register in `index.css` via `@import` or the build will omit them. ## Conventions - **Prefix**: All classes use `.claudian-` prefix - **BEM-lite**: `.claudian-{block}`, `.claudian-{block}-{element}`, `.claudian-{block}--{modifier}` - **No `!important`**: Avoid unless overriding Obsidian defaults - **CSS variables**: Use Obsidian's `--background-*`, `--text-*`, `--interactive-*` tokens ## Naming Patterns | Pattern | Examples | |---------|----------| | Layout | `-container`, `-header`, `-messages`, `-input` | | Messages | `-message`, `-message-user`, `-message-assistant` | | Tool calls | `-tool-call`, `-tool-header`, `-tool-content`, `-tool-status` | | Thinking | `-thinking-block`, `-thinking-header`, `-thinking-content` | | Panels | `-todo-list`, `-todo-item`, `-subagent-list`, `-subagent-header` | | Context | `-file-chip`, `-image-chip`, `-mention-dropdown` | | Plan mode | `-plan-approval-inline`, `-plan-content-preview`, `-plan-feedback-*`, `-plan-permissions` | | Ask user | `-ask-list`, `-ask-item`, `-ask-cursor`, `-ask-hints` | | Command panel | `-status-panel-bash`, `-status-panel-bash-header`, `-status-panel-bash-entry`, `-status-panel-bash-actions` | | Modals | `-instruction-modal`, `-mcp-modal`, `-fork-target-*` | ## Gotchas - Obsidian uses `body.theme-dark` / `body.theme-light` for theme detection - Modal z-index must be > 1000 to overlay Obsidian UI - Use `var(--font-monospace)` for code blocks, not hardcoded fonts ================================================ FILE: src/style/accessibility.css ================================================ /* Accessibility - Focus Visible Styles */ /* outline + offset + border-radius */ .claudian-tool-header:focus-visible, .claudian-thinking-header:focus-visible, .claudian-subagent-header:focus-visible, .claudian-header-btn:focus-visible, .claudian-model-btn:focus-visible, .claudian-thinking-current:focus-visible { outline: 2px solid var(--interactive-accent); outline-offset: 2px; border-radius: 4px; } /* outline + offset only */ .claudian-action-btn:focus-visible, .claudian-toggle-switch:focus-visible, .claudian-file-chip:focus-visible, .claudian-image-chip:focus-visible, .claudian-file-chip-remove:focus-visible, .claudian-image-remove:focus-visible, .claudian-image-modal-close:focus-visible, .claudian-approved-remove-btn:focus-visible, .claudian-save-env-btn:focus-visible, .claudian-restore-snippet-btn:focus-visible, .claudian-edit-snippet-btn:focus-visible, .claudian-delete-snippet-btn:focus-visible, .claudian-cancel-btn:focus-visible, .claudian-save-btn:focus-visible, .claudian-code-lang-label:focus-visible { outline: 2px solid var(--interactive-accent); outline-offset: 2px; } /* outline + negative offset + border-radius */ .claudian-history-item-content:focus-visible { outline: 2px solid var(--interactive-accent); outline-offset: -2px; border-radius: 4px; } ================================================ FILE: src/style/base/animations.css ================================================ @keyframes thinking-pulse { 0%, 100% { opacity: 0.5; } 50% { opacity: 1; } } @keyframes spin { to { transform: rotate(360deg); } } @keyframes external-context-glow { 0%, 100% { filter: drop-shadow(0 0 2px rgba(var(--claudian-brand-rgb), 0.4)); } 50% { filter: drop-shadow(0 0 6px rgba(var(--claudian-brand-rgb), 0.8)); } } @keyframes mcp-glow { 0%, 100% { filter: drop-shadow(0 0 2px rgba(124, 58, 237, 0.4)); } 50% { filter: drop-shadow(0 0 8px rgba(124, 58, 237, 0.9)); } } @keyframes claudian-spin { to { transform: rotate(360deg); } } ================================================ FILE: src/style/base/container.css ================================================ .claudian-container { display: flex; flex-direction: column; height: 100%; padding: 0; overflow: hidden; } ================================================ FILE: src/style/base/variables.css ================================================ /* Brand & semantic color tokens */ .claudian-container { --claudian-brand: #D97757; --claudian-brand-rgb: 217, 119, 87; --claudian-error: #dc3545; --claudian-error-rgb: 220, 53, 69; --claudian-compact: #5bc0de; } ================================================ FILE: src/style/components/code.css ================================================ /* Code block wrapper - contains pre + button outside scroll area */ .claudian-code-wrapper { position: relative; margin: 8px 0; } /* Code blocks in chat messages */ .claudian-code-wrapper pre, .claudian-message-content pre { background: rgba(0, 0, 0, 0.2); padding: 8px 12px; border-radius: 6px; overflow-x: auto; margin: 0; } /* Light mode: use a lighter background so hljs comment colors stay readable */ body.theme-light .claudian-code-wrapper pre, body.theme-light .claudian-message-content pre { background: rgba(0, 0, 0, 0.08); } /* Code blocks without language - wrap content */ .claudian-code-wrapper:not(.has-language) pre { white-space: pre-wrap; word-wrap: break-word; overflow-x: hidden; } /* Unwrapped pre still needs margin */ .claudian-message-content>pre { margin: 8px 0; } .claudian-message-content code { font-family: var(--font-monospace); font-size: 13px; } /* Clickable language label - positioned outside scroll area */ .claudian-code-wrapper .claudian-code-lang-label { position: absolute; top: 6px; inset-inline-end: 6px; padding: 2px 8px; font-size: 12px; font-family: var(--font-monospace); color: var(--text-faint); background: var(--background-primary); border-radius: 3px; cursor: pointer; z-index: 2; transition: color 0.15s ease, background 0.15s ease; } .claudian-code-wrapper .claudian-code-lang-label:hover { color: var(--text-normal); background: var(--background-modifier-hover); } /* Hide default copy button when language label exists */ .claudian-code-wrapper.has-language .copy-code-button { display: none; } /* Copy button - positioned outside scroll area */ .claudian-code-wrapper .copy-code-button { position: absolute; top: 6px; inset-inline-end: 6px; padding: 4px 8px; font-size: 11px; background: var(--background-primary); border: none; color: var(--text-muted); cursor: pointer; opacity: 0; transition: opacity 0.15s ease, color 0.15s ease, background 0.15s ease; border-radius: 3px; z-index: 2; } /* If copy button uses an icon (svg) */ .claudian-code-wrapper .copy-code-button svg { width: 14px; height: 14px; } /* Show copy button on hover */ .claudian-code-wrapper:not(.has-language):hover .copy-code-button { opacity: 1; } .claudian-code-wrapper .copy-code-button:hover { background: var(--background-modifier-hover); color: var(--text-normal); } ================================================ FILE: src/style/components/context-footer.css ================================================ /* Context usage meter (inline in toolbar) */ .claudian-context-meter { position: relative; display: flex; align-items: center; gap: 4px; margin-inline-start: 8px; cursor: default; } /* Custom tooltip */ .claudian-context-meter::after { content: attr(data-tooltip); position: absolute; bottom: 100%; left: 50%; transform: translateX(-50%); margin-bottom: 6px; padding: 4px 8px; font-size: 11px; color: var(--text-normal); background: var(--background-secondary); border: 1px solid var(--background-modifier-border); border-radius: 4px; white-space: nowrap; opacity: 0; visibility: hidden; transition: opacity 0.15s ease, visibility 0.15s ease; pointer-events: none; z-index: 100; } .claudian-context-meter:hover::after { opacity: 1; visibility: visible; } .claudian-context-meter-gauge { display: flex; align-items: center; justify-content: center; width: 24px; height: 24px; } .claudian-context-meter-gauge svg { width: 16px; height: 16px; } .claudian-meter-bg { stroke: var(--background-modifier-border); } .claudian-meter-fill { stroke: var(--claudian-brand); transition: stroke-dashoffset 0.3s ease, stroke 0.3s ease; } .claudian-context-meter-percent { font-size: 11px; color: var(--claudian-brand); min-width: 24px; text-align: end; transition: color 0.3s ease; } /* Warning state (> 80%) - pale red */ .claudian-context-meter.warning .claudian-meter-fill { stroke: #E57373; } .claudian-context-meter.warning .claudian-context-meter-percent { color: #E57373; } ================================================ FILE: src/style/components/header.css ================================================ /* Header - logo, title/tabs slot, and actions */ .claudian-header { display: flex; align-items: center; padding: 0 12px 12px 12px; } /* Title slot: contains logo + title (or tabs in header mode) */ .claudian-title-slot { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; /* Allow flex item to shrink below content size */ } /* Legacy class for backwards compatibility */ .claudian-title { display: flex; align-items: center; gap: 8px; flex-shrink: 0; } .claudian-title-text { margin: 0; font-size: 14px; font-weight: 600; } .claudian-logo { display: flex; align-items: center; } /* Header actions (end side - always stays at end via margin-inline-start: auto) */ .claudian-header-actions { display: flex; align-items: center; gap: 12px; flex-shrink: 0; margin-inline-start: auto; } .claudian-header-actions-slot { /* No margin-inline-start: auto here; it's set by the base .claudian-header-actions */ } .claudian-header .claudian-tab-bar-container { display: flex; gap: 4px; } .claudian-header-btn { display: flex; align-items: center; justify-content: center; cursor: pointer; color: var(--text-faint); transition: color 0.15s ease; } .claudian-header-btn:hover { color: var(--text-normal); } .claudian-header-btn svg { width: 16px; height: 16px; } .claudian-new-tab-btn svg { width: 16.8px; height: 16.8px; } ================================================ FILE: src/style/components/history.css ================================================ .claudian-history-container { position: relative; } /* History dropup menu (opens upward since it's at bottom of view) */ .claudian-history-menu { display: none; position: absolute; bottom: 100%; inset-inline-end: 0; margin-bottom: 4px; background: var(--background-secondary); backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px); border: 1px solid var(--background-modifier-border); border-radius: 6px; box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.25); z-index: 1000; max-height: 400px; overflow: hidden; width: 280px; } .claudian-history-menu.visible { display: block; } /* Header mode: dropdown instead of dropup */ .claudian-container--header-mode .claudian-history-menu { bottom: auto; top: 100%; margin-bottom: 0; margin-top: 4px; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25); } .claudian-history-header { padding: 8px 12px; font-size: 12px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; border-bottom: 1px solid var(--background-modifier-border); } .claudian-history-list { max-height: 350px; overflow-y: auto; } .claudian-history-empty { padding: 16px; text-align: center; color: var(--text-muted); font-size: 13px; } .claudian-history-item { display: flex; align-items: center; gap: 10px; padding: 8px 12px; border-bottom: 1px solid var(--background-modifier-border); transition: background 0.15s ease; } .claudian-history-item-icon { display: flex; align-items: center; color: var(--text-muted); flex-shrink: 0; } .claudian-history-item-icon svg { width: 16px; height: 16px; } .claudian-history-item:last-child { border-bottom: none; } .claudian-history-item:hover { background: var(--background-modifier-hover); } .claudian-history-item.active { background: var(--background-secondary); border-inline-start: 2px solid var(--interactive-accent); padding-inline-start: 10px; } .claudian-history-item.active .claudian-history-item-icon { color: var(--interactive-accent); } .claudian-history-item.active .claudian-history-item-content { cursor: default; } .claudian-history-item.active .claudian-history-item-date { color: var(--text-faint); } .claudian-history-item-content { flex: 1; min-width: 0; cursor: pointer; } .claudian-history-item-title { font-size: 13px; font-weight: 500; color: var(--text-normal); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .claudian-history-item-date { font-size: 11px; color: var(--text-faint); margin-top: 2px; } .claudian-history-item-actions { display: flex; gap: 4px; margin-inline-start: 8px; opacity: 0; transition: opacity 0.15s ease; } .claudian-history-item:hover .claudian-history-item-actions { opacity: 1; } .claudian-action-btn { background: transparent; border: none; cursor: pointer; padding: 4px; border-radius: 4px; color: var(--text-muted); transition: background 0.15s ease, color 0.15s ease; } .claudian-action-btn:hover { background: var(--background-modifier-hover); color: var(--text-normal); } .claudian-action-btn svg { width: 14px; height: 14px; } .claudian-delete-btn:hover { color: var(--color-red); } .claudian-rename-input { width: 100%; padding: 2px 4px; font-size: 13px; font-weight: 500; border: 1px solid var(--interactive-accent); border-radius: 4px; background: var(--background-primary); color: var(--text-normal); } .claudian-rename-input:focus { outline: none; box-shadow: 0 0 0 2px var(--interactive-accent-hover); } /* Loading indicator for title generation */ .claudian-action-loading { display: flex; align-items: center; justify-content: center; animation: spin 1s linear infinite; opacity: 0.6; cursor: default; } ================================================ FILE: src/style/components/input.css ================================================ /* Input area */ .claudian-input-container { position: relative; padding: 12px 0 0 0; } /* Input wrapper (border container) - flex column so textarea expands when no chips */ /* Height calculation: context row (36px) + textarea min (60px) + toolbar (38px) + border (2px) = 136px */ .claudian-input-wrapper { position: relative; display: flex; flex-direction: column; min-height: 140px; border: 1px solid var(--background-modifier-border); border-radius: 6px; background: var(--background-primary); } /* Context row (file chip start, selection indicator end) - inside input wrapper at top */ /* Collapsed by default; expanded via .has-content class; textarea fills remaining space */ .claudian-context-row { display: none; align-items: flex-start; justify-content: flex-start; flex-shrink: 0; padding: 6px 10px 0 10px; gap: 8px; } /* Show context row when it has visible content */ .claudian-context-row.has-content { display: flex; } /* Nav row (tab badges start, header icons end) - above input wrapper */ .claudian-input-nav-row { display: flex; align-items: center; justify-content: space-between; padding: 0 0 8px 0; min-height: 0; } /* Header mode: hide nav row above input (content moved to header) */ .claudian-container--header-mode .claudian-input-nav-row { display: none; } /* Selection indicator (shown when text is selected in editor) */ /* Match file chip height (24px): chip has 16px remove button + 6px padding + 2px border */ /* Indicator: 12px text + 10px padding (5+5) + 2px border = 24px */ .claudian-selection-indicator, .claudian-browser-selection-indicator, .claudian-canvas-indicator { color: #7abaff; font-size: 12px; line-height: 1; opacity: 0.9; pointer-events: none; white-space: nowrap; padding: 5px 6px; border: 1px solid transparent; border-radius: 4px; margin-inline-start: auto; flex-shrink: 0; order: 4; max-width: min(100%, clamp(220px, 64vw, 560px)); overflow: hidden; text-overflow: ellipsis; } .claudian-input { width: 100%; flex: 1 1 0; min-height: 60px; /* max-height dynamically set by JS: max(150px, 55% of view height) */ resize: none; padding: 8px 10px 10px 10px; border: none !important; border-radius: 6px; background: transparent !important; color: var(--text-normal); font-family: inherit; font-size: 14px; line-height: 1.4; box-shadow: none !important; overflow-y: auto; unicode-bidi: plaintext; /* Proper BiDi text handling for mixed RTL/LTR */ } .claudian-input:hover, .claudian-input:focus { outline: none !important; border: none !important; background: transparent !important; box-shadow: none !important; } .claudian-input::placeholder { color: var(--text-muted); } /* Input toolbar */ .claudian-input-toolbar { display: flex; align-items: center; justify-content: flex-start; flex-shrink: 0; padding: 4px 6px 6px 6px; } /* File indicator (attached files) */ .claudian-file-indicator { display: none; flex-wrap: wrap; gap: 6px; } .claudian-file-chip { display: inline-flex; align-items: center; gap: 4px; padding: 3px 6px 3px 8px; background: var(--background-primary); border: 1px solid var(--background-modifier-border); border-radius: 12px; font-size: 12px; line-height: 1; max-width: 200px; cursor: pointer; } .claudian-file-chip-icon { display: flex; align-items: center; color: var(--text-muted); flex-shrink: 0; } .claudian-file-chip-icon svg { width: 12px; height: 12px; } .claudian-file-chip-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-normal); } .claudian-file-chip-remove { display: flex; align-items: center; justify-content: center; width: 16px; height: 16px; border-radius: 50%; cursor: pointer; color: var(--text-muted); font-size: 14px; line-height: 1; transition: background 0.15s ease, color 0.15s ease; } .claudian-file-chip-remove:hover { background: var(--background-modifier-hover); color: var(--text-normal); } .claudian-file-chip:hover { background: var(--background-modifier-hover); } /* Message Queue Indicator (shown below flavor text in thinking indicator) */ .claudian-queue-indicator { display: none; font-size: 12px; color: var(--text-muted); font-style: normal; margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } /* Light blue border when instruction mode is active */ .claudian-input-instruction-mode { border-color: #60a5fa !important; box-shadow: 0 0 0 1px #60a5fa; } /* Pink border when bash mode is active */ .claudian-input-bang-bash-mode { border-color: #f472b6 !important; box-shadow: 0 0 0 1px #f472b6; } /* Monospace input while in bash mode */ .claudian-input-wrapper.claudian-input-bang-bash-mode .claudian-input { font-family: var(--font-monospace); } ================================================ FILE: src/style/components/messages.css ================================================ /* Messages wrapper (for scroll-to-bottom button positioning) */ .claudian-messages-wrapper { position: relative; flex: 1; min-height: 0; display: flex; flex-direction: column; overflow: hidden; } .claudian-messages { flex: 1; overflow-y: auto; padding: 12px 0; display: flex; flex-direction: column; gap: 12px; } /* Focusable messages panel for vim-style navigation */ .claudian-messages-focusable:focus { outline: none; } /* Welcome message - claude.ai style */ .claudian-welcome { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; padding: 20px; min-height: 200px; } .claudian-welcome-greeting { font-family: 'Copernicus', 'Tiempos Headline', 'Tiempos', Georgia, 'Times New Roman', serif; font-size: 28px; font-weight: 300; color: var(--text-muted); letter-spacing: -0.01em; } .claudian-message { padding: 10px 14px; border-radius: 8px; max-width: 95%; word-wrap: break-word; } .claudian-message-user { position: relative; background: rgba(0, 0, 0, 0.3); align-self: flex-end; border-end-end-radius: 4px; } /* Text selection in user messages - visible highlight */ .claudian-message-user ::selection { background: rgba(255, 255, 255, 0.35); color: inherit; } .claudian-message-assistant { background: transparent; align-self: stretch; width: 100%; max-width: 100%; border-end-start-radius: 4px; text-align: start; } .claudian-message-content { line-height: 1.5; user-select: text; -webkit-user-select: text; unicode-bidi: plaintext; /* Proper BiDi text handling for mixed RTL/LTR */ } .claudian-interrupted { color: #d45d5d; } .claudian-interrupted-hint { color: var(--text-muted); } .claudian-text-block { position: relative; margin: 0; } .claudian-text-copy-btn { position: absolute; bottom: 0; inset-inline-end: 0; border: none; color: var(--text-faint); cursor: pointer; opacity: 0; transition: opacity 0.15s ease, color 0.15s ease; z-index: 2; display: flex; align-items: center; gap: 4px; } .claudian-text-copy-btn svg { width: 16px; height: 16px; } .claudian-text-block:hover .claudian-text-copy-btn { opacity: 1; } .claudian-text-copy-btn:hover { color: var(--text-normal); } .claudian-text-copy-btn.copied { color: var(--text-accent); font-size: 11px; font-family: var(--font-monospace); } .claudian-text-block+.claudian-tool-call { margin-top: 8px; } .claudian-tool-call+.claudian-text-block { margin-top: 8px; } .claudian-message-content p { margin: 0 0 8px 0; } .claudian-message-content p:last-child { margin-bottom: 0; } .claudian-message-content ul, .claudian-message-content ol { margin: 8px 0; padding-inline-start: 20px; } /* Full-width tables */ .claudian-message-content table { width: 100%; border-collapse: collapse; margin: 8px 0; } .claudian-message-content th, .claudian-message-content td { border: 1px solid var(--background-modifier-border); padding: 6px 10px; text-align: start; } .claudian-message-content th { background: var(--background-secondary); font-weight: 600; } .claudian-message-content tr:hover { background: var(--background-secondary-alt); } .claudian-messages::-webkit-scrollbar { width: 6px; } .claudian-messages::-webkit-scrollbar-track { background: transparent; } .claudian-messages::-webkit-scrollbar-thumb { background: var(--background-modifier-border); border-radius: 3px; } .claudian-messages::-webkit-scrollbar-thumb:hover { background: var(--background-modifier-border-hover); } /* Response duration footer - styled as another line of content */ .claudian-response-footer { margin-top: 8px; } .claudian-baked-duration { color: var(--text-muted); font-size: 12px; font-weight: 500; font-style: italic; } /* Action buttons toolbar below user messages */ .claudian-user-msg-actions { position: absolute; bottom: -20px; right: 0; display: flex; gap: 12px; opacity: 0; transition: opacity 0.15s; z-index: 1; } .claudian-message-user:hover .claudian-user-msg-actions { opacity: 1; } .claudian-user-msg-actions span { cursor: pointer; display: flex; align-items: center; justify-content: center; color: var(--text-faint); transition: color 0.15s; } .claudian-user-msg-actions span svg { width: 16px; height: 16px; } .claudian-user-msg-actions span:hover { color: var(--text-normal); } .claudian-user-msg-actions span.copied { color: var(--text-accent); font-size: 11px; font-family: var(--font-monospace); } /* Compact boundary indicator */ .claudian-compact-boundary { display: flex; align-items: center; gap: 10px; margin: 12px 0; } .claudian-compact-boundary::before, .claudian-compact-boundary::after { content: ''; flex: 1; height: 1px; background: var(--background-modifier-border); } .claudian-compact-boundary-label { color: var(--text-muted); font-size: 11px; white-space: nowrap; } ================================================ FILE: src/style/components/nav-sidebar.css ================================================ /* Navigation Sidebar */ .claudian-nav-sidebar { position: absolute; right: 2px; top: 50%; transform: translateY(-50%); display: flex; flex-direction: column; gap: 4px; z-index: 100; opacity: 0; pointer-events: none; transition: opacity 0.2s ease; } .claudian-nav-sidebar.visible { opacity: 0.15; pointer-events: auto; } .claudian-nav-sidebar.visible:hover { opacity: 1; } .claudian-nav-btn { width: 32px; height: 32px; border-radius: 16px; background: var(--background-primary); border: 1px solid var(--background-modifier-border); color: var(--text-muted); display: flex; align-items: center; justify-content: center; cursor: pointer; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); transition: all 0.2s ease; } .claudian-nav-btn:hover { background: var(--background-secondary); color: var(--text-normal); transform: scale(1.05); } .claudian-nav-btn svg { width: 18px; height: 18px; } /* Specific button spacing/grouping if needed */ .claudian-nav-btn-top { margin-bottom: 4px; } .claudian-nav-btn-bottom { margin-top: 4px; } ================================================ FILE: src/style/components/status-panel.css ================================================ /* Status Panel - persistent bottom panel for todos and command output */ .claudian-status-panel-container { flex-shrink: 0; padding: 0 14px; } .claudian-status-panel { padding-top: 12px; } /* Todo Section */ .claudian-status-panel-todos { margin-top: 4px; } .claudian-status-panel-header { display: flex; align-items: center; gap: 8px; padding: 4px 0; cursor: pointer; border-radius: 4px; transition: background 0.15s ease; overflow: hidden; } .claudian-status-panel-header:hover { background: var(--background-modifier-hover); } .claudian-status-panel-header:focus-visible { outline: 2px solid var(--interactive-accent); outline-offset: 2px; } .claudian-status-panel-icon { display: flex; align-items: center; color: var(--text-accent); flex-shrink: 0; } .claudian-status-panel-icon svg { width: 16px; height: 16px; } .claudian-status-panel-label { font-family: var(--font-monospace); font-size: 13px; font-weight: 500; color: var(--text-normal); } .claudian-status-panel-current { flex: 1; font-family: var(--font-monospace); font-size: 13px; color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .claudian-status-panel-status { display: flex; align-items: center; flex-shrink: 0; } .claudian-status-panel-status svg { width: 14px; height: 14px; } .claudian-status-panel-status.status-completed { color: var(--color-green); } .claudian-status-panel-content { padding: 2px 0; } /* Individual todo item - shared by status panel and inline tool via .claudian-todo-list-container */ .claudian-todo-list-container .claudian-todo-item { display: flex; align-items: flex-start; padding: 1px 0; } .claudian-todo-list-container .claudian-todo-status-icon { display: flex; align-items: center; flex-shrink: 0; } .claudian-todo-list-container .claudian-todo-status-icon svg { width: 12px; height: 12px; } .claudian-todo-list-container .claudian-todo-text { font-family: var(--font-monospace); font-size: 12px; line-height: 1.4; flex: 1; padding-left: 12px; } .claudian-todo-list-container .claudian-todo-pending .claudian-todo-status-icon { color: var(--text-normal); } .claudian-todo-list-container .claudian-todo-pending .claudian-todo-status-icon svg { transform: scale(2); } .claudian-todo-list-container .claudian-todo-pending .claudian-todo-text { color: var(--text-normal); } .claudian-todo-list-container .claudian-todo-in_progress .claudian-todo-status-icon { color: var(--interactive-accent); } .claudian-todo-list-container .claudian-todo-in_progress .claudian-todo-status-icon svg { transform: scale(2); } .claudian-todo-list-container .claudian-todo-in_progress .claudian-todo-text { color: var(--text-normal); } .claudian-todo-list-container .claudian-todo-completed .claudian-todo-status-icon { color: var(--color-green); } .claudian-todo-list-container .claudian-todo-completed .claudian-todo-text { color: var(--text-muted); } /* Bash Output Section */ .claudian-status-panel-bash { margin-bottom: 4px; } .claudian-status-panel-bash-header { padding: 4px 0; } .claudian-status-panel-bash-actions { display: flex; align-items: center; gap: 6px; } .claudian-status-panel-bash-action { display: inline-flex; align-items: center; justify-content: center; width: 22px; height: 22px; border-radius: 4px; color: var(--text-muted); } .claudian-status-panel-bash-action:hover { background: var(--background-modifier-hover); color: var(--text-normal); } .claudian-status-panel-bash-content { padding-top: 2px; max-height: 320px; max-height: min(40vh, 320px); overflow-y: auto; overscroll-behavior: contain; } .claudian-status-panel-bash-entry { margin: 4px 0; } .claudian-status-panel-bash-entry .claudian-tool-icon svg { width: 14px; height: 14px; position: relative; top: -1px; } .claudian-status-panel-bash-entry .claudian-tool-call { margin: 0; } /* Keep bash output blocks from growing without bound */ .claudian-status-panel-bash-entry .claudian-tool-result-text { max-height: 200px; overflow-y: auto; word-break: break-word; } ================================================ FILE: src/style/components/subagent.css ================================================ .claudian-subagent-list { margin: 8px 0; } .claudian-text-block+.claudian-subagent-list { margin-top: 8px; } .claudian-subagent-list+.claudian-text-block { margin-top: 8px; } .claudian-tool-call+.claudian-subagent-list { margin-top: 8px; } .claudian-subagent-list+.claudian-tool-call { margin-top: 8px; } .claudian-subagent-header { display: flex; align-items: center; gap: 8px; padding: 4px 0; cursor: pointer; overflow: hidden; } .claudian-subagent-icon { display: flex; align-items: center; color: var(--interactive-accent); flex-shrink: 0; } .claudian-subagent-icon svg { width: 16px; height: 16px; } .claudian-subagent-label { flex: 1; font-family: var(--font-monospace); font-size: 13px; font-weight: 400; color: var(--text-normal); } .claudian-subagent-count { font-size: 11px; color: var(--text-muted); flex-shrink: 0; } .claudian-subagent-status { display: flex; align-items: center; flex-shrink: 0; } .claudian-subagent-status svg { width: 14px; height: 14px; } .claudian-subagent-status.status-running { color: var(--text-accent); } .claudian-subagent-status.status-completed { color: var(--color-green); } .claudian-subagent-status.status-error { color: var(--color-red); } .claudian-subagent-content { padding: 4px 0; padding-inline-start: 16px; margin-inline-start: 7px; border-inline-start: 2px solid var(--background-modifier-border); } .claudian-subagent-section { margin: 2px 0 6px; } .claudian-subagent-section-header { display: flex; align-items: center; gap: 6px; cursor: pointer; user-select: none; font-family: var(--font-monospace); font-size: 12px; color: var(--text-muted); padding: 2px 0; } .claudian-subagent-section-header:hover { color: var(--text-normal); } .claudian-subagent-section-title { flex: 1; min-width: 0; } .claudian-subagent-section-body { padding-inline-start: 6px; } .claudian-subagent-prompt-text, .claudian-subagent-result-output { font-family: var(--font-monospace); font-size: 12px; line-height: 1.4; color: var(--text-muted); white-space: pre-wrap; word-break: break-word; } .claudian-subagent-result-output { max-height: 220px; overflow-y: auto; } .claudian-subagent-tools { display: flex; flex-direction: column; gap: 4px; } .claudian-subagent-tool-item { display: block; } .claudian-subagent-tool-header { display: flex; align-items: center; gap: 6px; cursor: pointer; overflow: hidden; padding: 2px 0; } .claudian-subagent-tool-header:hover { opacity: 0.85; } .claudian-subagent-tool-icon { display: flex; align-items: center; color: var(--text-accent); flex-shrink: 0; } .claudian-subagent-tool-icon svg { width: 13px; height: 13px; } .claudian-subagent-tool-name { font-family: var(--font-monospace); font-size: 12px; color: var(--text-normal); white-space: nowrap; flex-shrink: 0; } .claudian-subagent-tool-summary { font-family: var(--font-monospace); font-size: 12px; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 1; min-width: 0; } .claudian-subagent-tool-status { display: flex; align-items: center; flex-shrink: 0; } .claudian-subagent-tool-status svg { width: 12px; height: 12px; } .claudian-subagent-tool-status.status-running { color: var(--text-accent); } .claudian-subagent-tool-status.status-completed { color: var(--color-green); } .claudian-subagent-tool-status.status-error, .claudian-subagent-tool-status.status-blocked { color: var(--color-red); } .claudian-subagent-tool-content { padding: 2px 0 2px 16px; } .claudian-subagent-tool-empty { color: var(--text-faint); font-style: italic; font-family: var(--font-monospace); font-size: 12px; padding: 2px 0; } .claudian-subagent-status-text { font-size: 11px; font-family: var(--font-monospace); color: var(--text-muted); margin-inline-start: auto; padding-inline-start: 8px; } .claudian-subagent-list.async .claudian-subagent-icon { color: var(--interactive-accent); } .claudian-subagent-list.async.pending .claudian-subagent-status-text { color: var(--text-muted); } .claudian-subagent-list.async.running .claudian-subagent-status-text { color: var(--text-accent); } .claudian-subagent-list.async.awaiting .claudian-subagent-status-text { color: var(--color-yellow); } .claudian-subagent-list.async.completed .claudian-subagent-status-text { color: var(--color-green); } .claudian-subagent-list.async.error .claudian-subagent-status-text { color: var(--color-red); } .claudian-subagent-list.async.orphaned .claudian-subagent-status-text { color: var(--color-orange); } ================================================ FILE: src/style/components/tabs.css ================================================ .claudian-tab-bar-container { display: flex; align-items: center; gap: 4px; } .claudian-tab-badges { display: flex; align-items: center; gap: 4px; } .claudian-tab-badge { display: flex; align-items: center; justify-content: center; width: 24px; height: 24px; border-radius: 4px; border: 2px solid var(--background-modifier-border); font-size: 12px; font-weight: 500; cursor: pointer; color: var(--text-muted); background: var(--background-primary); transition: border-color 0.15s ease, color 0.15s ease, background 0.15s ease; } .claudian-tab-badge:hover { background: var(--background-modifier-hover); color: var(--text-normal); } .claudian-tab-badge-active { border-color: var(--interactive-accent); color: var(--text-normal); } .claudian-tab-badge-streaming { border-color: var(--claudian-brand, #da7756); } .claudian-tab-badge-attention { border-color: var(--text-error); } .claudian-tab-badge-idle { border-color: var(--background-modifier-border); } .claudian-tab-content-container { display: flex; flex-direction: column; flex: 1; min-height: 0; overflow: hidden; } .claudian-tab-content { position: relative; /* For scroll-to-bottom button positioning */ display: flex; flex-direction: column; flex: 1; min-height: 0; overflow: hidden; } ================================================ FILE: src/style/components/thinking.css ================================================ .claudian-thinking { color: var(--claudian-brand); font-style: italic; padding: 4px 0; text-align: start; animation: thinking-pulse 1.5s ease-in-out infinite; } .claudian-thinking.claudian-thinking--compact { color: var(--claudian-compact); } .claudian-thinking-hint { color: var(--text-muted); font-style: normal; font-variant-numeric: tabular-nums; } .claudian-thinking-block { margin: 8px 0; } .claudian-text-block+.claudian-thinking-block { margin-top: 8px; } .claudian-thinking-block+.claudian-text-block { margin-top: 8px; } .claudian-thinking-block+.claudian-tool-call { margin-top: 8px; } .claudian-tool-call+.claudian-thinking-block { margin-top: 8px; } .claudian-thinking-header { display: flex; align-items: center; gap: 8px; padding: 4px 0; cursor: pointer; overflow: hidden; } .claudian-thinking-label { flex: 1; font-size: 13px; font-weight: 500; color: var(--claudian-brand); } /* Thinking block content - tree-branch style */ .claudian-thinking-content { padding: 4px 0; padding-inline-start: 24px; font-size: 13px; line-height: 1.5; color: var(--text-muted); max-height: 400px; overflow-y: auto; border-inline-start: 2px solid var(--background-modifier-border); margin-inline-start: 7px; } .claudian-thinking-content p { margin: 0 0 8px 0; } .claudian-thinking-content p:last-child { margin-bottom: 0; } .claudian-thinking-content .claudian-code-wrapper { margin: 8px 0; } .claudian-thinking-content .claudian-code-wrapper pre { padding: 8px 10px; border-radius: 4px; } .claudian-thinking-content code { font-family: var(--font-monospace); font-size: 12px; } ================================================ FILE: src/style/components/toolcalls.css ================================================ .claudian-tool-call { margin: 8px 0; } .claudian-tool-header { display: flex; align-items: center; gap: 8px; padding: 4px 0; cursor: pointer; overflow: hidden; } .claudian-tool-header:hover { opacity: 0.85; } .claudian-tool-icon { display: flex; align-items: center; color: var(--text-accent); flex-shrink: 0; } .claudian-tool-icon svg { width: 16px; height: 16px; } .claudian-tool-name { font-family: var(--font-monospace); font-size: 13px; font-weight: 400; color: var(--text-normal); white-space: nowrap; flex-shrink: 0; } .claudian-tool-summary { font-family: var(--font-monospace); font-size: 13px; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 1; min-width: 0; } .claudian-tool-summary:empty { display: none; } /* Legacy: StatusPanel bash entries still use claudian-tool-label */ .claudian-tool-label { font-family: var(--font-monospace); font-size: 13px; font-weight: 400; color: var(--text-normal); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .claudian-tool-current { font-family: var(--font-monospace); font-size: 13px; color: var(--text-muted); white-space: nowrap; } .claudian-tool-current:empty { display: none; } .claudian-tool-status { display: flex; align-items: center; flex-shrink: 0; margin-left: auto; } .claudian-tool-status svg { width: 14px; height: 14px; } .claudian-tool-status.status-running { color: var(--text-accent); } .claudian-tool-status.status-completed { color: var(--color-green); } .claudian-tool-status.status-error { color: var(--color-red); } .claudian-tool-status.status-blocked { color: var(--color-orange); } /* Tool call content - border style (like thinking block) */ .claudian-tool-content { padding: 4px 0; padding-inline-start: 16px; margin-inline-start: 7px; border-inline-start: 2px solid var(--background-modifier-border); } /* Tool content variants that render inline widgets instead of bordered results */ .claudian-tool-content-todo, .claudian-tool-content-ask { border-inline-start: none; margin-inline-start: 0; padding-inline-start: 0; } /* Expanded content: per-line rendering */ .claudian-tool-lines { font-family: var(--font-monospace); font-size: 12px; line-height: 1.4; overflow-x: auto; } .claudian-tool-line { padding: 1px 0; color: var(--text-muted); white-space: pre; } /* Hover highlight for file search results */ .claudian-tool-line.hoverable:hover { background: var(--background-modifier-hover); } /* Truncation indicator: "... N more lines" */ .claudian-tool-truncated { color: var(--text-faint); font-style: italic; padding: 4px 0; font-family: var(--font-monospace); font-size: 12px; } /* ToolSearch expanded: icon + tool name rows */ .claudian-tool-search-item { display: flex; align-items: center; gap: 4px; padding: 2px 0; font-family: var(--font-monospace); font-size: 12px; color: var(--text-muted); } .claudian-tool-search-icon { display: flex; align-items: center; flex-shrink: 0; width: 14px; height: 14px; color: var(--text-faint); } .claudian-tool-search-icon svg { width: 14px; height: 14px; } /* Web search links */ .claudian-tool-link { display: flex; align-items: flex-start; gap: 6px; padding: 3px 0; color: var(--text-muted); text-decoration: none; cursor: pointer; font-family: var(--font-monospace); font-size: 12px; line-height: 1.4; } .claudian-tool-link:hover { color: var(--text-accent); } .claudian-tool-link-icon { flex-shrink: 0; width: 12px; height: 12px; margin-top: 2px; color: var(--text-faint); } .claudian-tool-link-icon svg { width: 12px; height: 12px; } .claudian-tool-link-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } /* Web search summary section */ .claudian-tool-web-summary { font-family: var(--font-monospace); font-size: 12px; color: var(--text-muted); padding-top: 4px; border-top: 1px solid var(--background-modifier-border); margin-top: 4px; white-space: pre-wrap; word-break: break-word; max-height: 200px; overflow-y: auto; } /* Empty state for file search */ .claudian-tool-empty { color: var(--text-faint); font-style: italic; font-family: var(--font-monospace); font-size: 12px; padding: 4px 0; } .claudian-tool-result-row { display: flex; align-items: flex-start; } .claudian-tool-result-text { font-family: var(--font-monospace); font-size: 12px; color: var(--text-muted); line-height: 1.4; white-space: pre-wrap; overflow: hidden; flex: 1; } .claudian-tool-result-item { display: block; margin-bottom: 2px; } .claudian-tool-result-item:last-child { margin-bottom: 0; } ================================================ FILE: src/style/features/ask-user-question.css ================================================ /* AskUserQuestion - inline widget rendered in chat panel */ .claudian-ask-question-inline { font-family: var(--font-monospace); font-size: 12px; outline: none; } .claudian-ask-inline-title { font-weight: 700; color: var(--text-muted); padding: 6px 10px 0; } /* ── Tab bar ─────────────────────────────────── */ .claudian-ask-tab-bar { display: flex; align-items: center; gap: 6px; padding: 4px 10px; border-bottom: 1px solid var(--background-modifier-border); line-height: 1.4; } .claudian-ask-tab { display: inline-flex; align-items: center; padding: 2px 10px; border-radius: 3px; cursor: pointer; user-select: none; color: var(--text-muted); transition: background 0.15s ease, color 0.15s ease; } .claudian-ask-tab:hover { color: var(--text-normal); } .claudian-ask-tab.is-active { background: hsla(55, 30%, 50%, 0.18); color: var(--text-normal); } .claudian-ask-tab-label { font-weight: 600; max-width: 14ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .claudian-ask-tab-tick { color: var(--color-green); font-weight: 700; } .claudian-ask-tab-submit-check { color: var(--color-green); white-space: pre; } /* ── Content area ────────────────────────────── */ .claudian-ask-content { padding: 8px 10px; } .claudian-ask-question-text { font-weight: 700; color: var(--text-normal); margin-bottom: 8px; line-height: 1.4; } /* ── Item list ───────────────────────────────── */ .claudian-ask-list { display: flex; flex-direction: column; gap: 2px; margin-bottom: 8px; } .claudian-ask-item { display: flex; align-items: flex-start; padding: 3px 4px; cursor: pointer; line-height: 1.4; color: var(--text-normal); border-radius: 3px; } .claudian-ask-item:hover { background: var(--background-modifier-hover); } .claudian-ask-cursor { display: inline-block; width: 2ch; flex-shrink: 0; color: var(--text-accent); font-weight: 700; } .claudian-ask-item-num { color: var(--text-muted); flex-shrink: 0; } .claudian-ask-item-content { display: flex; flex-direction: column; flex: 1; min-width: 0; } .claudian-ask-label-row { display: flex; align-items: baseline; } .claudian-ask-item-label { font-weight: 600; } .claudian-ask-item-desc { color: var(--text-muted); font-weight: 400; line-height: 1.4; margin-top: 1px; } /* Selected items: green text */ .claudian-ask-item.is-selected .claudian-ask-item-label { color: var(--color-green); } /* Disabled items: muted text */ .claudian-ask-item.is-disabled .claudian-ask-item-label { color: var(--text-faint); } /* ── Multi-select brackets ───────────────────── */ .claudian-ask-check { color: var(--text-faint); flex-shrink: 0; white-space: pre; } .claudian-ask-check.is-checked { color: var(--color-green); } /* ── Single-select check mark ────────────────── */ .claudian-ask-check-mark { color: var(--color-green); font-weight: 700; } /* ── Custom input ────────────────────────────── */ .claudian-ask-item input.claudian-ask-custom-text, .claudian-ask-item input.claudian-ask-custom-text:hover, .claudian-ask-item input.claudian-ask-custom-text:focus { border: none; border-radius: 0; background: transparent; box-shadow: none; font-family: var(--font-monospace); font-size: inherit; color: var(--text-normal); outline: none; padding: 0; width: 0; height: auto; min-height: 0; line-height: 1.4; flex: 1 1 0; min-width: 0; } .claudian-ask-custom-text::placeholder { color: var(--text-faint); } /* ── Submit review tab ───────────────────────── */ .claudian-ask-review-title { font-weight: 700; color: var(--text-normal); margin-bottom: 6px; } .claudian-ask-review { font-family: var(--font-monospace); margin-bottom: 16px; } .claudian-tool-content .claudian-ask-review { font-size: 12px; } .claudian-ask-review:last-child { margin-bottom: 0; } .claudian-ask-review-pair { display: flex; gap: 6px; margin-bottom: 4px; } .claudian-ask-review-pair:last-child { margin-bottom: 0; } .claudian-ask-review-num { color: var(--text-muted); flex-shrink: 0; } .claudian-ask-review-body { min-width: 0; } .claudian-ask-review-q-text { color: var(--text-muted); } .claudian-ask-review-a-text { color: var(--text-normal); } .claudian-ask-review-empty { color: var(--text-faint); font-style: italic; } .claudian-ask-review-prompt { color: var(--text-muted); margin-bottom: 6px; } /* ── Hints ───────────────────────────────────── */ .claudian-ask-hints { color: var(--text-faint); padding-top: 6px; border-top: 1px solid var(--background-modifier-border); } /* ── Approval header (inline permission request) ── */ .claudian-ask-approval-info { padding: 8px 10px; } .claudian-ask-approval-tool { display: flex; align-items: center; gap: 8px; padding: 6px 10px; background: var(--background-secondary); border-radius: 6px; margin-bottom: 8px; } .claudian-ask-approval-icon { color: var(--claudian-brand); } .claudian-ask-approval-tool-name { font-weight: 600; color: var(--text-normal); } .claudian-ask-approval-reason { color: var(--text-muted); font-size: 12px; margin-bottom: 6px; } .claudian-ask-approval-blocked-path { font-family: var(--font-monospace); font-size: 11px; color: var(--text-muted); padding: 3px 6px; background: var(--background-primary-alt); border-radius: 4px; margin-bottom: 6px; word-break: break-all; } .claudian-ask-approval-agent { color: var(--text-muted); font-size: 12px; margin-bottom: 6px; } .claudian-ask-approval-desc { padding: 8px 10px; background: var(--background-primary-alt); border-radius: 6px; font-family: var(--font-monospace); font-size: 12px; color: var(--text-normal); word-break: break-all; } ================================================ FILE: src/style/features/diff.css ================================================ /* Write/Edit Diff Block - Subagent style */ .claudian-write-edit-block { margin: 4px 0; background: transparent; overflow: hidden; } .claudian-text-block+.claudian-write-edit-block { margin-top: 8px; } .claudian-write-edit-block+.claudian-text-block { margin-top: 8px; } .claudian-write-edit-header { display: flex; align-items: center; gap: 8px; padding: 4px 0; cursor: pointer; user-select: none; background: transparent; overflow: hidden; } .claudian-write-edit-icon { width: 16px; height: 16px; color: var(--text-accent); flex-shrink: 0; } .claudian-write-edit-icon svg { width: 16px; height: 16px; } /* Two-part header: name (fixed) + summary (flexible) */ .claudian-write-edit-name { font-family: var(--font-monospace); font-size: 13px; font-weight: 400; color: var(--text-normal); white-space: nowrap; flex-shrink: 0; } .claudian-write-edit-summary { font-family: var(--font-monospace); font-size: 13px; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 1; min-width: 0; } .claudian-write-edit-stats { font-family: var(--font-monospace); font-size: 11px; flex-shrink: 0; } .claudian-write-edit-stats .added { color: var(--color-green); } .claudian-write-edit-stats .removed { color: var(--color-red); margin-left: 4px; } .claudian-write-edit-status { width: 16px; height: 16px; flex-shrink: 0; } /* Hide empty status on successful completion so stats align to right edge */ .claudian-write-edit-block.done .claudian-write-edit-status:empty { display: none; } .claudian-write-edit-status svg { width: 16px; height: 16px; } .claudian-write-edit-status.status-completed { color: var(--color-green); } .claudian-write-edit-status.status-error, .claudian-write-edit-status.status-blocked { color: var(--color-red); } .claudian-write-edit-content { padding: 0; background: transparent; overflow: hidden; } .claudian-write-edit-diff-row { display: flex; } .claudian-write-edit-diff { flex: 1; font-family: var(--font-monospace); font-size: 12px; line-height: 1.5; background: transparent; max-height: 300px; overflow-y: auto; overflow-x: auto; } .claudian-write-edit-loading, .claudian-write-edit-binary, .claudian-write-edit-error, .claudian-write-edit-done-text { font-family: var(--font-monospace); font-size: 12px; color: var(--text-muted); } .claudian-write-edit-error { color: var(--color-red); } /* Diff line styling */ .claudian-diff-hunk { margin-bottom: 4px; } .claudian-diff-line { display: flex; white-space: pre-wrap; word-break: break-all; } .claudian-diff-prefix { flex-shrink: 0; width: 16px; text-align: center; color: var(--text-muted); user-select: none; } .claudian-diff-text { flex: 1; min-width: 0; } /* Diff colors - NO strikethrough for Write/Edit blocks */ .claudian-diff-equal { color: var(--text-muted); } .claudian-diff-delete { background: rgba(255, 80, 80, 0.25); color: var(--text-normal); } .claudian-diff-delete .claudian-diff-prefix { color: var(--color-red); } .claudian-diff-insert { background: rgba(80, 200, 80, 0.25); color: var(--text-normal); } .claudian-diff-insert .claudian-diff-prefix { color: var(--color-green); } /* Hunk separator */ .claudian-diff-separator { color: var(--text-muted); text-align: center; padding: 4px 0; font-style: italic; border-top: 1px dashed var(--background-modifier-border); border-bottom: 1px dashed var(--background-modifier-border); margin: 8px 0; font-size: 11px; } .claudian-diff-no-changes { color: var(--text-muted); font-style: italic; padding: 8px; } ================================================ FILE: src/style/features/file-context.css ================================================ /* @ Mention dropdown */ .claudian-mention-dropdown { display: none; position: absolute; bottom: 100%; left: 0; right: 0; margin-bottom: 4px; background: var(--background-secondary); backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px); border: 1px solid var(--background-modifier-border); border-radius: 6px; box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.2); z-index: 1000; max-height: 250px; overflow-y: auto; } .claudian-mention-dropdown.visible { display: block; } /* Fixed positioning for inline editor */ .claudian-mention-dropdown-fixed { position: fixed; z-index: 10001; } .claudian-mention-item { display: flex; align-items: center; gap: 8px; padding: 8px 12px; cursor: pointer; transition: background 0.1s ease; } .claudian-mention-item:hover, .claudian-mention-item.selected { background: var(--background-modifier-hover); } .claudian-mention-icon { display: flex; align-items: center; color: var(--text-muted); flex-shrink: 0; } .claudian-mention-icon svg { width: 14px; height: 14px; } .claudian-mention-path { font-size: 13px; color: var(--text-normal); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .claudian-mention-empty { padding: 12px; text-align: center; color: var(--text-muted); font-size: 13px; } /* Scrollbar for mention dropdown */ .claudian-mention-dropdown::-webkit-scrollbar { width: 6px; } .claudian-mention-dropdown::-webkit-scrollbar-track { background: transparent; } .claudian-mention-dropdown::-webkit-scrollbar-thumb { background: var(--background-modifier-border); border-radius: 3px; } /* MCP items in @-mention dropdown */ .claudian-mention-item.mcp-server .claudian-mention-icon { color: var(--interactive-accent); } .claudian-mention-item.vault-folder .claudian-mention-icon { color: var(--text-muted); } .claudian-mention-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1; } .claudian-mention-name { font-size: 13px; font-weight: 500; color: var(--interactive-accent); } .claudian-mention-desc { font-size: 11px; color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } /* Context file items in @-mention dropdown */ .claudian-mention-item.context-file .claudian-mention-icon { color: var(--claudian-brand); } .claudian-mention-item.context-file .claudian-mention-text { flex-direction: row; overflow: hidden; white-space: nowrap; } .claudian-mention-item.context-file .claudian-mention-name-context { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; } /* Context folder filter items in @-mention dropdown */ .claudian-mention-item.context-folder .claudian-mention-icon { color: var(--text-muted); } .claudian-mention-name-folder { color: var(--text-normal); font-weight: 500; } .claudian-mention-name-context { color: var(--text-normal); } /* Agent folder items in @-mention dropdown */ .claudian-mention-item.agent-folder .claudian-mention-icon { color: var(--link-color); } .claudian-mention-name-agent-folder { color: var(--link-color); font-weight: 600; } /* Agent items in @-mention dropdown (inside @Agents/) */ .claudian-mention-item.agent .claudian-mention-icon { color: var(--link-color); } .claudian-mention-item.agent .claudian-mention-text { flex-direction: row; align-items: baseline; gap: 6px; overflow: hidden; white-space: nowrap; } .claudian-mention-item.agent .claudian-mention-name-agent { color: var(--text-normal); font-size: 13px; flex-shrink: 0; } .claudian-mention-item.agent .claudian-mention-agent-desc { font-size: 11px; color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; } ================================================ FILE: src/style/features/file-link.css ================================================ /* Clickable file links that open files in Obsidian */ .claudian-file-link { color: var(--text-accent); text-decoration: none; cursor: pointer; border-radius: 3px; transition: color 0.15s ease; } .claudian-file-link:hover { color: var(--text-accent-hover); text-decoration: underline; } /* File link inside inline code */ code .claudian-file-link { color: var(--text-accent); } code .claudian-file-link:hover { color: var(--text-accent-hover); } ================================================ FILE: src/style/features/image-context.css ================================================ /* Image Context - Preview & Attachments */ /* Image preview container (in input area) */ .claudian-image-preview { display: none; flex-wrap: wrap; gap: 8px; padding: 8px 0; margin-bottom: 4px; } /* Individual image preview chip */ .claudian-image-chip { display: flex; align-items: center; gap: 8px; padding: 6px 8px; background: var(--background-primary); border: 1px solid var(--background-modifier-border); border-radius: 8px; max-width: 200px; } .claudian-image-chip:hover { border-color: var(--interactive-accent); } /* Image thumbnail */ .claudian-image-thumb { width: 40px; height: 40px; border-radius: 4px; overflow: hidden; flex-shrink: 0; cursor: pointer; } .claudian-image-thumb img { width: 100%; height: 100%; object-fit: cover; } /* Image info */ .claudian-image-info { display: flex; flex-direction: column; min-width: 0; flex: 1; } .claudian-image-name { font-size: 12px; color: var(--text-normal); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .claudian-image-size { font-size: 10px; color: var(--text-muted); } /* Remove button */ .claudian-image-remove { position: relative; width: 20px; height: 20px; border-radius: 50%; cursor: pointer; transition: background 0.15s ease; flex-shrink: 0; font-size: 0; /* Hide text character */ } .claudian-image-remove::before, .claudian-image-remove::after { content: ''; position: absolute; top: 50%; left: 50%; width: 10px; height: 2px; background: var(--text-muted); border-radius: 1px; transition: background 0.15s ease; } .claudian-image-remove::before { transform: translate(-50%, -50%) rotate(45deg); } .claudian-image-remove::after { transform: translate(-50%, -50%) rotate(-45deg); } .claudian-image-remove:hover { background: var(--background-modifier-error); } .claudian-image-remove:hover::before, .claudian-image-remove:hover::after { background: var(--text-on-accent); } /* Drop overlay - inside input wrapper */ .claudian-drop-overlay { position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: rgba(var(--claudian-brand-rgb), 0.08); border: 2px dashed var(--claudian-brand); border-radius: 6px; display: none; align-items: center; justify-content: center; z-index: 100; pointer-events: none; } .claudian-drop-overlay.visible { display: flex; } .claudian-drop-content { display: flex; flex-direction: column; align-items: center; gap: 4px; color: var(--claudian-brand); } .claudian-drop-content svg { opacity: 0.7; } .claudian-drop-content span { font-size: 12px; font-weight: 500; } /* Images in Messages (displayed above user bubble) */ /* Images container - right-aligned above user message */ .claudian-message-images { display: flex; flex-wrap: wrap; gap: 6px; justify-content: flex-end; margin-bottom: 6px; padding-right: 4px; } /* Individual image in message - standardized size */ .claudian-message-image { width: 120px; height: 120px; border-radius: 8px; overflow: hidden; cursor: pointer; transition: transform 0.15s ease, box-shadow 0.15s ease; background: var(--background-secondary); border: 1px solid var(--background-modifier-border); } .claudian-message-image:hover { transform: scale(1.03); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); } .claudian-message-image img { width: 100%; height: 100%; object-fit: cover; display: block; } ================================================ FILE: src/style/features/image-embed.css ================================================ /* Image embed styles - displays ![[image.png]] wikilinks as actual images */ .claudian-embedded-image { display: inline-block; max-width: 100%; margin: 0.5em 0; vertical-align: middle; } .claudian-embedded-image img { max-width: 100%; height: auto; border-radius: var(--radius-s); cursor: pointer; transition: opacity 0.2s ease; } .claudian-embedded-image img:hover { opacity: 0.9; } /* When image is inline with text */ .claudian-text-block p .claudian-embedded-image { margin: 0.25em 0; } /* Block-level image (standalone on its own line) */ .claudian-text-block p > .claudian-embedded-image:only-child { display: block; margin: 0.75em 0; } /* Fallback when image file not found */ .claudian-embedded-image-fallback { color: var(--text-muted); font-style: italic; background: var(--background-modifier-hover); padding: 0.1em 0.4em; border-radius: var(--radius-s); } ================================================ FILE: src/style/features/image-modal.css ================================================ /* Full-size Image Modal */ .claudian-image-modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 0, 0, 0.85); display: flex; align-items: center; justify-content: center; z-index: 10000; cursor: pointer; } .claudian-image-modal { position: relative; max-width: 90vw; max-height: 90vh; cursor: default; } .claudian-image-modal img { max-width: 90vw; max-height: 90vh; object-fit: contain; border-radius: 8px; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); } .claudian-image-modal-close { position: absolute; top: -12px; right: -12px; width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; background: var(--background-secondary); border-radius: 50%; cursor: pointer; font-size: 20px; color: var(--text-muted); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); transition: background 0.15s ease, color 0.15s ease; } .claudian-image-modal-close:hover { background: var(--background-modifier-error); color: var(--text-on-accent); } ================================================ FILE: src/style/features/inline-edit.css ================================================ /* Inline Edit (CM6 decorations) */ /* Selection highlight (shared by inline edit and chat) */ .cm-line .claudian-selection-highlight, .claudian-selection-highlight { background: var(--text-selection) !important; border-radius: 2px; padding: 3px 0; margin: -3px 0; } /* CM6 widget container - ensure transparent background */ .cm-widgetBuffer, .cm-line:has(.claudian-inline-input-container) { background: transparent !important; } /* Input container - fully transparent */ .claudian-inline-input-container { display: flex; flex-direction: column; gap: 0; padding: 2px 0; background: transparent !important; } /* Input wrapper - contains input and spinner */ .claudian-inline-input-wrap { flex: 1; position: relative; background: transparent !important; overflow: visible; } /* Input - fully transparent */ .claudian-inline-input { width: 100%; padding: 4px 8px; padding-inline-end: 30px; border-width: 1px !important; border-style: solid !important; border-color: var(--background-modifier-border) !important; border-radius: 8px !important; background: transparent !important; color: var(--text-normal); font-family: var(--font-interface, -apple-system, BlinkMacSystemFont, sans-serif) !important; font-size: var(--font-ui-small, 13px) !important; } .claudian-inline-input:focus, .claudian-inline-input:focus-visible { outline: none !important; box-shadow: none !important; } .claudian-inline-input::placeholder { color: var(--text-faint); } .claudian-inline-input:disabled { opacity: 0.6; } /* Spinner - inside input box on end side */ .claudian-inline-spinner { position: absolute; inset-inline-end: 8px; top: 50%; width: 14px; height: 14px; margin-top: -7px; border: 2px solid var(--background-modifier-border); border-top-color: var(--claudian-brand); border-radius: 50%; box-sizing: border-box; animation: claudian-spin 0.8s linear infinite; } /* Agent reply - shown when agent asks clarifying question */ .claudian-inline-agent-reply { padding: 8px; margin-bottom: 4px; background: transparent; border-width: 1px; border-style: solid; border-color: var(--background-modifier-border); border-radius: 8px; font-family: var(--font-interface, -apple-system, BlinkMacSystemFont, sans-serif); font-size: var(--font-ui-small, 13px); line-height: 1.4; color: var(--text-muted); white-space: pre-wrap; word-wrap: break-word; } /* Inline Diff - replaces selection in place */ .claudian-inline-diff-replace { /* Inherit all font properties from document */ font-size: inherit; font-family: inherit; line-height: inherit; font-weight: inherit; } /* Deleted text - red strikethrough */ .claudian-diff-del { background: rgba(255, 80, 80, 0.2); text-decoration: line-through; color: var(--text-muted); } /* Inserted text - green background */ .claudian-diff-ins { background: rgba(80, 200, 80, 0.2); } /* Accept/Reject buttons inline with diff */ .claudian-inline-diff-buttons { display: inline-flex; gap: 8px; margin-inline-start: 6px; background: none !important; } .claudian-inline-diff-btn { padding: 4px 6px; border: none !important; background: none !important; box-shadow: none !important; outline: none !important; font-size: 16px; cursor: pointer; } .claudian-inline-diff-btn.reject { color: var(--color-red); } .claudian-inline-diff-btn.accept { color: var(--color-green); } ================================================ FILE: src/style/features/plan-mode.css ================================================ /* Plan Mode - inline cards for EnterPlanMode / ExitPlanMode */ .claudian-plan-approval-inline { font-family: var(--font-monospace); font-size: 12px; outline: none; } .claudian-plan-inline-title { font-weight: 700; color: var(--text-muted); padding: 6px 10px 0; } /* ── Plan content preview ────────────────────────── */ .claudian-plan-content-preview { max-height: 300px; overflow-y: auto; margin: 6px 10px 8px; padding: 8px 10px; border: 1px solid var(--background-modifier-border); border-radius: 4px; background: var(--background-primary); font-size: 12px; line-height: 1.5; color: var(--text-normal); } .claudian-plan-content-preview::-webkit-scrollbar { width: 4px; } .claudian-plan-content-preview::-webkit-scrollbar-thumb { background: var(--background-modifier-border); border-radius: 2px; } .claudian-plan-content-preview p { margin: 0 0 6px; } .claudian-plan-content-preview p:last-child { margin-bottom: 0; } .claudian-plan-content-preview h1, .claudian-plan-content-preview h2, .claudian-plan-content-preview h3, .claudian-plan-content-preview h4 { margin: 8px 0 4px; font-size: 13px; } .claudian-plan-content-preview ul, .claudian-plan-content-preview ol { margin: 2px 0 6px; padding-left: 18px; } .claudian-plan-content-preview li { margin: 0; } .claudian-plan-content-preview code { font-size: 11px; } .claudian-plan-content-text { white-space: pre-wrap; word-break: break-word; font-family: var(--font-monospace); } /* ── Permissions list ──────────────────────────── */ .claudian-plan-permissions { padding: 4px 10px 8px; } .claudian-plan-permissions-label { color: var(--text-muted); font-weight: 600; margin-bottom: 4px; } .claudian-plan-permissions-list { margin: 0; padding-left: 18px; color: var(--text-normal); line-height: 1.5; } .claudian-plan-permissions-list li { margin: 0; } /* ── Plan mode input border ──────────────────────── */ .claudian-input-wrapper.claudian-input-plan-mode { border-color: rgb(92, 148, 140) !important; box-shadow: 0 0 0 1px rgb(92, 148, 140); } ================================================ FILE: src/style/features/resume-session.css ================================================ /* Resume Session Dropdown */ .claudian-resume-dropdown { display: none; position: absolute; bottom: 100%; left: 0; right: 0; margin-bottom: 4px; background: var(--background-secondary); backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px); border: 1px solid var(--background-modifier-border); border-radius: 6px; box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.2); z-index: 1000; max-height: 400px; overflow: hidden; } .claudian-resume-dropdown.visible { display: block; } .claudian-resume-header { padding: 8px 12px; font-size: 12px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; border-bottom: 1px solid var(--background-modifier-border); } .claudian-resume-list { max-height: 350px; overflow-y: auto; } .claudian-resume-empty { padding: 16px; text-align: center; color: var(--text-muted); font-size: 13px; } .claudian-resume-item { display: flex; align-items: center; gap: 10px; padding: 8px 12px; cursor: pointer; border-bottom: 1px solid var(--background-modifier-border); transition: background 0.1s ease; } .claudian-resume-item:last-child { border-bottom: none; } .claudian-resume-item:hover, .claudian-resume-item.selected { background: var(--background-modifier-hover); } .claudian-resume-item.current { background: var(--background-secondary); border-inline-start: 2px solid var(--interactive-accent); padding-inline-start: 10px; } .claudian-resume-item-icon { display: flex; align-items: center; color: var(--text-muted); flex-shrink: 0; } .claudian-resume-item-icon svg { width: 16px; height: 16px; } .claudian-resume-item.current .claudian-resume-item-icon { color: var(--interactive-accent); } .claudian-resume-item-content { flex: 1; min-width: 0; } .claudian-resume-item-title { font-size: 13px; font-weight: 500; color: var(--text-normal); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .claudian-resume-item-date { font-size: 11px; color: var(--text-faint); margin-top: 2px; } /* Scrollbar */ .claudian-resume-list::-webkit-scrollbar { width: 6px; } .claudian-resume-list::-webkit-scrollbar-track { background: transparent; } .claudian-resume-list::-webkit-scrollbar-thumb { background: var(--background-modifier-border); border-radius: 3px; } ================================================ FILE: src/style/features/slash-commands.css ================================================ /* Slash Command Dropdown */ .claudian-slash-dropdown { display: none; position: absolute; bottom: 100%; left: 0; right: 0; margin-bottom: 4px; background: var(--background-secondary); backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px); border: 1px solid var(--background-modifier-border); border-radius: 6px; box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.2); z-index: 1000; max-height: 300px; overflow-y: auto; } .claudian-slash-dropdown.visible { display: block; } /* Fixed positioning for inline editor */ .claudian-slash-dropdown-fixed { position: fixed; z-index: 10001; } .claudian-slash-item { padding: 8px 12px; cursor: pointer; transition: background 0.1s ease; border-bottom: 1px solid var(--background-modifier-border); } .claudian-slash-item:last-child { border-bottom: none; } .claudian-slash-item:hover, .claudian-slash-item.selected { background: var(--background-modifier-hover); } .claudian-slash-name { font-size: 12px; font-weight: 500; color: var(--text-normal); font-family: var(--font-monospace); } .claudian-slash-hint { font-size: 12px; color: var(--text-muted); margin-left: 8px; } .claudian-slash-desc { font-size: 11px; color: var(--text-muted); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .claudian-slash-empty { padding: 16px; text-align: center; color: var(--text-muted); font-size: 13px; } /* Scrollbar */ .claudian-slash-dropdown::-webkit-scrollbar { width: 6px; } .claudian-slash-dropdown::-webkit-scrollbar-track { background: transparent; } .claudian-slash-dropdown::-webkit-scrollbar-thumb { background: var(--background-modifier-border); border-radius: 3px; } ================================================ FILE: src/style/index.css ================================================ /* CSS module order. scripts/build-css.mjs reads these @import lines. */ /* Add new modules here to include them in the build. */ /* Base */ @import "./base/variables.css"; @import "./base/container.css"; @import "./base/animations.css"; /* Components */ @import "./components/header.css"; @import "./components/tabs.css"; @import "./components/history.css"; @import "./components/messages.css"; @import "./components/nav-sidebar.css"; @import "./components/code.css"; @import "./components/thinking.css"; @import "./components/toolcalls.css"; @import "./components/status-panel.css"; @import "./components/subagent.css"; @import "./components/input.css"; @import "./components/context-footer.css"; /* Toolbar */ @import "./toolbar/model-selector.css"; @import "./toolbar/thinking-selector.css"; @import "./toolbar/permission-toggle.css"; @import "./toolbar/external-context.css"; @import "./toolbar/mcp-selector.css"; /* Features */ @import "./features/file-context.css"; @import "./features/file-link.css"; @import "./features/image-context.css"; @import "./features/image-embed.css"; @import "./features/image-modal.css"; @import "./features/inline-edit.css"; @import "./features/diff.css"; @import "./features/slash-commands.css"; @import "./features/resume-session.css"; @import "./features/ask-user-question.css"; @import "./features/plan-mode.css"; /* Modals */ @import "./modals/instruction.css"; @import "./modals/mcp-modal.css"; @import "./modals/fork-target.css"; /* Settings */ @import "./settings/base.css"; @import "./settings/env-snippets.css"; @import "./settings/slash-settings.css"; @import "./settings/mcp-settings.css"; @import "./settings/plugin-settings.css"; @import "./settings/agent-settings.css"; /* Accessibility */ @import "./accessibility.css"; ================================================ FILE: src/style/modals/fork-target.css ================================================ /* Fork Target Modal */ .claudian-fork-target-modal { max-width: 340px; } .claudian-fork-target-list { display: flex; flex-direction: column; } .claudian-fork-target-option { padding: 10px 12px; border-radius: 6px; cursor: pointer; color: var(--text-normal); font-size: 14px; } .claudian-fork-target-option:hover { background: var(--background-modifier-hover); } ================================================ FILE: src/style/modals/instruction.css ================================================ /* Instruction Mode */ /* Instruction Confirm Modal */ .claudian-instruction-modal { max-width: 500px; } .claudian-instruction-section { margin-bottom: 16px; } .claudian-instruction-label { font-size: 12px; font-weight: 500; color: var(--text-muted); margin-bottom: 6px; } .claudian-instruction-original { padding: 10px 12px; background: var(--background-secondary); border-radius: 6px; font-size: 13px; color: var(--text-muted); font-style: italic; white-space: pre-wrap; } .claudian-instruction-refined { padding: 12px; background: var(--background-primary-alt); border: 1px solid var(--background-modifier-border); border-radius: 6px; font-size: 14px; color: var(--text-normal); line-height: 1.5; white-space: pre-wrap; } .claudian-instruction-clarification { padding: 12px; background: var(--background-primary-alt); border: 1px solid var(--background-modifier-border); border-radius: 6px; font-size: 14px; color: var(--text-normal); line-height: 1.5; white-space: pre-wrap; } .claudian-instruction-edit-container { margin-top: 6px; } .claudian-instruction-edit-textarea { width: 100%; padding: 10px 12px; font-size: 14px; line-height: 1.5; border: 1px solid var(--background-modifier-border); border-radius: 6px; background: var(--background-primary); color: var(--text-normal); resize: vertical; min-height: 80px; } .claudian-instruction-edit-textarea:focus { outline: none; border-color: var(--interactive-accent); } .claudian-instruction-response-textarea { width: 100%; padding: 10px 12px; font-size: 14px; line-height: 1.5; border: 1px solid var(--background-modifier-border); border-radius: 6px; background: var(--background-primary); color: var(--text-normal); resize: vertical; min-height: 60px; } .claudian-instruction-response-textarea:focus { outline: none; border-color: var(--interactive-accent); } .claudian-instruction-buttons { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; } .claudian-instruction-btn { padding: 8px 16px; font-size: 13px; border: none; border-radius: 6px; cursor: pointer; font-weight: 500; } .claudian-instruction-reject-btn { background: var(--background-modifier-border); color: var(--text-normal); } .claudian-instruction-reject-btn:hover { background: var(--background-modifier-border-hover); } .claudian-instruction-edit-btn { background: var(--background-modifier-border); color: var(--text-normal); } .claudian-instruction-edit-btn:hover { background: var(--background-modifier-border-hover); } .claudian-instruction-accept-btn { background: var(--interactive-accent); color: var(--text-on-accent); } .claudian-instruction-accept-btn:hover { opacity: 0.9; } /* Instruction loading state */ .claudian-instruction-loading { display: flex; align-items: center; justify-content: center; gap: 10px; padding: 20px; color: var(--text-muted); } .claudian-instruction-spinner { width: 18px; height: 18px; border: 2px solid var(--background-modifier-border); border-top-color: var(--interactive-accent); border-radius: 50%; animation: claudian-spin 0.8s linear infinite; } /* Instruction modal content sections */ .claudian-instruction-content-section { margin: 8px 0; } .claudian-instruction-clarification-section, .claudian-instruction-confirmation-section { margin-top: 8px; } ================================================ FILE: src/style/modals/mcp-modal.css ================================================ /* MCP Server Modal */ .claudian-mcp-modal .modal-content { width: 480px; max-width: 90vw; } .claudian-mcp-type-fields { margin: 12px 0; padding: 12px; background: var(--background-secondary); border-radius: 6px; } .claudian-mcp-type-fields .setting-item { padding: 8px 0; border-top: none; } .claudian-mcp-type-fields .setting-item:first-child { padding-top: 0; } .claudian-mcp-cmd-setting, .claudian-mcp-env-setting { flex-direction: column; align-items: flex-start; } .claudian-mcp-cmd-setting .setting-item-control, .claudian-mcp-env-setting .setting-item-control { width: 100%; margin-top: 8px; } .claudian-mcp-cmd-textarea, .claudian-mcp-env-textarea { width: 100%; min-height: 50px; resize: vertical; font-family: var(--font-monospace); font-size: 12px; padding: 8px; border-radius: 4px; border: 1px solid var(--background-modifier-border); background: var(--background-primary); } .claudian-mcp-cmd-textarea:focus, .claudian-mcp-env-textarea:focus { border-color: var(--interactive-accent); outline: none; } .claudian-mcp-buttons { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; } /* MCP Test Modal */ .claudian-mcp-test-modal { width: 500px; max-width: 90vw; } .claudian-mcp-test-modal .modal-content { padding: 0 20px 20px 20px; } .claudian-mcp-test-modal .modal-title { padding: 20px 20px 12px 20px; margin: 0 -20px; } .claudian-mcp-test-loading { display: flex; align-items: center; justify-content: center; gap: 12px; padding: 32px; color: var(--text-muted); } .claudian-mcp-test-spinner { width: 20px; height: 20px; animation: claudian-spin 1s linear infinite; } .claudian-mcp-test-spinner svg { width: 100%; height: 100%; } .claudian-mcp-test-status { display: flex; align-items: center; gap: 10px; padding: 12px; background: var(--background-secondary); border-radius: 6px; margin-bottom: 12px; } .claudian-mcp-test-icon { display: flex; align-items: center; } .claudian-mcp-test-icon svg { width: 20px; height: 20px; } .claudian-mcp-test-icon.success { color: var(--color-green); } .claudian-mcp-test-icon.error { color: var(--text-error); } .claudian-mcp-test-text { font-weight: 500; } .claudian-mcp-test-error { padding: 10px 12px; background: rgba(var(--color-red-rgb), 0.1); border: 1px solid var(--text-error); border-radius: 6px; color: var(--text-error); font-size: 12px; margin-bottom: 12px; } .claudian-mcp-test-tools { margin-bottom: 16px; } .claudian-mcp-test-tools-header { font-weight: 600; font-size: 13px; margin-bottom: 8px; color: var(--text-muted); } .claudian-mcp-test-tools-list { display: flex; flex-direction: column; gap: 8px; max-height: 300px; overflow-y: auto; } .claudian-mcp-test-tool { padding: 10px 12px; background: var(--background-secondary); border-radius: 6px; } .claudian-mcp-test-tool-header { display: flex; align-items: center; gap: 8px; } .claudian-mcp-test-tool-icon { display: flex; align-items: center; color: var(--text-muted); } .claudian-mcp-test-tool-icon svg { width: 14px; height: 14px; } .claudian-mcp-test-tool-name { font-weight: 500; font-size: 13px; } .claudian-mcp-test-tool-toggle { margin-left: auto; } .claudian-mcp-test-tool-toggle .checkbox-container { display: flex; align-items: center; } .claudian-mcp-test-tool-disabled { opacity: 0.75; } .claudian-mcp-test-tool-disabled .claudian-mcp-test-tool-name { text-decoration: line-through; color: var(--text-muted); } .claudian-mcp-toggle-all-btn { margin-right: 8px; } .claudian-mcp-toggle-all-btn.is-destructive { background: rgba(var(--color-red-rgb), 0.1); border-color: rgba(var(--color-red-rgb), 0.3); color: var(--text-error); } .claudian-mcp-toggle-all-btn.is-destructive:hover { background: rgba(var(--color-red-rgb), 0.2); } .claudian-mcp-test-tool-desc { font-size: 12px; color: var(--text-muted); margin-top: 4px; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; } .claudian-mcp-test-no-tools { padding: 16px; text-align: center; color: var(--text-muted); font-size: 13px; background: var(--background-secondary); border-radius: 6px; margin-bottom: 16px; } .claudian-mcp-test-buttons { display: flex; justify-content: center; margin-top: 16px; } ================================================ FILE: src/style/settings/agent-settings.css ================================================ /* Agent Settings — all structural rules live in base.css .claudian-sp-* */ /* This file is kept as a placeholder for future agent-specific overrides. */ ================================================ FILE: src/style/settings/base.css ================================================ /* Settings page - remove separator lines from setting items */ .claudian-settings .setting-item { border-top: none; } /* Settings section headings (via setHeading()) */ .claudian-settings .setting-item-heading { padding-top: 18px; margin-top: 12px; border-top: 1px solid var(--background-modifier-border); } .claudian-settings .setting-item-heading:first-child { padding-top: 0; margin-top: 0; border-top: none; } .claudian-settings .setting-item-heading .setting-item-name { font-size: var(--font-ui-medium); font-weight: var(--font-semibold); color: var(--text-normal); } /* Custom section descriptions - align with items */ .claudian-sp-settings-desc, .claudian-mcp-settings-desc, .claudian-plugin-settings-desc, .claudian-approved-desc { padding: 0 12px; } /* Unified icon action buttons for settings */ .claudian-settings-action-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; padding: 0; border: none; background: transparent; border-radius: 4px; cursor: pointer; color: var(--text-muted); transition: background 0.15s ease, color 0.15s ease; } .claudian-settings-action-btn:hover { background: var(--background-modifier-hover); color: var(--text-normal); } .claudian-settings-action-btn svg { width: 14px; height: 14px; } .claudian-settings-delete-btn:hover { color: var(--text-error); } /* Hotkey grid - 3 columns */ .claudian-hotkey-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 4px 12px; padding: 4px 0; } .claudian-hotkey-item { display: flex; align-items: center; gap: 8px; padding: 6px 12px; cursor: pointer; border-radius: 6px; } .claudian-hotkey-item:hover { background: var(--background-modifier-hover); } .claudian-hotkey-name { flex: 1; color: var(--text-normal); font-size: var(--font-ui-small); } .claudian-hotkey-badge { color: var(--text-muted); font-size: var(--font-ui-smaller); background: var(--background-modifier-hover); padding: 2px 6px; border-radius: 4px; font-family: var(--font-monospace); } /* Media folder input width */ .claudian-settings-media-input { width: 200px; } /* ── Shared settings panel layout (used by slash-settings + agent-settings) ── */ .claudian-sp-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; padding: 0 12px; } .claudian-sp-label { font-size: var(--font-ui-small); color: var(--text-muted); font-weight: var(--font-medium); } .claudian-sp-header-actions { display: flex; gap: 4px; } .claudian-sp-empty-state { padding: 20px; text-align: center; color: var(--text-muted); background: var(--background-secondary); border-radius: 6px; margin-top: 8px; } .claudian-sp-list { display: flex; flex-direction: column; gap: 8px; margin-top: 8px; } .claudian-sp-item { display: flex; align-items: flex-start; justify-content: space-between; padding: 12px; background: var(--background-secondary); border-radius: 6px; } .claudian-sp-item:hover { background: var(--background-modifier-hover); } .claudian-sp-info { flex: 1; min-width: 0; } .claudian-sp-item-header { display: flex; align-items: baseline; gap: 8px; } .claudian-sp-item-name { font-weight: 600; font-family: var(--font-monospace); color: var(--text-normal); } .claudian-sp-item-desc { font-size: 13px; color: var(--text-muted); margin-top: 2px; } .claudian-sp-item-actions { display: flex; gap: 4px; margin-left: 16px; flex-shrink: 0; } .claudian-sp-advanced-section { border: 1px solid var(--background-modifier-border); border-radius: 6px; padding: 0 12px; margin: 8px 0; } .claudian-sp-advanced-summary { cursor: pointer; padding: 8px 0; font-size: var(--font-ui-small); color: var(--text-muted); font-weight: var(--font-medium); } .claudian-sp-advanced-section[open] .claudian-sp-advanced-summary { margin-bottom: 4px; } .claudian-sp-modal .modal-content { max-width: 600px; width: auto; } .claudian-sp-content-area { width: 100%; font-family: var(--font-monospace); font-size: 13px; padding: 8px; border: 1px solid var(--background-modifier-border); border-radius: 4px; background: var(--background-primary); color: var(--text-normal); resize: vertical; margin-top: 8px; } .claudian-sp-content-area:focus { outline: none; border-color: var(--interactive-accent); } .claudian-sp-modal-buttons { display: flex; gap: 8px; margin-top: 16px; justify-content: flex-end; } ================================================ FILE: src/style/settings/env-snippets.css ================================================ /* Context Limits Styles */ .claudian-context-limits-container { margin-top: 16px; } .claudian-context-limits-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; margin-top: 16px; padding: 0 12px; } .claudian-context-limits-label { font-size: var(--font-ui-small); color: var(--text-muted); font-weight: var(--font-medium); } .claudian-context-limits-desc { font-size: var(--font-ui-smaller); color: var(--text-muted); padding: 0 12px; margin-bottom: 8px; } .claudian-context-limits-list { display: flex; flex-direction: column; gap: 8px; margin-top: 8px; } .claudian-context-limits-item { display: flex; align-items: center; justify-content: space-between; padding: 12px; background: var(--background-secondary); border-radius: 6px; transition: background-color 0.2s; } .claudian-context-limits-item:hover { background: var(--background-modifier-hover); } .claudian-context-limits-model { font-family: var(--font-monospace); font-size: var(--font-ui-small); color: var(--text-normal); flex: 1; min-width: 0; word-break: break-all; } .claudian-context-limits-input-wrapper { display: flex; flex-direction: column; align-items: flex-end; margin-left: 16px; flex-shrink: 0; } .claudian-context-limits-input { width: 80px; padding: 4px 8px; font-size: var(--font-ui-small); border: 1px solid var(--background-modifier-border); border-radius: 4px; background: var(--background-primary); color: var(--text-normal); } .claudian-context-limits-input:focus { border-color: var(--interactive-accent); outline: none; } .claudian-context-limits-input.claudian-input-error { border-color: var(--text-error); } .claudian-context-limit-validation { display: none; font-size: var(--font-ui-smaller); color: var(--text-error); margin-top: 4px; } /* Environment Snippets Styles */ .claudian-env-snippets-container { margin-top: 16px; } .claudian-snippet-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; margin-top: 16px; padding: 0 12px; } .claudian-snippet-label { font-size: var(--font-ui-small); color: var(--text-muted); font-weight: var(--font-medium); } .claudian-save-env-btn { padding: 6px 16px; font-size: 13px; background: var(--interactive-accent); color: var(--text-on-accent); border: none; border-radius: 4px; cursor: pointer; transition: opacity 0.2s; } .claudian-save-env-btn:hover { opacity: 0.9; } .claudian-snippet-empty { padding: 20px; text-align: center; color: var(--text-muted); background: var(--background-secondary); border-radius: 6px; margin-top: 8px; } .claudian-snippet-list { display: flex; flex-direction: column; gap: 8px; margin-top: 8px; } .claudian-snippet-item { display: flex; align-items: center; justify-content: space-between; padding: 12px; background: var(--background-secondary); border-radius: 6px; transition: background-color 0.2s; } .claudian-snippet-item:hover { background: var(--background-modifier-hover); } .claudian-snippet-info { flex: 1; min-width: 0; } .claudian-snippet-name { font-weight: 600; margin-bottom: 4px; word-break: break-word; } .claudian-snippet-description { font-size: 13px; color: var(--text-muted); } .claudian-snippet-actions { display: flex; gap: 4px; margin-left: 16px; flex-shrink: 0; } .claudian-restore-snippet-btn { padding: 4px 12px; font-size: 12px; background: var(--interactive-accent); color: var(--text-on-accent); border: none; border-radius: 4px; cursor: pointer; } .claudian-restore-snippet-btn:hover { opacity: 0.9; } .claudian-edit-snippet-btn { padding: 4px 12px; font-size: 12px; background: var(--background-modifier-border); color: var(--text-normal); border: none; border-radius: 4px; cursor: pointer; } .claudian-edit-snippet-btn:hover { background: var(--background-modifier-border-hover); } .claudian-delete-snippet-btn { padding: 4px 12px; font-size: 12px; background: var(--background-modifier-error); color: var(--text-on-accent); border: none; border-radius: 4px; cursor: pointer; } .claudian-delete-snippet-btn:hover { opacity: 0.9; } /* Env Snippet Modal */ .claudian-env-snippet-modal .modal-content { max-width: 550px; width: 550px; padding: 16px; } .claudian-env-snippet-modal h2 { margin: 0 0 16px 0; } .claudian-env-snippet-modal .setting-item { padding: 8px 0; margin: 0; } .claudian-env-snippet-modal .setting-item-info { margin-bottom: 4px; } /* Full-width env vars textarea setting */ .claudian-env-snippet-setting { flex-direction: column; align-items: flex-start; } .claudian-env-snippet-setting .setting-item-info { width: 100%; margin-bottom: 8px; } .claudian-env-snippet-control { width: 100%; } .claudian-env-snippet-control textarea { width: 100%; min-width: 100%; font-family: var(--font-monospace); font-size: 12px; resize: vertical; } .claudian-snippet-preview { margin: 8px 0; padding: 6px; background: var(--background-secondary); border-radius: 6px; } .claudian-env-preview { background: var(--background-primary); padding: 6px; border-radius: 4px; font-family: var(--font-monospace); font-size: 11px; line-height: 1.3; white-space: pre-wrap; word-break: break-all; color: var(--text-muted); max-height: 120px; overflow-y: auto; margin: 0; } .claudian-snippet-buttons { display: flex; gap: 8px; margin-top: 16px; justify-content: flex-end; } .claudian-cancel-btn, .claudian-save-btn { padding: 6px 16px; font-size: 13px; border: none; border-radius: 4px; cursor: pointer; } .claudian-cancel-btn { background: var(--background-modifier-border); color: var(--text-normal); } .claudian-cancel-btn:hover { background: var(--background-modifier-border-hover); } .claudian-save-btn { background: var(--interactive-accent); color: var(--text-on-accent); } .claudian-save-btn:hover { opacity: 0.9; } /* Context limits section in snippet modal */ .claudian-snippet-context-limits { margin-top: 1em; } .claudian-snippet-context-limits .setting-item-description { margin-bottom: 0.5em; } .claudian-snippet-limit-row { display: flex; align-items: center; gap: 0.5em; margin-bottom: 0.25em; } .claudian-snippet-limit-model { font-family: var(--font-monospace); font-size: var(--font-ui-small); } .claudian-snippet-limit-spacer { flex: 1; } .claudian-snippet-limit-input { width: 80px; } ================================================ FILE: src/style/settings/mcp-settings.css ================================================ /* MCP Server Settings */ .claudian-mcp-settings-desc { margin-bottom: 12px; } .claudian-mcp-container { margin-top: 8px; } .claudian-mcp-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; padding: 0 12px; } .claudian-mcp-label { font-size: var(--font-ui-small); color: var(--text-muted); font-weight: var(--font-medium); } .claudian-mcp-add-container { position: relative; } .claudian-add-mcp-btn { padding: 4px 12px; border-radius: 4px; background: var(--interactive-accent); color: var(--text-on-accent); font-size: 12px; cursor: pointer; border: none; } .claudian-add-mcp-btn:hover { background: var(--interactive-accent-hover); } .claudian-mcp-add-dropdown { display: none; position: absolute; top: 100%; right: 0; margin-top: 4px; min-width: 180px; background-color: var(--modal-background, var(--background-primary)); border: 1px solid var(--background-modifier-border); border-radius: 6px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); z-index: 100; overflow: hidden; } .claudian-mcp-add-dropdown.is-visible { display: block; } .claudian-mcp-add-option { display: flex; align-items: center; gap: 8px; padding: 8px 12px; cursor: pointer; font-size: 13px; color: var(--text-normal); } .claudian-mcp-add-option:hover { background: var(--background-modifier-hover); } .claudian-mcp-add-option-icon { display: flex; align-items: center; color: var(--text-muted); } .claudian-mcp-add-option-icon svg { width: 16px; height: 16px; } .claudian-mcp-empty { padding: 16px; text-align: center; color: var(--text-muted); font-size: 13px; background: var(--background-secondary); border-radius: 6px; } .claudian-mcp-list { display: flex; flex-direction: column; gap: 8px; } .claudian-mcp-item { display: flex; align-items: flex-start; gap: 10px; padding: 10px 12px; background: var(--background-secondary); border-radius: 6px; transition: background 0.15s ease; } .claudian-mcp-item:hover { background: var(--background-modifier-hover); } .claudian-mcp-item-disabled { opacity: 0.6; } .claudian-mcp-status { width: 8px; height: 8px; border-radius: 50%; margin-top: 6px; flex-shrink: 0; } .claudian-mcp-status-enabled { background: var(--color-green); } .claudian-mcp-status-disabled { background: var(--text-muted); } .claudian-mcp-info { flex: 1; min-width: 0; } .claudian-mcp-name-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } .claudian-mcp-name { font-weight: 600; } .claudian-mcp-type-badge { font-size: 10px; padding: 2px 6px; background: var(--background-modifier-border); border-radius: 4px; color: var(--text-muted); text-transform: uppercase; } .claudian-mcp-context-saving-badge { font-size: 11px; padding: 2px 6px; background: var(--interactive-accent); color: var(--text-on-accent); border-radius: 4px; font-weight: 600; } .claudian-mcp-preview { font-size: 12px; color: var(--text-muted); margin-top: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .claudian-mcp-actions { display: flex; gap: 4px; flex-shrink: 0; } .claudian-mcp-action-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; padding: 0; border: none; background: transparent; border-radius: 4px; cursor: pointer; color: var(--text-muted); transition: background 0.15s ease, color 0.15s ease; } .claudian-mcp-action-btn:hover { background: var(--background-modifier-hover); color: var(--text-normal); } .claudian-mcp-action-btn svg { width: 14px; height: 14px; } .claudian-mcp-delete-btn:hover { color: var(--text-error); } ================================================ FILE: src/style/settings/plugin-settings.css ================================================ /* Plugin Settings */ .claudian-plugin-settings-desc { margin-bottom: 12px; } .claudian-plugins-container { margin-top: 8px; } .claudian-plugin-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; padding: 0 12px; } .claudian-plugin-label { font-size: var(--font-ui-small); color: var(--text-muted); font-weight: var(--font-medium); } .claudian-plugin-empty { padding: 16px; text-align: center; color: var(--text-muted); font-size: 13px; background: var(--background-secondary); border-radius: 6px; } .claudian-plugin-list { display: flex; flex-direction: column; gap: 8px; } .claudian-plugin-section-header { font-size: 11px; color: var(--text-muted); text-transform: uppercase; padding: 8px 12px 4px; font-weight: 600; } .claudian-plugin-item { display: flex; align-items: flex-start; gap: 10px; padding: 10px 12px; background: var(--background-secondary); border-radius: 6px; transition: background 0.15s ease; } .claudian-plugin-item:hover { background: var(--background-modifier-hover); } .claudian-plugin-item-disabled { opacity: 0.6; } .claudian-plugin-item-error { opacity: 0.8; } .claudian-plugin-status { width: 8px; height: 8px; border-radius: 50%; margin-top: 6px; flex-shrink: 0; } .claudian-plugin-status-enabled { background: var(--color-green); } .claudian-plugin-status-disabled { background: var(--text-muted); } .claudian-plugin-status-error { background: var(--text-error); } .claudian-plugin-info { flex: 1; min-width: 0; } .claudian-plugin-name-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } .claudian-plugin-name { font-weight: 600; } .claudian-plugin-version-badge { font-size: 10px; padding: 2px 6px; background: var(--background-modifier-border); border-radius: 4px; color: var(--text-muted); } .claudian-plugin-error-badge { font-size: 10px; padding: 2px 6px; background: var(--text-error); color: var(--text-on-accent); border-radius: 4px; text-transform: uppercase; } .claudian-plugin-preview { font-size: 12px; color: var(--text-muted); margin-top: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .claudian-plugin-preview-error { color: var(--text-error); } .claudian-plugin-actions { display: flex; gap: 4px; flex-shrink: 0; } .claudian-plugin-action-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; padding: 0; border: none; background: transparent; border-radius: 4px; cursor: pointer; color: var(--text-muted); transition: background 0.15s ease, color 0.15s ease; } .claudian-plugin-action-btn:hover { background: var(--background-modifier-hover); color: var(--text-normal); } .claudian-plugin-action-btn svg { width: 14px; height: 14px; } ================================================ FILE: src/style/settings/slash-settings.css ================================================ /* Slash Command Settings — unique rules only (shared layout in base.css .claudian-sp-*) */ .claudian-slash-item-hint { font-size: 12px; color: var(--text-muted); font-style: italic; } .claudian-slash-item-badge { font-size: 10px; padding: 2px 6px; background: var(--background-modifier-border); border-radius: 4px; color: var(--text-muted); text-transform: uppercase; } ================================================ FILE: src/style/toolbar/external-context.css ================================================ /* External Context Selector */ .claudian-external-context-selector { position: relative; display: flex; align-items: center; margin-left: 8px; } .claudian-external-context-icon-wrapper { position: relative; display: flex; align-items: center; justify-content: center; cursor: pointer; } .claudian-external-context-icon { display: flex; align-items: center; justify-content: center; width: 24px; height: 24px; color: var(--text-faint); transition: color 0.15s ease; } .claudian-external-context-icon-wrapper:hover .claudian-external-context-icon { color: var(--text-normal); } .claudian-external-context-icon.active { color: var(--claudian-brand); animation: external-context-glow 2s ease-in-out infinite; } .claudian-external-context-icon svg { width: 16px; height: 16px; } .claudian-external-context-badge { position: absolute; top: 0; right: 0; font-size: 9px; font-weight: 600; color: var(--claudian-brand); opacity: 0; transition: opacity 0.15s ease; pointer-events: none; } .claudian-external-context-badge.visible { opacity: 1; } .claudian-external-context-dropdown { position: absolute; left: 50%; transform: translateX(-50%); bottom: 100%; margin-bottom: 4px; min-width: 260px; max-width: 320px; background: var(--background-secondary); border: 1px solid var(--background-modifier-border); border-radius: 8px; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25); opacity: 0; visibility: hidden; transition: opacity 0.15s ease, visibility 0.15s ease; z-index: 100; } .claudian-external-context-selector:hover .claudian-external-context-dropdown { opacity: 1; visibility: visible; } .claudian-external-context-header { padding: 10px 12px; font-size: 12px; font-weight: 600; color: var(--text-muted); border-bottom: 1px solid var(--background-modifier-border); } .claudian-external-context-list { max-height: 200px; overflow-y: auto; } .claudian-external-context-empty { padding: 16px 12px; text-align: center; color: var(--text-muted); font-size: 12px; font-style: italic; } .claudian-external-context-item { display: flex; align-items: center; padding: 8px 12px; gap: 8px; border-bottom: 1px solid var(--background-modifier-border-focus); } .claudian-external-context-item:last-child { border-bottom: none; } .claudian-external-context-text { flex: 1; font-size: 12px; font-family: var(--font-monospace); color: var(--text-normal); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .claudian-external-context-lock { display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: 4px; cursor: pointer; color: var(--text-muted); opacity: 0.4; transition: all 0.15s ease; } .claudian-external-context-lock:hover { background: var(--background-modifier-hover); opacity: 0.8; } .claudian-external-context-lock.locked { color: var(--claudian-brand); opacity: 0.9; } .claudian-external-context-lock.locked:hover { opacity: 1; } .claudian-external-context-lock svg { width: 12px; height: 12px; } .claudian-external-context-remove { display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: 4px; cursor: pointer; color: var(--text-muted); opacity: 0.6; transition: all 0.15s ease; } .claudian-external-context-remove:hover { background: rgba(var(--claudian-error-rgb), 0.15); color: var(--claudian-error); opacity: 1; } .claudian-external-context-remove svg { width: 14px; height: 14px; } ================================================ FILE: src/style/toolbar/mcp-selector.css ================================================ /* MCP Server Selector */ .claudian-mcp-selector { position: relative; display: flex; align-items: center; margin-left: 8px; } .claudian-mcp-selector-icon-wrapper { position: relative; display: flex; align-items: center; justify-content: center; cursor: pointer; } .claudian-mcp-selector-icon { display: flex; align-items: center; justify-content: center; width: 24px; height: 24px; color: var(--text-faint); transition: color 0.15s ease; } .claudian-mcp-selector-icon-wrapper:hover .claudian-mcp-selector-icon { color: var(--text-normal); } .claudian-mcp-selector-icon.active { color: var(--claudian-brand); animation: mcp-glow 2s ease-in-out infinite; } .claudian-mcp-selector-icon svg { width: 16px; height: 16px; } .claudian-mcp-selector-badge { position: absolute; top: 0; right: 0; font-size: 9px; font-weight: 600; color: var(--claudian-brand); opacity: 0; transition: opacity 0.15s ease; pointer-events: none; } .claudian-mcp-selector-badge.visible { opacity: 1; } .claudian-mcp-selector-dropdown { position: absolute; left: 50%; transform: translateX(-50%); bottom: 100%; margin-bottom: 4px; min-width: 200px; max-width: 280px; background: var(--background-secondary); border: 1px solid var(--background-modifier-border); border-radius: 8px; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25); opacity: 0; visibility: hidden; transition: opacity 0.15s ease, visibility 0.15s ease; z-index: 100; } /* Bridge the gap between icon and dropdown to prevent hover breaks */ .claudian-mcp-selector-dropdown::after { content: ''; position: absolute; left: 0; right: 0; bottom: -8px; height: 8px; } .claudian-mcp-selector-dropdown.visible, .claudian-mcp-selector:hover .claudian-mcp-selector-dropdown { opacity: 1; visibility: visible; } .claudian-mcp-selector-header { padding: 10px 12px; font-size: 12px; font-weight: 600; color: var(--text-muted); border-bottom: 1px solid var(--background-modifier-border); } .claudian-mcp-selector-list { max-height: 200px; overflow-y: auto; } .claudian-mcp-selector-empty { padding: 16px 12px; text-align: center; color: var(--text-muted); font-size: 12px; font-style: italic; } .claudian-mcp-selector-item { display: flex; align-items: center; padding: 8px 12px; gap: 8px; cursor: pointer; transition: background 0.15s ease; } .claudian-mcp-selector-item:hover { background: var(--background-modifier-hover); } .claudian-mcp-selector-item.enabled { background: rgba(var(--claudian-brand-rgb), 0.1); } .claudian-mcp-selector-check { display: flex; align-items: center; justify-content: center; width: 16px; height: 16px; border: 1px solid var(--background-modifier-border); border-radius: 3px; color: var(--claudian-brand); } .claudian-mcp-selector-item.enabled .claudian-mcp-selector-check { background: rgba(var(--claudian-brand-rgb), 0.2); border-color: var(--claudian-brand); } .claudian-mcp-selector-check svg { width: 12px; height: 12px; } .claudian-mcp-selector-item-info { display: flex; align-items: center; justify-content: space-between; gap: 6px; flex: 1; overflow: hidden; } .claudian-mcp-selector-item-name { font-size: 12px; color: var(--text-normal); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .claudian-mcp-selector-cs-badge { font-size: 10px; font-weight: 600; padding: 1px 4px; border-radius: 3px; background: rgba(var(--claudian-brand-rgb), 0.2); color: var(--claudian-brand); flex-shrink: 0; margin-left: auto; } ================================================ FILE: src/style/toolbar/model-selector.css ================================================ /* Model selector */ .claudian-model-selector { position: relative; } .claudian-model-btn { display: flex; align-items: center; gap: 4px; padding: 4px 8px; border-radius: 4px; cursor: pointer; color: var(--text-muted); font-size: 12px; transition: color 0.2s ease; } .claudian-model-btn.ready { color: var(--claudian-brand); } .claudian-model-label { font-weight: 500; } .claudian-model-chevron { display: flex; align-items: center; } .claudian-model-chevron svg { width: 12px; height: 12px; } .claudian-model-dropdown { position: absolute; bottom: 100%; left: 0; margin-bottom: 0; display: flex; flex-direction: column; gap: 2px; background: var(--background-secondary); border: 1px solid var(--background-modifier-border); border-radius: 4px; box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.15); z-index: 1000; width: max-content; padding: 4px; opacity: 0; visibility: hidden; transition: opacity 0.15s ease, visibility 0.15s ease; } .claudian-model-selector:hover .claudian-model-dropdown { opacity: 1; visibility: visible; } .claudian-model-option { padding: 4px 8px; cursor: pointer; font-size: 12px; color: var(--text-muted); border-radius: 3px; transition: background 0.1s ease, color 0.1s ease; white-space: nowrap; } .claudian-model-option:hover { background: var(--background-modifier-hover); color: var(--text-normal); } .claudian-model-option.selected { background: rgba(var(--claudian-brand-rgb), 0.15); color: var(--claudian-brand); font-weight: 500; } ================================================ FILE: src/style/toolbar/permission-toggle.css ================================================ /* Permission Mode Toggle */ .claudian-permission-toggle { display: flex; align-items: center; gap: 6px; margin-left: auto; padding-left: 12px; padding-right: 8px; } .claudian-permission-label { font-size: 11px; color: var(--text-muted); min-width: 28px; } .claudian-permission-label.plan-active { color: rgb(92, 148, 140); font-weight: 600; } .claudian-toggle-switch { width: 32px; height: 18px; border-radius: 9px; background: var(--background-modifier-border); cursor: pointer; position: relative; transition: background 0.2s ease; flex-shrink: 0; } .claudian-toggle-switch::after { content: ''; position: absolute; width: 14px; height: 14px; border-radius: 50%; background: var(--text-muted); top: 2px; left: 2px; transition: transform 0.2s ease, background 0.2s ease; } .claudian-toggle-switch:hover { background: var(--background-modifier-hover); } .claudian-toggle-switch.active { background: rgba(var(--claudian-brand-rgb), 0.3); } .claudian-toggle-switch.active::after { transform: translateX(14px); background: var(--claudian-brand); } ================================================ FILE: src/style/toolbar/thinking-selector.css ================================================ /* Thinking selector (effort for adaptive models, token budget for custom) */ .claudian-thinking-selector { display: flex; align-items: center; gap: 6px; } /* Effort / budget container (shared layout) */ .claudian-thinking-effort, .claudian-thinking-budget { display: flex; align-items: center; gap: 6px; } .claudian-thinking-label-text { font-size: 11px; color: var(--text-muted); } .claudian-thinking-gears { position: relative; display: flex; align-items: center; border-radius: 4px; } /* Current selection (visible when collapsed) */ .claudian-thinking-current { padding: 3px 8px; font-size: 11px; color: var(--claudian-brand); font-weight: 500; cursor: pointer; border-radius: 3px; white-space: nowrap; background: transparent; } /* Options container - expands vertically upward */ .claudian-thinking-options { position: absolute; left: 0; bottom: 100%; margin-bottom: 0; display: flex; flex-direction: column; gap: 2px; background: var(--background-secondary); border: 1px solid var(--background-modifier-border); border-radius: 4px; padding: 4px; opacity: 0; visibility: hidden; transition: opacity 0.15s ease, visibility 0.15s ease; } /* Expand on hover */ .claudian-thinking-gears:hover .claudian-thinking-options { opacity: 1; visibility: visible; } .claudian-thinking-gear { padding: 3px 8px; font-size: 11px; color: var(--text-muted); cursor: pointer; border-radius: 3px; transition: background 0.1s ease, color 0.1s ease; white-space: nowrap; } .claudian-thinking-gear:hover { background: var(--background-modifier-hover); color: var(--text-normal); } .claudian-thinking-gear.selected { background: rgba(var(--claudian-brand-rgb), 0.15); color: var(--claudian-brand); font-weight: 500; } ================================================ FILE: src/utils/agent.ts ================================================ import type { AgentDefinition } from '../core/types'; import { validateSlugName } from './frontmatter'; import { yamlString } from './slashCommand'; export function validateAgentName(name: string): string | null { return validateSlugName(name, 'Agent'); } function pushYamlList(lines: string[], key: string, items?: string[]): void { if (!items || items.length === 0) return; lines.push(`${key}:`); for (const item of items) { lines.push(` - ${yamlString(item)}`); } } export function serializeAgent(agent: AgentDefinition): string { const lines: string[] = ['---']; lines.push(`name: ${agent.name}`); lines.push(`description: ${yamlString(agent.description)}`); pushYamlList(lines, 'tools', agent.tools); pushYamlList(lines, 'disallowedTools', agent.disallowedTools); if (agent.model && agent.model !== 'inherit') { lines.push(`model: ${agent.model}`); } if (agent.permissionMode) { lines.push(`permissionMode: ${agent.permissionMode}`); } pushYamlList(lines, 'skills', agent.skills); if (agent.hooks !== undefined) { lines.push(`hooks: ${JSON.stringify(agent.hooks)}`); } if (agent.extraFrontmatter) { for (const [key, value] of Object.entries(agent.extraFrontmatter)) { lines.push(`${key}: ${JSON.stringify(value)}`); } } lines.push('---'); lines.push(agent.prompt); return lines.join('\n'); } ================================================ FILE: src/utils/browser.ts ================================================ export interface BrowserSelectionContext { source: string; selectedText: string; title?: string; url?: string; } function escapeXmlAttribute(value: string): string { return value .replace(/&/g, '&') .replace(/"/g, '"') .replace(//g, '>'); } function buildAttributeList(context: BrowserSelectionContext): string { const attrs: string[] = []; const source = context.source.trim() || 'unknown'; attrs.push(`source="${escapeXmlAttribute(source)}"`); if (context.title?.trim()) { attrs.push(`title="${escapeXmlAttribute(context.title.trim())}"`); } if (context.url?.trim()) { attrs.push(`url="${escapeXmlAttribute(context.url.trim())}"`); } return attrs.join(' '); } function escapeXmlBody(text: string): string { return text.replace(/<\/browser_selection>/gi, '</browser_selection>'); } export function formatBrowserContext(context: BrowserSelectionContext): string { const selectedText = context.selectedText.trim(); if (!selectedText) return ''; const attrs = buildAttributeList(context); return `\n${escapeXmlBody(selectedText)}\n`; } export function appendBrowserContext(prompt: string, context: BrowserSelectionContext): string { const formatted = formatBrowserContext(context); return formatted ? `${prompt}\n\n${formatted}` : prompt; } ================================================ FILE: src/utils/canvas.ts ================================================ export interface CanvasSelectionContext { canvasPath: string; nodeIds: string[]; } export function formatCanvasContext(context: CanvasSelectionContext): string { if (context.nodeIds.length === 0) return ''; return `\n${context.nodeIds.join(', ')}\n`; } export function appendCanvasContext(prompt: string, context: CanvasSelectionContext): string { const formatted = formatCanvasContext(context); return formatted ? `${prompt}\n\n${formatted}` : prompt; } ================================================ FILE: src/utils/claudeCli.ts ================================================ /** * Claudian - Claude CLI resolver * * Shared resolver for Claude CLI path detection across services. */ import * as fs from 'fs'; import { type HostnameCliPaths } from '../core/types/settings'; import { getHostnameKey, parseEnvironmentVariables } from './env'; import { expandHomePath, findClaudeCLIPath } from './path'; export class ClaudeCliResolver { private resolvedPath: string | null = null; private lastHostnamePath = ''; private lastLegacyPath = ''; private lastEnvText = ''; // Cache hostname since it doesn't change during a session private readonly cachedHostname = getHostnameKey(); /** * Resolves CLI path with priority: hostname-specific -> legacy -> auto-detect. * @param hostnamePaths Per-device CLI paths keyed by hostname (preferred) * @param legacyPath Legacy claudeCliPath (for backwards compatibility) * @param envText Environment variables text */ resolve( hostnamePaths: HostnameCliPaths | undefined, legacyPath: string | undefined, envText: string ): string | null { const hostnameKey = this.cachedHostname; const hostnamePath = (hostnamePaths?.[hostnameKey] ?? '').trim(); const normalizedLegacy = (legacyPath ?? '').trim(); const normalizedEnv = envText ?? ''; if ( this.resolvedPath && hostnamePath === this.lastHostnamePath && normalizedLegacy === this.lastLegacyPath && normalizedEnv === this.lastEnvText ) { return this.resolvedPath; } this.lastHostnamePath = hostnamePath; this.lastLegacyPath = normalizedLegacy; this.lastEnvText = normalizedEnv; this.resolvedPath = resolveClaudeCliPath(hostnamePath, normalizedLegacy, normalizedEnv); return this.resolvedPath; } reset(): void { this.resolvedPath = null; this.lastHostnamePath = ''; this.lastLegacyPath = ''; this.lastEnvText = ''; } } /** * Resolves CLI path with fallback chain. * @param hostnamePath Hostname-specific path for this device (preferred) * @param legacyPath Legacy claudeCliPath (backwards compatibility) * @param envText Environment variables text */ export function resolveClaudeCliPath( hostnamePath: string | undefined, legacyPath: string | undefined, envText: string ): string | null { const trimmedHostname = (hostnamePath ?? '').trim(); if (trimmedHostname) { try { const expandedPath = expandHomePath(trimmedHostname); if (fs.existsSync(expandedPath)) { const stat = fs.statSync(expandedPath); if (stat.isFile()) { return expandedPath; } } } catch { // Fall through to next resolution method } } const trimmedLegacy = (legacyPath ?? '').trim(); if (trimmedLegacy) { try { const expandedPath = expandHomePath(trimmedLegacy); if (fs.existsSync(expandedPath)) { const stat = fs.statSync(expandedPath); if (stat.isFile()) { return expandedPath; } } } catch { // Fall through to auto-detect } } const customEnv = parseEnvironmentVariables(envText || ''); return findClaudeCLIPath(customEnv.PATH); } ================================================ FILE: src/utils/context.ts ================================================ /** * Claudian - Context Utilities * * Current note and context file formatting for prompts. */ // Matches at the START of prompt (legacy format) const CURRENT_NOTE_PREFIX_REGEX = /^\n[\s\S]*?<\/current_note>\n\n/; // Matches at the END of prompt (current format) const CURRENT_NOTE_SUFFIX_REGEX = /\n\n\n[\s\S]*?<\/current_note>$/; /** * Pattern to match XML context tags appended to prompts. * These tags are always preceded by \n\n separator. * Matches: current_note, editor_selection (with attributes), editor_cursor (with attributes), * context_files, canvas_selection, browser_selection */ export const XML_CONTEXT_PATTERN = /\n\n<(?:current_note|editor_selection|editor_cursor|context_files|canvas_selection|browser_selection)[\s>]/; export function formatCurrentNote(notePath: string): string { return `\n${notePath}\n`; } export function appendCurrentNote(prompt: string, notePath: string): string { return `${prompt}\n\n${formatCurrentNote(notePath)}`; } /** * Strips current note context from a prompt (both prefix and suffix formats). * Handles legacy (prefix) and current (suffix) formats. */ export function stripCurrentNoteContext(prompt: string): string { // Try prefix format first (legacy) const strippedPrefix = prompt.replace(CURRENT_NOTE_PREFIX_REGEX, ''); if (strippedPrefix !== prompt) { return strippedPrefix; } // Try suffix format (current) return prompt.replace(CURRENT_NOTE_SUFFIX_REGEX, ''); } /** * Extracts user content that appears before XML context tags. * Handles two formats: * 1. Legacy: content inside tags * 2. Current: user content first, context XML appended after */ export function extractContentBeforeXmlContext(text: string): string | undefined { if (!text) return undefined; // Legacy format: content inside tags const queryMatch = text.match(/\n?([\s\S]*?)\n?<\/query>/); if (queryMatch) { return queryMatch[1].trim(); } // Current format: user content before any XML context tags // Context tags are always appended with \n\n separator const xmlMatch = text.match(XML_CONTEXT_PATTERN); if (xmlMatch?.index !== undefined) { return text.substring(0, xmlMatch.index).trim(); } return undefined; } /** * Extracts the actual user query from an XML-wrapped prompt. * Used for comparing prompts during history deduplication. * * Always returns a string - falls back to stripping all XML tags if no * structured context is found. */ export function extractUserQuery(prompt: string): string { if (!prompt) return ''; // Try to extract content before XML context const extracted = extractContentBeforeXmlContext(prompt); if (extracted !== undefined) { return extracted; } // No XML context - return the whole prompt stripped of any remaining tags return prompt .replace(/[\s\S]*?<\/current_note>\s*/g, '') .replace(/\s*/g, '') .replace(/\s*/g, '') .replace(/[\s\S]*?<\/context_files>\s*/g, '') .replace(/\s*/g, '') .replace(/\s*/g, '') .trim(); } function formatContextFilesLine(files: string[]): string { return `\n${files.join(', ')}\n`; } export function appendContextFiles(prompt: string, files: string[]): string { return `${prompt}\n\n${formatContextFilesLine(files)}`; } ================================================ FILE: src/utils/contextMentionResolver.ts ================================================ import type { ExternalContextDisplayEntry } from './externalContext'; import type { ExternalContextFile } from './externalContextScanner'; export interface MentionLookupMatch { resolvedPath: string; endIndex: number; trailingPunctuation: string; } const TRAILING_PUNCTUATION_REGEX = /[),.!?:;]+$/; const BOUNDARY_PUNCTUATION = new Set([',', ')', '!', '?', ':', ';']); function isWhitespace(char: string): boolean { return /\s/.test(char); } function collectMentionEndCandidates(text: string, pathStart: number): number[] { const candidates = new Set(); for (let index = pathStart; index < text.length; index++) { const char = text[index]; if (isWhitespace(char)) { candidates.add(index); continue; } if (BOUNDARY_PUNCTUATION.has(char)) { candidates.add(index + 1); } } candidates.add(text.length); return Array.from(candidates).sort((a, b) => b - a); } export function isMentionStart(text: string, index: number): boolean { if (text[index] !== '@') return false; if (index === 0) return true; return isWhitespace(text[index - 1]); } export function normalizeMentionPath(pathText: string): string { return pathText .replace(/\\/g, '/') .replace(/^\.?\//, '') .replace(/\/+/g, '/') .replace(/\/+$/, ''); } export function normalizeForPlatformLookup(value: string): string { return process.platform === 'win32' ? value.toLowerCase() : value; } export function buildExternalContextLookup( files: ExternalContextFile[] ): Map { const lookup = new Map(); for (const file of files) { const normalized = normalizeMentionPath(file.relativePath); if (!normalized) continue; const key = normalizeForPlatformLookup(normalized); if (!lookup.has(key)) { lookup.set(key, file.path); } } return lookup; } export function resolveExternalMentionAtIndex( text: string, mentionStart: number, contextEntries: ExternalContextDisplayEntry[], getContextLookup: (contextRoot: string) => Map ): MentionLookupMatch | null { const mentionBodyStart = mentionStart + 1; let bestMatch: MentionLookupMatch | null = null; for (const entry of contextEntries) { const displayNameEnd = mentionBodyStart + entry.displayName.length; if (displayNameEnd >= text.length) continue; const mentionDisplayName = text.slice(mentionBodyStart, displayNameEnd).toLowerCase(); if (mentionDisplayName !== entry.displayNameLower) continue; const separator = text[displayNameEnd]; if (separator !== '/' && separator !== '\\') continue; const lookup = getContextLookup(entry.contextRoot); const match = findBestMentionLookupMatch( text, displayNameEnd + 1, lookup, normalizeMentionPath, normalizeForPlatformLookup ); if (!match) continue; if (!bestMatch || match.endIndex > bestMatch.endIndex) { bestMatch = match; } } return bestMatch; } export function findBestMentionLookupMatch( text: string, pathStart: number, pathLookup: Map, normalizePath: (pathText: string) => string, normalizeLookupKey: (value: string) => string ): MentionLookupMatch | null { if (pathLookup.size === 0 || pathStart >= text.length) return null; const endCandidates = collectMentionEndCandidates(text, pathStart); for (const endIndex of endCandidates) { if (endIndex <= pathStart) continue; const rawPath = text.slice(pathStart, endIndex); const trailingPunctuation = rawPath.match(TRAILING_PUNCTUATION_REGEX)?.[0] ?? ''; const rawPathWithoutPunctuation = trailingPunctuation ? rawPath.slice(0, -trailingPunctuation.length) : rawPath; const normalizedPath = normalizePath(rawPathWithoutPunctuation); if (!normalizedPath) continue; const resolvedPath = pathLookup.get(normalizeLookupKey(normalizedPath)); if (resolvedPath) { return { resolvedPath, endIndex, trailingPunctuation, }; } } return null; } export function createExternalContextLookupGetter( getContextFiles: (contextRoot: string) => ExternalContextFile[] ): (contextRoot: string) => Map { const lookupCache = new Map>(); return (contextRoot: string): Map => { const cached = lookupCache.get(contextRoot); if (cached) return cached; const lookup = buildExternalContextLookup(getContextFiles(contextRoot)); lookupCache.set(contextRoot, lookup); return lookup; }; } ================================================ FILE: src/utils/date.ts ================================================ /** * Claudian - Date Utilities * * Date formatting helpers for system prompts. */ /** Returns today's date in readable and ISO format for the system prompt. */ export function getTodayDate(): string { const now = new Date(); const readable = now.toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', }); const iso = now.toISOString().split('T')[0]; return `${readable} (${iso})`; } /** Formats a duration in seconds as "1m 23s" or "45s". */ export function formatDurationMmSs(seconds: number): string { if (!Number.isFinite(seconds) || seconds < 0) { return '0s'; } const mins = Math.floor(seconds / 60); const secs = seconds % 60; if (mins === 0) { return `${secs}s`; } return `${mins}m ${secs}s`; } ================================================ FILE: src/utils/diff.ts ================================================ import type { DiffLine, DiffStats, StructuredPatchHunk } from '../core/types/diff'; import type { ToolCallInfo, ToolDiffData } from '../core/types/tools'; /** * Convert SDK structuredPatch hunks to DiffLine[]. * Each line in the hunk is prefixed with '+' (insert), '-' (delete), or ' ' (context). */ export function structuredPatchToDiffLines(hunks: StructuredPatchHunk[]): DiffLine[] { const result: DiffLine[] = []; for (const hunk of hunks) { let oldLineNum = hunk.oldStart; let newLineNum = hunk.newStart; for (const line of hunk.lines) { const prefix = line[0]; const text = line.slice(1); if (prefix === '+') { result.push({ type: 'insert', text, newLineNum: newLineNum++ }); } else if (prefix === '-') { result.push({ type: 'delete', text, oldLineNum: oldLineNum++ }); } else { result.push({ type: 'equal', text, oldLineNum: oldLineNum++, newLineNum: newLineNum++ }); } } } return result; } export function countLineChanges(diffLines: DiffLine[]): DiffStats { let added = 0; let removed = 0; for (const line of diffLines) { if (line.type === 'insert') added++; else if (line.type === 'delete') removed++; } return { added, removed }; } /** * Extracts ToolDiffData from an SDK toolUseResult object. * * Primary: Use structuredPatch hunks from the SDK result. * Fallback: Compute diff from tool input (Edit: old/new string, Write: content as inserts). */ export function extractDiffData(toolUseResult: unknown, toolCall: ToolCallInfo): ToolDiffData | undefined { const filePath = (toolCall.input.file_path as string) || 'file'; if (toolUseResult && typeof toolUseResult === 'object') { const result = toolUseResult as Record; if (Array.isArray(result.structuredPatch) && result.structuredPatch.length > 0) { const resultFilePath = (typeof result.filePath === 'string' ? result.filePath : null) || filePath; const hunks = result.structuredPatch as StructuredPatchHunk[]; const diffLines = structuredPatchToDiffLines(hunks); const stats = countLineChanges(diffLines); return { filePath: resultFilePath, diffLines, stats }; } } return diffFromToolInput(toolCall, filePath); } /** * Computes diff data from tool input when structuredPatch is unavailable or empty. * Edit: old_string lines as deletes, new_string lines as inserts. * Write: all content lines as inserts (file create/full rewrite). */ export function diffFromToolInput(toolCall: ToolCallInfo, filePath: string): ToolDiffData | undefined { if (toolCall.name === 'Edit') { const oldStr = toolCall.input.old_string; const newStr = toolCall.input.new_string; if (typeof oldStr === 'string' && typeof newStr === 'string') { const diffLines: DiffLine[] = []; const oldLines = oldStr.split('\n'); const newLines = newStr.split('\n'); let oldLineNum = 1; for (const line of oldLines) { diffLines.push({ type: 'delete', text: line, oldLineNum: oldLineNum++ }); } let newLineNum = 1; for (const line of newLines) { diffLines.push({ type: 'insert', text: line, newLineNum: newLineNum++ }); } return { filePath, diffLines, stats: countLineChanges(diffLines) }; } } if (toolCall.name === 'Write') { const content = toolCall.input.content; if (typeof content === 'string') { const newLines = content.split('\n'); const diffLines: DiffLine[] = newLines.map((text, i) => ({ type: 'insert', text, newLineNum: i + 1, })); return { filePath, diffLines, stats: { added: newLines.length, removed: 0 } }; } } return undefined; } ================================================ FILE: src/utils/editor.ts ================================================ /** * Claudian - Editor Context Utilities * * Editor cursor and selection context for inline editing. */ import type { EditorView } from '@codemirror/view'; import type { Editor } from 'obsidian'; /** * Gets the CodeMirror EditorView from an Obsidian Editor. * Obsidian's Editor type doesn't expose the internal `.cm` property. */ export function getEditorView(editor: Editor): EditorView | undefined { return (editor as unknown as { cm?: EditorView }).cm; } export interface CursorContext { beforeCursor: string; afterCursor: string; isInbetween: boolean; line: number; column: number; } export interface EditorSelectionContext { notePath: string; mode: 'selection' | 'cursor' | 'none'; selectedText?: string; cursorContext?: CursorContext; lineCount?: number; // Number of lines in selection (for UI indicator) startLine?: number; // 1-indexed starting line number } export function findNearestNonEmptyLine( getLine: (line: number) => string, lineCount: number, startLine: number, direction: 'before' | 'after' ): string { const step = direction === 'before' ? -1 : 1; for (let i = startLine + step; i >= 0 && i < lineCount; i += step) { const content = getLine(i); if (content.trim().length > 0) { return content; } } return ''; } /** All line/column params are 0-indexed. */ export function buildCursorContext( getLine: (line: number) => string, lineCount: number, line: number, column: number ): CursorContext { const lineContent = getLine(line); const beforeCursor = lineContent.substring(0, column); const afterCursor = lineContent.substring(column); const lineIsEmpty = lineContent.trim().length === 0; const nothingBefore = beforeCursor.trim().length === 0; const nothingAfter = afterCursor.trim().length === 0; const isInbetween = lineIsEmpty || (nothingBefore && nothingAfter); let contextBefore = beforeCursor; let contextAfter = afterCursor; if (isInbetween) { contextBefore = findNearestNonEmptyLine(getLine, lineCount, line, 'before'); contextAfter = findNearestNonEmptyLine(getLine, lineCount, line, 'after'); } return { beforeCursor: contextBefore, afterCursor: contextAfter, isInbetween, line, column }; } export function formatEditorContext(context: EditorSelectionContext): string { if (context.mode === 'selection' && context.selectedText) { const lineAttr = context.startLine && context.lineCount ? ` lines="${context.startLine}-${context.startLine + context.lineCount - 1}"` : ''; return `\n${context.selectedText}\n`; } else if (context.mode === 'cursor' && context.cursorContext) { const ctx = context.cursorContext; let content: string; if (ctx.isInbetween) { const parts = []; if (ctx.beforeCursor) parts.push(ctx.beforeCursor); parts.push('| #inbetween'); if (ctx.afterCursor) parts.push(ctx.afterCursor); content = parts.join('\n'); } else { content = `${ctx.beforeCursor}|${ctx.afterCursor} #inline`; } return `\n${content}\n`; } return ''; } export function appendEditorContext(prompt: string, context: EditorSelectionContext): string { const formatted = formatEditorContext(context); return formatted ? `${prompt}\n\n${formatted}` : prompt; } ================================================ FILE: src/utils/env.ts ================================================ /** * Claudian - Environment Utilities * * Environment variable parsing, model configuration, PATH enhancement for GUI apps, * and system identification utilities. */ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { parsePathEntries, resolveNvmDefaultBin } from './path'; const isWindows = process.platform === 'win32'; const PATH_SEPARATOR = isWindows ? ';' : ':'; const NODE_EXECUTABLE = isWindows ? 'node.exe' : 'node'; function getHomeDir(): string { return process.env.HOME || process.env.USERPROFILE || ''; } /** * Linux is excluded because Obsidian registers the CLI through stable symlinks * like /usr/local/bin or ~/.local/bin, while process.execPath may point to a * transient AppImage mount that changes on every launch. */ function getAppProvidedCliPaths(): string[] { if (process.platform === 'darwin') { const appBundleMatch = process.execPath.match(/^(.+?\.app)\//); if (appBundleMatch) { return [path.join(appBundleMatch[1], 'Contents', 'MacOS')]; } return [path.dirname(process.execPath)]; } if (process.platform === 'win32') { return [path.dirname(process.execPath)]; } return []; } /** GUI apps like Obsidian have minimal PATH, so we add common binary locations. */ function getExtraBinaryPaths(): string[] { const home = getHomeDir(); if (isWindows) { const paths: string[] = []; const localAppData = process.env.LOCALAPPDATA; const appData = process.env.APPDATA; const programFiles = process.env.ProgramFiles || 'C:\\Program Files'; const programFilesX86 = process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)'; const programData = process.env.ProgramData || 'C:\\ProgramData'; // Node.js / npm locations if (appData) { paths.push(path.join(appData, 'npm')); } if (localAppData) { paths.push(path.join(localAppData, 'Programs', 'nodejs')); paths.push(path.join(localAppData, 'Programs', 'node')); } // Common program locations (official Node.js installer) paths.push(path.join(programFiles, 'nodejs')); paths.push(path.join(programFilesX86, 'nodejs')); // nvm-windows: active Node.js is usually under %NVM_SYMLINK% const nvmSymlink = process.env.NVM_SYMLINK; if (nvmSymlink) { paths.push(nvmSymlink); } // nvm-windows: stores Node.js versions in %NVM_HOME% or %APPDATA%\nvm const nvmHome = process.env.NVM_HOME; if (nvmHome) { paths.push(nvmHome); } else if (appData) { paths.push(path.join(appData, 'nvm')); } // volta: installs to %VOLTA_HOME%\bin or %USERPROFILE%\.volta\bin const voltaHome = process.env.VOLTA_HOME; if (voltaHome) { paths.push(path.join(voltaHome, 'bin')); } else if (home) { paths.push(path.join(home, '.volta', 'bin')); } // fnm (Fast Node Manager): %FNM_MULTISHELL_PATH% is the active Node.js bin const fnmMultishell = process.env.FNM_MULTISHELL_PATH; if (fnmMultishell) { paths.push(fnmMultishell); } // fnm (Fast Node Manager): %FNM_DIR% or %LOCALAPPDATA%\fnm const fnmDir = process.env.FNM_DIR; if (fnmDir) { paths.push(fnmDir); } else if (localAppData) { paths.push(path.join(localAppData, 'fnm')); } // Chocolatey: %ChocolateyInstall%\bin or C:\ProgramData\chocolatey\bin const chocolateyInstall = process.env.ChocolateyInstall; if (chocolateyInstall) { paths.push(path.join(chocolateyInstall, 'bin')); } else { paths.push(path.join(programData, 'chocolatey', 'bin')); } // scoop: %SCOOP%\shims or %USERPROFILE%\scoop\shims const scoopDir = process.env.SCOOP; if (scoopDir) { paths.push(path.join(scoopDir, 'shims')); paths.push(path.join(scoopDir, 'apps', 'nodejs', 'current', 'bin')); paths.push(path.join(scoopDir, 'apps', 'nodejs', 'current')); } else if (home) { paths.push(path.join(home, 'scoop', 'shims')); paths.push(path.join(home, 'scoop', 'apps', 'nodejs', 'current', 'bin')); paths.push(path.join(home, 'scoop', 'apps', 'nodejs', 'current')); } // Docker paths.push(path.join(programFiles, 'Docker', 'Docker', 'resources', 'bin')); // User bin (if exists) if (home) { paths.push(path.join(home, '.local', 'bin')); } paths.push(...getAppProvidedCliPaths()); return paths; } else { // Unix paths const paths = [ '/usr/local/bin', '/opt/homebrew/bin', // macOS ARM Homebrew '/usr/bin', '/bin', ]; const voltaHome = process.env.VOLTA_HOME; if (voltaHome) { paths.push(path.join(voltaHome, 'bin')); } const asdfRoot = process.env.ASDF_DATA_DIR || process.env.ASDF_DIR; if (asdfRoot) { paths.push(path.join(asdfRoot, 'shims')); paths.push(path.join(asdfRoot, 'bin')); } const fnmMultishell = process.env.FNM_MULTISHELL_PATH; if (fnmMultishell) { paths.push(fnmMultishell); } const fnmDir = process.env.FNM_DIR; if (fnmDir) { paths.push(fnmDir); } if (home) { paths.push(path.join(home, '.local', 'bin')); paths.push(path.join(home, '.docker', 'bin')); paths.push(path.join(home, '.volta', 'bin')); paths.push(path.join(home, '.asdf', 'shims')); paths.push(path.join(home, '.asdf', 'bin')); paths.push(path.join(home, '.fnm')); // NVM: use NVM_BIN if set, otherwise resolve default version from filesystem const nvmBin = process.env.NVM_BIN; if (nvmBin) { paths.push(nvmBin); } else { const nvmDefault = resolveNvmDefaultBin(home); if (nvmDefault) { paths.push(nvmDefault); } } } paths.push(...getAppProvidedCliPaths()); return paths; } } export function findNodeDirectory(additionalPaths?: string): string | null { const searchPaths = getExtraBinaryPaths(); const currentPath = process.env.PATH || ''; const pathDirs = parsePathEntries(currentPath); const additionalDirs = additionalPaths ? parsePathEntries(additionalPaths) : []; const allPaths = [...additionalDirs, ...searchPaths, ...pathDirs]; for (const dir of allPaths) { if (!dir) continue; try { const nodePath = path.join(dir, NODE_EXECUTABLE); if (fs.existsSync(nodePath)) { const stat = fs.statSync(nodePath); if (stat.isFile()) { return dir; } } } catch { // Inaccessible directory } } return null; } export function findNodeExecutable(additionalPaths?: string): string | null { const nodeDir = findNodeDirectory(additionalPaths); if (nodeDir) { return path.join(nodeDir, NODE_EXECUTABLE); } return null; } export function cliPathRequiresNode(cliPath: string): boolean { const jsExtensions = ['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx']; const lower = cliPath.toLowerCase(); if (jsExtensions.some(ext => lower.endsWith(ext))) { return true; } try { if (!fs.existsSync(cliPath)) { return false; } const stat = fs.statSync(cliPath); if (!stat.isFile()) { return false; } let fd: number | null = null; try { fd = fs.openSync(cliPath, 'r'); const buffer = Buffer.alloc(200); const bytesRead = fs.readSync(fd, buffer, 0, buffer.length, 0); const header = buffer.slice(0, bytesRead).toString('utf8'); return header.startsWith('#!') && header.toLowerCase().includes('node'); } finally { if (fd !== null) { try { fs.closeSync(fd); } catch { // Ignore close errors } } } } catch { return false; } } export function getMissingNodeError(cliPath: string, enhancedPath?: string): string | null { if (!cliPathRequiresNode(cliPath)) { return null; } const nodePath = findNodeExecutable(enhancedPath); if (nodePath) { return null; } return 'Claude Code CLI requires Node.js, but Node was not found on PATH. Install Node.js or use the native Claude Code binary, then restart Obsidian.'; } /** * Returns an enhanced PATH that includes common binary locations. * GUI apps like Obsidian have minimal PATH, so we need to add standard locations * where binaries like node, python, etc. are typically installed. * * @param additionalPaths - Optional additional PATH entries to include (from user config). * These take priority and are prepended. * @param cliPath - Optional CLI path. If provided and its directory contains node, * that directory is added to PATH. This handles nvm, fnm, volta, etc. * where npm globals are installed alongside node. */ export function getEnhancedPath(additionalPaths?: string, cliPath?: string): string { const extraPaths = getExtraBinaryPaths().filter(p => p); // Filter out empty const currentPath = process.env.PATH || ''; const segments: string[] = []; if (additionalPaths) { segments.push(...parsePathEntries(additionalPaths)); } // If CLI path is provided, check if its directory contains node executable. // This handles nvm, fnm, volta, asdf, etc. where npm globals are installed // in the same bin directory as node. Works on both Windows and Unix. let cliDirHasNode = false; if (cliPath) { try { const cliDir = path.dirname(cliPath); const nodeInCliDir = path.join(cliDir, NODE_EXECUTABLE); if (fs.existsSync(nodeInCliDir)) { const stat = fs.statSync(nodeInCliDir); if (stat.isFile()) { segments.push(cliDir); cliDirHasNode = true; } } } catch { // Ignore errors checking CLI directory } } if (cliPath && cliPathRequiresNode(cliPath) && !cliDirHasNode) { const nodeDir = findNodeDirectory(); if (nodeDir) { segments.push(nodeDir); } } segments.push(...extraPaths); if (currentPath) { segments.push(...parsePathEntries(currentPath)); } const seen = new Set(); const unique = segments.filter(p => { const normalized = isWindows ? p.toLowerCase() : p; if (seen.has(normalized)) return false; seen.add(normalized); return true; }); return unique.join(PATH_SEPARATOR); } const CUSTOM_MODEL_ENV_KEYS = [ 'ANTHROPIC_MODEL', 'ANTHROPIC_DEFAULT_OPUS_MODEL', 'ANTHROPIC_DEFAULT_SONNET_MODEL', 'ANTHROPIC_DEFAULT_HAIKU_MODEL', ] as const; function getModelTypeFromEnvKey(envKey: string): string { if (envKey === 'ANTHROPIC_MODEL') return 'model'; const match = envKey.match(/ANTHROPIC_DEFAULT_(\w+)_MODEL/); return match ? match[1].toLowerCase() : envKey; } /** Parses KEY=VALUE environment variables from text. Supports comments (#) and empty lines. */ export function parseEnvironmentVariables(input: string): Record { const result: Record = {}; for (const line of input.split(/\r?\n/)) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith('#')) continue; // Strip 'export ' prefix if present (common in shell snippets) const normalized = trimmed.startsWith('export ') ? trimmed.slice(7) : trimmed; const eqIndex = normalized.indexOf('='); if (eqIndex > 0) { const key = normalized.substring(0, eqIndex).trim(); let value = normalized.substring(eqIndex + 1).trim(); // Strip surrounding quotes (single or double) if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { value = value.slice(1, -1); } if (key) { result[key] = value; } } } return result; } export function getModelsFromEnvironment(envVars: Record): { value: string; label: string; description: string }[] { const modelMap = new Map(); for (const envKey of CUSTOM_MODEL_ENV_KEYS) { const type = getModelTypeFromEnvKey(envKey); const modelValue = envVars[envKey]; if (modelValue) { const label = modelValue.includes('/') ? modelValue.split('/').pop() || modelValue : modelValue.replace(/-/g, ' ').replace(/\b\w/g, l => l.toUpperCase()); if (!modelMap.has(modelValue)) { modelMap.set(modelValue, { types: [type], label }); } else { modelMap.get(modelValue)!.types.push(type); } } } const models: { value: string; label: string; description: string }[] = []; const typePriority = { 'model': 4, 'haiku': 3, 'sonnet': 2, 'opus': 1 }; const sortedEntries = Array.from(modelMap.entries()).sort(([, aInfo], [, bInfo]) => { const aPriority = Math.max(...aInfo.types.map(t => typePriority[t as keyof typeof typePriority] || 0)); const bPriority = Math.max(...bInfo.types.map(t => typePriority[t as keyof typeof typePriority] || 0)); return bPriority - aPriority; }); for (const [modelValue, info] of sortedEntries) { const sortedTypes = info.types.sort((a, b) => (typePriority[b as keyof typeof typePriority] || 0) - (typePriority[a as keyof typeof typePriority] || 0) ); models.push({ value: modelValue, label: info.label, description: `Custom model (${sortedTypes.join(', ')})` }); } return models; } export function getCurrentModelFromEnvironment(envVars: Record): string | null { if (envVars.ANTHROPIC_MODEL) { return envVars.ANTHROPIC_MODEL; } if (envVars.ANTHROPIC_DEFAULT_HAIKU_MODEL) { return envVars.ANTHROPIC_DEFAULT_HAIKU_MODEL; } if (envVars.ANTHROPIC_DEFAULT_SONNET_MODEL) { return envVars.ANTHROPIC_DEFAULT_SONNET_MODEL; } if (envVars.ANTHROPIC_DEFAULT_OPUS_MODEL) { return envVars.ANTHROPIC_DEFAULT_OPUS_MODEL; } return null; } /** Hostname changes will require reconfiguration. */ export function getHostnameKey(): string { return os.hostname(); } export const MIN_CONTEXT_LIMIT = 1_000; export const MAX_CONTEXT_LIMIT = 10_000_000; export function getCustomModelIds(envVars: Record): Set { const modelIds = new Set(); for (const envKey of CUSTOM_MODEL_ENV_KEYS) { const modelId = envVars[envKey]; if (modelId) { modelIds.add(modelId); } } return modelIds; } /** Supports "256k", "1m", "1.5m", or exact token count. Case-insensitive. */ export function parseContextLimit(input: string): number | null { const trimmed = input.trim().toLowerCase().replace(/,/g, ''); if (!trimmed) return null; // Match number with optional suffix (k, m) const match = trimmed.match(/^(\d+(?:\.\d+)?)\s*(k|m)?$/); if (!match) return null; const value = parseFloat(match[1]); const suffix = match[2]; if (isNaN(value) || value <= 0) return null; const MULTIPLIERS: Record = { k: 1_000, m: 1_000_000 }; const multiplier = suffix ? MULTIPLIERS[suffix] ?? 1 : 1; const result = Math.round(value * multiplier); if (result < MIN_CONTEXT_LIMIT || result > MAX_CONTEXT_LIMIT) return null; return result; } export function formatContextLimit(tokens: number): string { if (tokens >= 1_000_000 && tokens % 1_000_000 === 0) { return `${tokens / 1_000_000}m`; } if (tokens >= 1000 && tokens % 1000 === 0) { return `${tokens / 1000}k`; } return tokens.toLocaleString(); } ================================================ FILE: src/utils/externalContext.ts ================================================ /** * Claudian - External Context Utilities * * Utilities for external context validation, normalization, and conflict detection. */ import * as fs from 'fs'; import { normalizePathForComparison as normalizePathForComparisonImpl } from './path'; export interface PathConflict { path: string; type: 'parent' | 'child'; } /** * Normalizes a path for comparison. * Re-exports the unified implementation from path.ts for consistency. * - Handles MSYS paths, home/env expansions * - Case-insensitive on Windows * - Trailing slash removed */ export function normalizePathForComparison(p: string): string { return normalizePathForComparisonImpl(p); } function normalizePathForDisplay(p: string): string { if (!p) return ''; return p.replace(/\\/g, '/').replace(/\/+$/, ''); } export function findConflictingPath( newPath: string, existingPaths: string[] ): PathConflict | null { const normalizedNew = normalizePathForComparison(newPath); for (const existing of existingPaths) { const normalizedExisting = normalizePathForComparison(existing); if (normalizedNew.startsWith(normalizedExisting + '/')) { return { path: existing, type: 'parent' }; } if (normalizedExisting.startsWith(normalizedNew + '/')) { return { path: existing, type: 'child' }; } } return null; } export function getFolderName(p: string): string { const normalized = normalizePathForDisplay(p); const segments = normalized.split('/'); return segments[segments.length - 1] || normalized; } export interface ExternalContextDisplayEntry { contextRoot: string; displayName: string; displayNameLower: string; } function getContextDisplayName( normalizedPath: string, folderName: string, needsDisambiguation: boolean ): string { if (!needsDisambiguation) return folderName; const segments = normalizedPath.split('/').filter(Boolean); if (segments.length < 2) return folderName; const parent = segments[segments.length - 2]; if (!parent) return folderName; return `${parent}/${folderName}`; } export function buildExternalContextDisplayEntries( externalContexts: string[] ): ExternalContextDisplayEntry[] { const counts = new Map(); const normalizedPaths = new Map(); for (const contextPath of externalContexts) { const normalized = normalizePathForComparison(contextPath); normalizedPaths.set(contextPath, normalized); const folderName = getFolderName(normalized); counts.set(folderName, (counts.get(folderName) ?? 0) + 1); } return externalContexts.map(contextRoot => { const normalized = normalizedPaths.get(contextRoot) ?? normalizePathForComparison(contextRoot); const folderName = getFolderName(contextRoot); const needsDisambiguation = (counts.get(folderName) ?? 0) > 1; const displayName = getContextDisplayName(normalized, folderName, needsDisambiguation); return { contextRoot, displayName, displayNameLower: displayName.toLowerCase(), }; }); } export interface DirectoryValidationResult { valid: boolean; error?: string; } export function validateDirectoryPath(p: string): DirectoryValidationResult { try { const stats = fs.statSync(p); if (!stats.isDirectory()) { return { valid: false, error: 'Path exists but is not a directory' }; } return { valid: true }; } catch (err) { const error = err as NodeJS.ErrnoException; if (error.code === 'ENOENT') { return { valid: false, error: 'Path does not exist' }; } if (error.code === 'EACCES') { return { valid: false, error: 'Permission denied' }; } return { valid: false, error: `Cannot access path: ${error.message}` }; } } export function isValidDirectoryPath(p: string): boolean { return validateDirectoryPath(p).valid; } export function filterValidPaths(paths: string[]): string[] { return paths.filter(isValidDirectoryPath); } export function isDuplicatePath(newPath: string, existingPaths: string[]): boolean { const normalizedNew = normalizePathForComparison(newPath); return existingPaths.some(existing => normalizePathForComparison(existing) === normalizedNew); } ================================================ FILE: src/utils/externalContextScanner.ts ================================================ /** * Claudian - External Context Scanner * * Scans configured external context paths for files to include in @-mention dropdown. * Features: recursive scanning, caching, and error handling. */ import * as fs from 'fs'; import * as path from 'path'; import { normalizePathForFilesystem } from './path'; export interface ExternalContextFile { path: string; name: string; relativePath: string; contextRoot: string; /** In milliseconds */ mtime: number; } interface ScanCache { files: ExternalContextFile[]; timestamp: number; } const CACHE_TTL_MS = 30000; const MAX_FILES_PER_PATH = 1000; const MAX_DEPTH = 10; const SKIP_DIRECTORIES = new Set([ 'node_modules', '__pycache__', 'venv', '.venv', '.git', '.svn', '.hg', 'dist', 'build', 'out', '.next', '.nuxt', 'target', 'vendor', 'Pods', ]); class ExternalContextScanner { private cache = new Map(); scanPaths(externalContextPaths: string[]): ExternalContextFile[] { const allFiles: ExternalContextFile[] = []; const now = Date.now(); for (const contextPath of externalContextPaths) { const expandedPath = normalizePathForFilesystem(contextPath); const cached = this.cache.get(expandedPath); if (cached && now - cached.timestamp < CACHE_TTL_MS) { allFiles.push(...cached.files); continue; } const files = this.scanDirectory(expandedPath, expandedPath, 0); this.cache.set(expandedPath, { files, timestamp: now }); allFiles.push(...files); } return allFiles; } private scanDirectory( dir: string, contextRoot: string, depth: number ): ExternalContextFile[] { if (depth > MAX_DEPTH) return []; const files: ExternalContextFile[] = []; try { if (!fs.existsSync(dir)) return []; const stat = fs.statSync(dir); if (!stat.isDirectory()) return []; const entries = fs.readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { if (entry.name.startsWith('.')) continue; if (SKIP_DIRECTORIES.has(entry.name)) continue; // Symlinks can cause infinite recursion and directory escape if (entry.isSymbolicLink()) continue; const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { const subFiles = this.scanDirectory(fullPath, contextRoot, depth + 1); files.push(...subFiles); } else if (entry.isFile()) { try { const fileStat = fs.statSync(fullPath); files.push({ path: fullPath, name: entry.name, relativePath: path.relative(contextRoot, fullPath), contextRoot, mtime: fileStat.mtimeMs, }); } catch { // Inaccessible file } } if (files.length >= MAX_FILES_PER_PATH) break; } } catch { // Inaccessible directory } return files; } invalidateCache(): void { this.cache.clear(); } invalidatePath(contextPath: string): void { const expandedPath = normalizePathForFilesystem(contextPath); this.cache.delete(expandedPath); } } export const externalContextScanner = new ExternalContextScanner(); ================================================ FILE: src/utils/fileLink.ts ================================================ /** * Claudian - File Link Utilities * * Detects Obsidian wikilinks [[path/to/file]] in rendered content and makes * them clickable to open the file in Obsidian. */ import type { App, Component } from 'obsidian'; /** * Regex pattern to match Obsidian wikilinks in text content. * * Matches: * - Standard wikilinks: [[note]] or [[folder/note]] * - Wikilinks with display text: [[note|display text]] * - Wikilinks with headings: [[note#heading]] * - Wikilinks with block references: [[note^block]] * * Does NOT match image embeds ![[image.png]] (those are handled separately). */ const WIKILINK_PATTERN_SOURCE = '(?= 0 ? inner.slice(0, pipeIndex) : inner; } /** * Finds all wikilinks in text that exist in the vault. * Sorted by index descending for end-to-start processing. */ function findWikilinks(app: App, text: string): WikilinkMatch[] { const pattern = createWikilinkPattern(); const matches: WikilinkMatch[] = []; let match: RegExpExecArray | null; while ((match = pattern.exec(text)) !== null) { const fullMatch = match[0]; const linkPath = match[1]; const linkTarget = extractLinkTarget(fullMatch); if (!fileExistsInVault(app, linkPath)) continue; const pipeIndex = fullMatch.lastIndexOf('|'); const displayText = pipeIndex > 0 ? fullMatch.slice(pipeIndex + 1, -2) : linkPath; matches.push({ index: match.index, fullMatch, linkPath, linkTarget, displayText }); } return matches.sort((a, b) => b.index - a.index); } function fileExistsInVault(app: App, linkPath: string): boolean { const file = app.metadataCache.getFirstLinkpathDest(linkPath, ''); if (file) { return true; } const directFile = app.vault.getFileByPath(linkPath); if (directFile) { return true; } if (!linkPath.endsWith('.md')) { const withExt = app.vault.getFileByPath(linkPath + '.md'); if (withExt) { return true; } } return false; } /** * Creates a link element for a wikilink. * Click handling is done via event delegation in registerFileLinkHandler. */ function createWikilink( linkTarget: string, displayText: string ): HTMLElement { const link = document.createElement('a'); link.className = 'claudian-file-link internal-link'; link.textContent = displayText; link.setAttribute('data-href', linkTarget); link.setAttribute('href', linkTarget); return link; } /** * Registers a delegated click handler for file links on a container. * Should be called once on the messages container. * Handles both our custom .claudian-file-link and Obsidian's .internal-link. */ export function registerFileLinkHandler( app: App, container: HTMLElement, component: Component ): void { component.registerDomEvent(container, 'click', (event: MouseEvent) => { const target = event.target as HTMLElement; // Handle both our links and Obsidian's internal links const link = target.closest('.claudian-file-link, .internal-link') as HTMLAnchorElement; if (link) { event.preventDefault(); const linkTarget = link.dataset.href || link.getAttribute('href'); if (linkTarget) { void app.workspace.openLinkText(linkTarget, '', 'tab'); } } }); } function buildFragmentWithLinks(text: string, matches: WikilinkMatch[]): DocumentFragment { const fragment = document.createDocumentFragment(); let currentIndex = text.length; for (const { index, fullMatch, linkTarget, displayText } of matches) { const endIndex = index + fullMatch.length; if (endIndex < currentIndex) { fragment.insertBefore( document.createTextNode(text.slice(endIndex, currentIndex)), fragment.firstChild ); } fragment.insertBefore(createWikilink(linkTarget, displayText), fragment.firstChild); currentIndex = index; } if (currentIndex > 0) { fragment.insertBefore( document.createTextNode(text.slice(0, currentIndex)), fragment.firstChild ); } return fragment; } function processTextNode(app: App, node: Text): boolean { const text = node.textContent; if (!text || !text.includes('[[')) return false; const matches = findWikilinks(app, text); if (matches.length === 0) return false; node.parentNode?.replaceChild(buildFragmentWithLinks(text, matches), node); return true; } /** * Call after MarkdownRenderer.renderMarkdown(). * Catches wikilinks that Obsidian's renderer doesn't process (e.g., in code blocks). */ export function processFileLinks(app: App, container: HTMLElement): void { if (!app || !container) return; // Wikilinks in inline code aren't rendered by Obsidian's MarkdownRenderer container.querySelectorAll('code').forEach((codeEl) => { if (codeEl.parentElement?.tagName === 'PRE') return; const text = codeEl.textContent; if (!text || !text.includes('[[')) return; const matches = findWikilinks(app, text); if (matches.length === 0) return; codeEl.textContent = ''; codeEl.appendChild(buildFragmentWithLinks(text, matches)); }); const walker = document.createTreeWalker( container, NodeFilter.SHOW_TEXT, { acceptNode(node) { const parent = node.parentElement; if (!parent) return NodeFilter.FILTER_REJECT; const tagName = parent.tagName.toUpperCase(); if (tagName === 'PRE' || tagName === 'CODE' || tagName === 'A') { return NodeFilter.FILTER_REJECT; } if (parent.closest('pre, code, a, .claudian-file-link, .internal-link')) { return NodeFilter.FILTER_REJECT; } return NodeFilter.FILTER_ACCEPT; }, } ); // Modifying DOM while walking causes issues, so collect first const textNodes: Text[] = []; let node: Node | null; while ((node = walker.nextNode())) { textNodes.push(node as Text); } for (const textNode of textNodes) { processTextNode(app, textNode); } } ================================================ FILE: src/utils/frontmatter.ts ================================================ import { parseYaml } from 'obsidian'; const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/; const VALID_KEY_PATTERN = /^[\w-]+$/; function isValidKey(key: string): boolean { return key.length > 0 && VALID_KEY_PATTERN.test(key); } function unquote(value: string): string { if ( (value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")) ) { return value.slice(1, -1); } return value; } function parseScalarValue(rawValue: string): unknown { const value = rawValue.trim(); if (value === 'true') return true; if (value === 'false') return false; if (value === 'null' || value === '') return null; if (!Number.isNaN(Number(value))) return Number(value); if (value.startsWith('[') && value.endsWith(']')) { return value .slice(1, -1) .split(',') .map(item => item.trim()) .filter(Boolean) .map(item => unquote(item)); } return unquote(value); } /** Handles malformed YAML (e.g. unquoted values with colons) by line-by-line key:value extraction. */ function parseFrontmatterFallback(yamlContent: string): Record { const result: Record = {}; const lines = yamlContent.split(/\r?\n/); let currentListKey: string | null = null; let currentList: unknown[] = []; function flushList(): void { if (!currentListKey) return; result[currentListKey] = currentList; currentListKey = null; currentList = []; } let pendingBareKey: string | null = null; for (const line of lines) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith('#')) continue; if (currentListKey) { if (trimmed.startsWith('- ')) { currentList.push(parseScalarValue(trimmed.slice(2))); continue; } flushList(); } if (pendingBareKey) { if (trimmed.startsWith('- ')) { currentListKey = pendingBareKey; currentList = []; pendingBareKey = null; currentList.push(parseScalarValue(trimmed.slice(2))); continue; } result[pendingBareKey] = ''; pendingBareKey = null; } const colonIndex = trimmed.indexOf(': '); if (colonIndex === -1) { if (trimmed.endsWith(':')) { const key = trimmed.slice(0, -1).trim(); if (isValidKey(key)) { pendingBareKey = key; } } continue; } const key = trimmed.slice(0, colonIndex).trim(); if (!isValidKey(key)) continue; result[key] = parseScalarValue(trimmed.slice(colonIndex + 2)); } if (pendingBareKey) { result[pendingBareKey] = ''; } flushList(); return result; } export function parseFrontmatter( content: string ): { frontmatter: Record; body: string } | null { const match = content.match(FRONTMATTER_PATTERN); if (!match) return null; try { const parsed = parseYaml(match[1]); if (parsed !== null && parsed !== undefined && typeof parsed !== 'object') { return null; } return { frontmatter: (parsed as Record) ?? {}, body: match[2], }; } catch { const fallbackParsed = parseFrontmatterFallback(match[1]); if (Object.keys(fallbackParsed).length > 0) { return { frontmatter: fallbackParsed, body: match[2], }; } return null; } } export function extractString( fm: Record, key: string ): string | undefined { const val = fm[key]; if (typeof val === 'string' && val.length > 0) return val; if (Array.isArray(val) && val.length > 0 && val.every(v => typeof v === 'string')) { return val.map(v => `[${v}]`).join(' '); } return undefined; } export function normalizeStringArray(val: unknown): string[] | undefined { if (val === undefined || val === null) return undefined; if (Array.isArray(val)) { return val.map(v => String(v).trim()).filter(Boolean); } if (typeof val === 'string') { const trimmed = val.trim(); if (!trimmed) return undefined; return trimmed.split(',').map(s => s.trim()).filter(Boolean); } return undefined; } export function extractStringArray( fm: Record, key: string ): string[] | undefined { return normalizeStringArray(fm[key]); } export function extractBoolean( fm: Record, key: string ): boolean | undefined { const val = fm[key]; if (typeof val === 'boolean') return val; return undefined; } export function isRecord(value: unknown): value is Record { return value != null && typeof value === 'object' && !Array.isArray(value); } const MAX_SLUG_LENGTH = 64; const SLUG_PATTERN = /^[a-z0-9-]+$/; const YAML_RESERVED_WORDS = new Set(['true', 'false', 'null', 'yes', 'no', 'on', 'off']); export function validateSlugName(name: string, label: string): string | null { if (!name) { return `${label} name is required`; } if (name.length > MAX_SLUG_LENGTH) { return `${label} name must be ${MAX_SLUG_LENGTH} characters or fewer`; } if (!SLUG_PATTERN.test(name)) { return `${label} name can only contain lowercase letters, numbers, and hyphens`; } if (YAML_RESERVED_WORDS.has(name)) { return `${label} name cannot be a YAML reserved word (true, false, null, yes, no, on, off)`; } return null; } ================================================ FILE: src/utils/imageEmbed.ts ================================================ /** * Claudian - Image Embed Utilities * * Replaces Obsidian image embeds ![[image.png]] with HTML tags * before MarkdownRenderer processes the content. * * Note: This is display-only - the agent still receives the wikilink text. */ import type { App, TFile } from 'obsidian'; import { escapeHtml } from './inlineEdit'; const IMAGE_EXTENSIONS = new Set([ 'png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico', ]); const IMAGE_EMBED_PATTERN = /!\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g; function isImagePath(path: string): boolean { const ext = path.split('.').pop()?.toLowerCase(); return ext ? IMAGE_EXTENSIONS.has(ext) : false; } function resolveImageFile( app: App, imagePath: string, mediaFolder: string ): TFile | null { let file = app.vault.getFileByPath(imagePath); if (file) return file; if (mediaFolder) { const withFolder = `${mediaFolder}/${imagePath}`; file = app.vault.getFileByPath(withFolder); if (file) return file; } const resolved = app.metadataCache.getFirstLinkpathDest(imagePath, ''); if (resolved) return resolved; return null; } /** Supports formats: "100" (width only) or "100x200" (width x height) */ function buildStyleAttribute(altText: string | undefined): string { if (!altText) return ''; const dimMatch = altText.match(/^(\d+)(?:x(\d+))?$/); if (!dimMatch) return ''; const width = dimMatch[1]; const height = dimMatch[2]; if (height) { return ` style="width: ${width}px; height: ${height}px;"`; } return ` style="width: ${width}px;"`; } function createImageHtml( app: App, file: TFile, altText: string | undefined ): string { const src = app.vault.getResourcePath(file); const alt = escapeHtml(altText || file.basename); const style = buildStyleAttribute(altText); return `${alt}`; } function createFallbackHtml(wikilink: string): string { return `${escapeHtml(wikilink)}`; } /** * Call before MarkdownRenderer.renderMarkdown(). * Non-image embeds (e.g., ![[note.md]]) pass through unchanged. */ export function replaceImageEmbedsWithHtml( markdown: string, app: App, mediaFolder: string = '' ): string { if (!app?.vault || !app?.metadataCache) { return markdown; } // Reset lastIndex to avoid issues with global regex IMAGE_EMBED_PATTERN.lastIndex = 0; return markdown.replace( IMAGE_EMBED_PATTERN, (match, imagePath: string, altText: string | undefined) => { try { if (!isImagePath(imagePath)) { return match; } const file = resolveImageFile(app, imagePath, mediaFolder); if (!file) { return createFallbackHtml(match); } return createImageHtml(app, file, altText); } catch { return createFallbackHtml(match); } } ); } ================================================ FILE: src/utils/inlineEdit.ts ================================================ /** * Utilities for inline edit UI. * Kept dependency-free so tests can import directly. */ /** * Trims leading and trailing blank lines from insertion text. * Matches the behavior expected by cursor insertion preview. */ export function normalizeInsertionText(text: string): string { return text.replace(/^(?:\r?\n)+|(?:\r?\n)+$/g, ''); } /** Escapes HTML special characters to prevent injection in rendered content. */ export function escapeHtml(text: string): string { return text .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } ================================================ FILE: src/utils/interrupt.ts ================================================ const INTERRUPT_MARKERS = new Set([ '[Request interrupted by user]', '[Request interrupted by user for tool use]', ]); const COMPACTION_CANCELED_STDERR_PATTERN = /^\s*Error:\s*Compaction canceled\.?\s*<\/local-command-stderr>$/i; function normalize(text: string): string { return text.trim(); } export function isBracketInterruptText(text: string): boolean { return INTERRUPT_MARKERS.has(normalize(text)); } export function isCompactionCanceledStderr(text: string): boolean { return COMPACTION_CANCELED_STDERR_PATTERN.test(normalize(text)); } export function isInterruptSignalText(text: string): boolean { return isBracketInterruptText(text) || isCompactionCanceledStderr(text); } ================================================ FILE: src/utils/markdown.ts ================================================ /** * Claudian - Markdown Utilities * * Markdown manipulation helpers. */ /** Appends a Markdown snippet to an existing prompt with sensible spacing. */ export function appendMarkdownSnippet(existingPrompt: string, snippet: string): string { const trimmedSnippet = snippet.trim(); if (!trimmedSnippet) { return existingPrompt; } if (!existingPrompt.trim()) { return trimmedSnippet; } const separator = existingPrompt.endsWith('\n\n') ? '' : existingPrompt.endsWith('\n') ? '\n' : '\n\n'; return existingPrompt + separator + trimmedSnippet; } ================================================ FILE: src/utils/mcp.ts ================================================ export function extractMcpMentions(text: string, validNames: Set): Set { const mentions = new Set(); const regex = /@([a-zA-Z0-9._-]+)(?!\/)/g; let match: RegExpExecArray | null; while ((match = regex.exec(text)) !== null) { const name = match[1]; if (validNames.has(name)) { mentions.add(name); } } return mentions; } /** * Transform MCP mentions in text by appending " MCP" after each valid @mention. * This is applied to the API request only, not shown in the input. */ export function transformMcpMentions(text: string, validNames: Set): string { if (validNames.size === 0) return text; // Sort names by length (longest first) to avoid partial matches const sortedNames = Array.from(validNames).sort((a, b) => b.length - a.length); // Build single pattern with alternation (more efficient than N passes) const escapedNames = sortedNames.map(escapeRegExp).join('|'); // Match @name that: // - is not already followed by " MCP" // - is not followed by "/" (context folder) // - is not followed by alphanumeric/underscore/hyphen (partial match) // - is not followed by "." + word char (e.g., @test in @test.server) // This allows @server. (period as punctuation) while preventing @test.foo matches const pattern = new RegExp( `@(${escapedNames})(?! MCP)(?!/)(?![a-zA-Z0-9_-])(?!\\.[a-zA-Z0-9_-])`, 'g' ); return text.replace(pattern, '@$1 MCP'); } function escapeRegExp(str: string): string { return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } export function parseCommand(command: string, providedArgs?: string[]): { cmd: string; args: string[] } { if (providedArgs && providedArgs.length > 0) { return { cmd: command, args: providedArgs }; } const parts = splitCommandString(command); if (parts.length === 0) { return { cmd: '', args: [] }; } return { cmd: parts[0], args: parts.slice(1) }; } export function splitCommandString(cmdStr: string): string[] { const parts: string[] = []; let current = ''; let inQuote = false; let quoteChar = ''; for (let i = 0; i < cmdStr.length; i++) { const char = cmdStr[i]; if ((char === '"' || char === "'") && !inQuote) { inQuote = true; quoteChar = char; continue; } if (char === quoteChar && inQuote) { inQuote = false; quoteChar = ''; continue; } if (/\s/.test(char) && !inQuote) { if (current) { parts.push(current); current = ''; } continue; } current += char; } if (current) { parts.push(current); } return parts; } ================================================ FILE: src/utils/path.ts ================================================ /** * Claudian - Path Utilities * * Path resolution, validation, and access control for vault operations. */ import * as fs from 'fs'; import type { App } from 'obsidian'; import * as os from 'os'; import * as path from 'path'; // ============================================ // Vault Path // ============================================ export function getVaultPath(app: App): string | null { const adapter = app.vault.adapter; if ('basePath' in adapter) { return (adapter as any).basePath; } return null; } // ============================================ // Home Path Expansion // ============================================ function getEnvValue(key: string): string | undefined { const hasKey = (name: string) => Object.prototype.hasOwnProperty.call(process.env, name); if (hasKey(key)) { return process.env[key]; } if (process.platform !== 'win32') { return undefined; } const upper = key.toUpperCase(); if (hasKey(upper)) { return process.env[upper]; } const lower = key.toLowerCase(); if (hasKey(lower)) { return process.env[lower]; } const matchKey = Object.keys(process.env).find((name) => name.toLowerCase() === key.toLowerCase()); return matchKey ? process.env[matchKey] : undefined; } function expandEnvironmentVariables(value: string): string { if (!value.includes('%') && !value.includes('$') && !value.includes('!')) { return value; } const isWindows = process.platform === 'win32'; let expanded = value; // Windows %VAR% format - allow parentheses for vars like %ProgramFiles(x86)% expanded = expanded.replace(/%([A-Za-z_][A-Za-z0-9_]*(?:\([A-Za-z0-9_]+\))?[A-Za-z0-9_]*)%/g, (match, name) => { const envValue = getEnvValue(name); return envValue !== undefined ? envValue : match; }); if (isWindows) { expanded = expanded.replace(/!([A-Za-z_][A-Za-z0-9_]*)!/g, (match, name) => { const envValue = getEnvValue(name); return envValue !== undefined ? envValue : match; }); expanded = expanded.replace(/\$env:([A-Za-z_][A-Za-z0-9_]*)/gi, (match, name) => { const envValue = getEnvValue(name); return envValue !== undefined ? envValue : match; }); } expanded = expanded.replace(/\$([A-Za-z_][A-Za-z0-9_]*)|\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (match, name1, name2) => { const key = name1 ?? name2; if (!key) return match; const envValue = getEnvValue(key); return envValue !== undefined ? envValue : match; }); return expanded; } /** * Expands home directory notation to absolute path. * Handles both ~/path and ~\path formats. */ export function expandHomePath(p: string): string { const expanded = expandEnvironmentVariables(p); if (expanded === '~') { return os.homedir(); } if (expanded.startsWith('~/')) { return path.join(os.homedir(), expanded.slice(2)); } if (expanded.startsWith('~\\')) { return path.join(os.homedir(), expanded.slice(2)); } return expanded; } // ============================================ // Claude CLI Detection // ============================================ function stripSurroundingQuotes(value: string): string { if ( (value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")) ) { return value.slice(1, -1); } return value; } export function parsePathEntries(pathValue?: string): string[] { if (!pathValue) { return []; } const delimiter = process.platform === 'win32' ? ';' : ':'; return pathValue .split(delimiter) .map(segment => stripSurroundingQuotes(segment.trim())) .filter(segment => { if (!segment) return false; const upper = segment.toUpperCase(); return upper !== '$PATH' && upper !== '${PATH}' && upper !== '%PATH%'; }) .map(segment => translateMsysPath(expandHomePath(segment))); } function dedupePaths(entries: string[]): string[] { const seen = new Set(); return entries.filter(entry => { const key = process.platform === 'win32' ? entry.toLowerCase() : entry; if (seen.has(key)) return false; seen.add(key); return true; }); } function findFirstExistingPath(entries: string[], candidates: string[]): string | null { for (const dir of entries) { if (!dir) continue; for (const candidate of candidates) { const fullPath = path.join(dir, candidate); if (isExistingFile(fullPath)) { return fullPath; } } } return null; } function isExistingFile(filePath: string): boolean { try { if (fs.existsSync(filePath)) { const stat = fs.statSync(filePath); return stat.isFile(); } } catch { // Inaccessible path } return false; } function resolveCliJsNearPathEntry(entry: string, isWindows: boolean): string | null { const directCandidate = path.join(entry, 'node_modules', '@anthropic-ai', 'claude-code', 'cli.js'); if (isExistingFile(directCandidate)) { return directCandidate; } const baseName = path.basename(entry).toLowerCase(); if (baseName === 'bin') { const prefix = path.dirname(entry); const candidate = isWindows ? path.join(prefix, 'node_modules', '@anthropic-ai', 'claude-code', 'cli.js') : path.join(prefix, 'lib', 'node_modules', '@anthropic-ai', 'claude-code', 'cli.js'); if (isExistingFile(candidate)) { return candidate; } } return null; } function resolveCliJsFromPathEntries(entries: string[], isWindows: boolean): string | null { for (const entry of entries) { const candidate = resolveCliJsNearPathEntry(entry, isWindows); if (candidate) { return candidate; } } return null; } function resolveClaudeFromPathEntries( entries: string[], isWindows: boolean ): string | null { if (entries.length === 0) { return null; } if (!isWindows) { const unixCandidate = findFirstExistingPath(entries, ['claude']); return unixCandidate; } const exeCandidate = findFirstExistingPath(entries, ['claude.exe', 'claude']); if (exeCandidate) { return exeCandidate; } const cliJsCandidate = resolveCliJsFromPathEntries(entries, isWindows); if (cliJsCandidate) { return cliJsCandidate; } return null; } function getNpmGlobalPrefix(): string | null { if (process.env.npm_config_prefix) { return process.env.npm_config_prefix; } if (process.platform === 'win32') { const appDataNpm = process.env.APPDATA ? path.join(process.env.APPDATA, 'npm') : null; if (appDataNpm && fs.existsSync(appDataNpm)) { return appDataNpm; } } return null; } function getNpmCliJsPaths(): string[] { const homeDir = os.homedir(); const isWindows = process.platform === 'win32'; const cliJsPaths: string[] = []; if (isWindows) { cliJsPaths.push( path.join(homeDir, 'AppData', 'Roaming', 'npm', 'node_modules', '@anthropic-ai', 'claude-code', 'cli.js') ); const npmPrefix = getNpmGlobalPrefix(); if (npmPrefix) { cliJsPaths.push( path.join(npmPrefix, 'node_modules', '@anthropic-ai', 'claude-code', 'cli.js') ); } const programFiles = process.env.ProgramFiles || 'C:\\Program Files'; const programFilesX86 = process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)'; cliJsPaths.push( path.join(programFiles, 'nodejs', 'node_global', 'node_modules', '@anthropic-ai', 'claude-code', 'cli.js'), path.join(programFilesX86, 'nodejs', 'node_global', 'node_modules', '@anthropic-ai', 'claude-code', 'cli.js') ); cliJsPaths.push( path.join('D:', 'Program Files', 'nodejs', 'node_global', 'node_modules', '@anthropic-ai', 'claude-code', 'cli.js') ); } else { cliJsPaths.push( path.join(homeDir, '.npm-global', 'lib', 'node_modules', '@anthropic-ai', 'claude-code', 'cli.js'), '/usr/local/lib/node_modules/@anthropic-ai/claude-code/cli.js', '/usr/lib/node_modules/@anthropic-ai/claude-code/cli.js' ); if (process.env.npm_config_prefix) { cliJsPaths.push( path.join(process.env.npm_config_prefix, 'lib', 'node_modules', '@anthropic-ai', 'claude-code', 'cli.js') ); } } return cliJsPaths; } /** * Resolves an nvm alias to a version string by following the alias chain. * e.g., "default" → "lts/*" → "lts/jod" → "v22.18.0" → "22" */ const NVM_LATEST_INSTALLED_ALIASES = new Set(['node', 'stable']); function isNvmBuiltInLatestAlias(alias: string): boolean { return NVM_LATEST_INSTALLED_ALIASES.has(alias); } function findMatchingNvmVersion(entries: string[], resolvedAlias: string): string | undefined { if (isNvmBuiltInLatestAlias(resolvedAlias)) { return entries[0]; } const version = resolvedAlias.replace(/^v/, ''); return entries.find(entry => { const entryVersion = entry.slice(1); // strip 'v' return entryVersion === version || entryVersion.startsWith(version + '.'); }); } function resolveNvmAlias(nvmDir: string, alias: string, depth = 0): string | null { if (depth > 5) return null; // If it looks like a version already (e.g., "v22.18.0" or "22"), return it if (/^\d/.test(alias) || alias.startsWith('v')) return alias; if (isNvmBuiltInLatestAlias(alias)) return alias; try { const aliasFile = path.join(nvmDir, 'alias', ...alias.split('/')); const target = fs.readFileSync(aliasFile, 'utf8').trim(); if (!target) return null; return resolveNvmAlias(nvmDir, target, depth + 1); } catch { return null; } } /** * Resolves the bin directory for nvm's default Node version from the filesystem. * GUI apps don't have NVM_BIN set, so we read ~/.nvm/alias/default and match * against installed versions in ~/.nvm/versions/node/. */ export function resolveNvmDefaultBin(home: string): string | null { const nvmDir = process.env.NVM_DIR || path.join(home, '.nvm'); try { const alias = fs.readFileSync(path.join(nvmDir, 'alias', 'default'), 'utf8').trim(); if (!alias) return null; const resolved = resolveNvmAlias(nvmDir, alias); if (!resolved) return null; const versionsDir = path.join(nvmDir, 'versions', 'node'); const entries = fs.readdirSync(versionsDir) .filter(entry => entry.startsWith('v')) .sort((a, b) => b.localeCompare(a, undefined, { numeric: true })); const matched = findMatchingNvmVersion(entries, resolved); if (matched) { const binDir = path.join(versionsDir, matched, 'bin'); if (fs.existsSync(binDir)) return binDir; } } catch { // Expected when nvm is not installed } return null; } export function findClaudeCLIPath(pathValue?: string): string | null { const homeDir = os.homedir(); const isWindows = process.platform === 'win32'; const customEntries = dedupePaths(parsePathEntries(pathValue)); if (customEntries.length > 0) { const customResolution = resolveClaudeFromPathEntries(customEntries, isWindows); if (customResolution) { return customResolution; } } // On Windows, prefer native .exe, then cli.js. Avoid .cmd fallback // because it requires shell: true and breaks SDK stdio streaming. if (isWindows) { const exePaths: string[] = [ path.join(homeDir, '.claude', 'local', 'claude.exe'), path.join(homeDir, 'AppData', 'Local', 'Claude', 'claude.exe'), path.join(process.env.ProgramFiles || 'C:\\Program Files', 'Claude', 'claude.exe'), path.join(process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)', 'Claude', 'claude.exe'), path.join(homeDir, '.local', 'bin', 'claude.exe'), ]; for (const p of exePaths) { if (isExistingFile(p)) { return p; } } const cliJsPaths = getNpmCliJsPaths(); for (const p of cliJsPaths) { if (isExistingFile(p)) { return p; } } } const commonPaths: string[] = [ path.join(homeDir, '.claude', 'local', 'claude'), path.join(homeDir, '.local', 'bin', 'claude'), path.join(homeDir, '.volta', 'bin', 'claude'), path.join(homeDir, '.asdf', 'shims', 'claude'), path.join(homeDir, '.asdf', 'bin', 'claude'), '/usr/local/bin/claude', '/opt/homebrew/bin/claude', path.join(homeDir, 'bin', 'claude'), path.join(homeDir, '.npm-global', 'bin', 'claude'), ]; const npmPrefix = getNpmGlobalPrefix(); if (npmPrefix) { commonPaths.push(path.join(npmPrefix, 'bin', 'claude')); } // NVM: resolve default version bin when NVM_BIN env var is not available (GUI apps) const nvmBin = resolveNvmDefaultBin(homeDir); if (nvmBin) { commonPaths.push(path.join(nvmBin, 'claude')); } for (const p of commonPaths) { if (isExistingFile(p)) { return p; } } if (!isWindows) { const cliJsPaths = getNpmCliJsPaths(); for (const p of cliJsPaths) { if (isExistingFile(p)) { return p; } } } const envEntries = dedupePaths(parsePathEntries(getEnvValue('PATH'))); if (envEntries.length > 0) { const envResolution = resolveClaudeFromPathEntries(envEntries, isWindows); if (envResolution) { return envResolution; } } return null; } // ============================================ // Path Resolution // ============================================ /** * Best-effort realpath that stays symlink-aware even when the target does not exist. * * If the full path doesn't exist, resolve the nearest existing ancestor via realpath * and then re-append the remaining path segments. */ function resolveRealPath(p: string): string { const realpathFn = (fs.realpathSync.native ?? fs.realpathSync) as (path: fs.PathLike) => string; try { return realpathFn(p); } catch { const absolute = path.resolve(p); let current = absolute; const suffix: string[] = []; // eslint-disable-next-line no-constant-condition while (true) { try { if (fs.existsSync(current)) { const resolvedExisting = realpathFn(current); return suffix.length > 0 ? path.join(resolvedExisting, ...suffix.reverse()) : resolvedExisting; } } catch { // Ignore and keep walking up the directory tree. } const parent = path.dirname(current); if (parent === current) { return absolute; } suffix.push(path.basename(current)); current = parent; } } } /** * Translates MSYS/Git Bash paths to Windows paths. * E.g., /c/Users/... → C:\Users\... * * This must be called BEFORE path.resolve() or path.isAbsolute() checks, * as those functions don't recognize MSYS-style drive paths. */ export function translateMsysPath(value: string): string { if (process.platform !== 'win32') { return value; } // Match /c/... or /C/... (single letter drive) const msysMatch = value.match(/^\/([a-zA-Z])(\/.*)?$/); if (msysMatch) { const driveLetter = msysMatch[1].toUpperCase(); const restOfPath = msysMatch[2] ?? ''; // Convert forward slashes to backslashes for the rest of the path return `${driveLetter}:${restOfPath.replace(/\//g, '\\')}`; } return value; } /** * Normalizes a path for cross-platform use before resolution. * Handles MSYS path translation and home directory expansion. * Call this before path.resolve() or path.isAbsolute() checks. */ function normalizePathBeforeResolution(p: string): string { // First expand environment variables and home path const expanded = expandHomePath(p); // Then translate MSYS paths on Windows (must happen before path.resolve) return translateMsysPath(expanded); } function normalizeWindowsPathPrefix(value: string): string { if (process.platform !== 'win32') { return value; } // First translate MSYS/Git Bash paths const normalized = translateMsysPath(value); if (normalized.startsWith('\\\\?\\UNC\\')) { return `\\\\${normalized.slice('\\\\?\\UNC\\'.length)}`; } if (normalized.startsWith('\\\\?\\')) { return normalized.slice('\\\\?\\'.length); } return normalized; } /** * Normalizes a path for filesystem operations (expand env/home, translate MSYS, strip Windows prefixes). * This is the main entry point for path normalization before file operations. */ export function normalizePathForFilesystem(value: string): string { if (!value || typeof value !== 'string') { return ''; } const expanded = normalizePathBeforeResolution(value); let normalized = expanded; try { normalized = process.platform === 'win32' ? path.win32.normalize(expanded) : path.normalize(expanded); } catch { normalized = expanded; } return normalizeWindowsPathPrefix(normalized); } /** * Normalizes a path for comparison (case-insensitive on Windows, slashes normalized, trailing slash removed). * This is the main entry point for path comparisons and should be used consistently across modules. */ export function normalizePathForComparison(value: string): string { if (!value || typeof value !== 'string') { return ''; } const expanded = normalizePathBeforeResolution(value); let normalized = expanded; try { normalized = process.platform === 'win32' ? path.win32.normalize(expanded) : path.normalize(expanded); } catch { normalized = expanded; } normalized = normalizeWindowsPathPrefix(normalized); normalized = normalized.replace(/\\/g, '/').replace(/\/+$/, ''); return process.platform === 'win32' ? normalized.toLowerCase() : normalized; } // ============================================ // Path Access Control // ============================================ export function isPathWithinVault(candidatePath: string, vaultPath: string): boolean { const vaultReal = normalizePathForComparison(resolveRealPath(vaultPath)); const normalizedPath = normalizePathBeforeResolution(candidatePath); const absCandidate = path.isAbsolute(normalizedPath) ? normalizedPath : path.resolve(vaultPath, normalizedPath); const resolvedCandidate = normalizePathForComparison(resolveRealPath(absCandidate)); return resolvedCandidate === vaultReal || resolvedCandidate.startsWith(vaultReal + '/'); } export function normalizePathForVault( rawPath: string | undefined | null, vaultPath: string | null | undefined ): string | null { if (!rawPath) return null; const normalizedRaw = normalizePathForFilesystem(rawPath); if (!normalizedRaw) return null; if (vaultPath && isPathWithinVault(normalizedRaw, vaultPath)) { const absolute = path.isAbsolute(normalizedRaw) ? normalizedRaw : path.resolve(vaultPath, normalizedRaw); const relative = path.relative(vaultPath, absolute); return relative ? relative.replace(/\\/g, '/') : null; } return normalizedRaw.replace(/\\/g, '/'); } export function isPathInAllowedExportPaths( candidatePath: string, allowedExportPaths: string[], vaultPath: string ): boolean { if (!allowedExportPaths || allowedExportPaths.length === 0) { return false; } const normalizedCandidate = normalizePathBeforeResolution(candidatePath); const absCandidate = path.isAbsolute(normalizedCandidate) ? normalizedCandidate : path.resolve(vaultPath, normalizedCandidate); const resolvedCandidate = normalizePathForComparison(resolveRealPath(absCandidate)); for (const exportPath of allowedExportPaths) { const normalizedExport = normalizePathBeforeResolution(exportPath); const resolvedExport = normalizePathForComparison(resolveRealPath(normalizedExport)); if ( resolvedCandidate === resolvedExport || resolvedCandidate.startsWith(resolvedExport + '/') ) { return true; } } return false; } export type PathAccessType = 'vault' | 'readwrite' | 'context' | 'export' | 'none'; /** * Resolve access type for a candidate path with context/export overlap handling. * The most specific matching root wins; exact context+export matches are read-write. */ export function getPathAccessType( candidatePath: string, allowedContextPaths: string[] | undefined, allowedExportPaths: string[] | undefined, vaultPath: string ): PathAccessType { if (!candidatePath) return 'none'; const vaultReal = normalizePathForComparison(resolveRealPath(vaultPath)); const normalizedCandidate = normalizePathBeforeResolution(candidatePath); const absCandidate = path.isAbsolute(normalizedCandidate) ? normalizedCandidate : path.resolve(vaultPath, normalizedCandidate); const resolvedCandidate = normalizePathForComparison(resolveRealPath(absCandidate)); if (resolvedCandidate === vaultReal || resolvedCandidate.startsWith(vaultReal + '/')) { return 'vault'; } // Allow access to specific safe subdirectories under ~/.claude/ const claudeDir = normalizePathForComparison(resolveRealPath(path.join(os.homedir(), '.claude'))); if (resolvedCandidate === claudeDir || resolvedCandidate.startsWith(claudeDir + '/')) { const safeSubdirs = ['sessions', 'projects', 'commands', 'agents', 'skills', 'plans']; const safeFiles = ['mcp.json', 'settings.json', 'settings.local.json', 'claudian-settings.json']; const relativeToClaude = resolvedCandidate.slice(claudeDir.length + 1); if (!relativeToClaude) { // ~/.claude/ itself — read-only return 'context'; } const topSegment = relativeToClaude.split('/')[0]; if (safeSubdirs.includes(topSegment) || safeFiles.includes(topSegment)) { return 'vault'; } // Other paths under ~/.claude/ are read-only return 'context'; } const roots = new Map(); const addRoot = (rawPath: string, kind: 'context' | 'export') => { const trimmed = rawPath.trim(); if (!trimmed) return; const normalized = normalizePathBeforeResolution(trimmed); const resolved = normalizePathForComparison(resolveRealPath(normalized)); const existing = roots.get(resolved) ?? { context: false, export: false }; existing[kind] = true; roots.set(resolved, existing); }; for (const contextPath of allowedContextPaths ?? []) { addRoot(contextPath, 'context'); } for (const exportPath of allowedExportPaths ?? []) { addRoot(exportPath, 'export'); } let bestRoot: string | null = null; let bestFlags: { context: boolean; export: boolean } | null = null; for (const [root, flags] of roots) { if (resolvedCandidate === root || resolvedCandidate.startsWith(root + '/')) { if (!bestRoot || root.length > bestRoot.length) { bestRoot = root; bestFlags = flags; } } } if (!bestRoot || !bestFlags) return 'none'; if (bestFlags.context && bestFlags.export) return 'readwrite'; if (bestFlags.context) return 'context'; if (bestFlags.export) return 'export'; return 'none'; } ================================================ FILE: src/utils/sdkSession.ts ================================================ /** * SDK Session Parser - Parses Claude Agent SDK native session files. * * The SDK stores sessions in ~/.claude/projects/{vault-path-encoded}/{sessionId}.jsonl * Each line is a JSON object with message data. * * This utility converts SDK native messages to Claudian's ChatMessage format * for displaying conversation history from native sessions. */ import { existsSync } from 'fs'; import * as fs from 'fs/promises'; import * as os from 'os'; import * as path from 'path'; import { extractToolResultContent } from '../core/sdk/toolResultContent'; import { extractResolvedAnswers, extractResolvedAnswersFromResultText } from '../core/tools'; import { isSubagentToolName, TOOL_ASK_USER_QUESTION } from '../core/tools/toolNames'; import type { AsyncSubagentStatus, ChatMessage, ContentBlock, ImageAttachment, ImageMediaType, SubagentInfo, ToolCallInfo, } from '../core/types'; import { extractContentBeforeXmlContext } from './context'; import { extractDiffData } from './diff'; import { isCompactionCanceledStderr, isInterruptSignalText } from './interrupt'; import { extractFinalResultFromSubagentJsonl } from './subagentJsonl'; export interface SDKSessionReadResult { messages: SDKNativeMessage[]; skippedLines: number; error?: string; } /** Stored in session JSONL files. Based on Claude Agent SDK internal format. */ export interface SDKNativeMessage { type: 'user' | 'assistant' | 'system' | 'result' | 'file-history-snapshot' | 'queue-operation'; parentUuid?: string | null; sessionId?: string; uuid?: string; timestamp?: string; /** Request ID groups assistant messages from the same API call. */ requestId?: string; message?: { role?: string; content?: string | SDKNativeContentBlock[]; model?: string; }; // Result message fields subtype?: string; duration_ms?: number; duration_api_ms?: number; /** Present on tool result user messages - contains the tool execution result. */ toolUseResult?: unknown; /** UUID of the assistant message that initiated this tool call. */ sourceToolAssistantUUID?: string; /** Tool use ID for injected content (e.g., skill prompt expansion). */ sourceToolUseID?: string; /** Meta messages are system-injected, not actual user input. */ isMeta?: boolean; /** Queue operation type (enqueue/dequeue) — present on queue-operation messages. */ operation?: string; /** Content string for queue-operation enqueue entries (e.g., task-notification XML). */ content?: string; } export interface SDKNativeContentBlock { type: 'text' | 'tool_use' | 'tool_result' | 'thinking' | 'image'; text?: string; thinking?: string; id?: string; name?: string; input?: Record; tool_use_id?: string; content?: string | unknown; is_error?: boolean; // Image block fields source?: { type: 'base64'; media_type: string; data: string; }; } /** * Encodes a vault path for the SDK project directory name. * The SDK replaces ALL non-alphanumeric characters with `-`. * This handles Unicode characters (Chinese, Japanese, etc.) and special chars (brackets, etc.). */ export function encodeVaultPathForSDK(vaultPath: string): string { const absolutePath = path.resolve(vaultPath); return absolutePath.replace(/[^a-zA-Z0-9]/g, '-'); } export function getSDKProjectsPath(): string { return path.join(os.homedir(), '.claude', 'projects'); } /** Validates a subagent agent ID to prevent path traversal attacks. */ function isValidAgentId(agentId: string): boolean { if (!agentId || agentId.length > 128) { return false; } if (agentId.includes('..') || agentId.includes('/') || agentId.includes('\\')) { return false; } return /^[a-zA-Z0-9_-]+$/.test(agentId); } type SubagentToolEvent = | { type: 'tool_use'; toolUseId: string; toolName: string; toolInput: Record; timestamp: number; } | { type: 'tool_result'; toolUseId: string; content: string; isError: boolean; timestamp: number; }; function parseTimestampMs(raw: unknown): number { if (typeof raw !== 'string') return Date.now(); const parsed = Date.parse(raw); return Number.isNaN(parsed) ? Date.now() : parsed; } function parseSubagentEvents(entry: unknown): SubagentToolEvent[] { if (!entry || typeof entry !== 'object') return []; const record = entry as { timestamp?: unknown; message?: { content?: unknown }; }; const content = record.message?.content; if (!Array.isArray(content)) return []; const timestamp = parseTimestampMs(record.timestamp); const events: SubagentToolEvent[] = []; for (const blockRaw of content) { if (!blockRaw || typeof blockRaw !== 'object') continue; const block = blockRaw as { type?: unknown; id?: unknown; name?: unknown; input?: unknown; tool_use_id?: unknown; content?: unknown; is_error?: unknown; }; if (block.type === 'tool_use') { if (typeof block.id !== 'string' || typeof block.name !== 'string') continue; events.push({ type: 'tool_use', toolUseId: block.id, toolName: block.name, toolInput: block.input && typeof block.input === 'object' ? (block.input as Record) : {}, timestamp, }); continue; } if (block.type === 'tool_result') { if (typeof block.tool_use_id !== 'string') continue; const contentText = extractToolResultContent(block.content); events.push({ type: 'tool_result', toolUseId: block.tool_use_id, content: contentText, isError: block.is_error === true, timestamp, }); } } return events; } function buildToolCallsFromSubagentEvents(events: SubagentToolEvent[]): ToolCallInfo[] { const toolsById = new Map< string, { toolCall: ToolCallInfo; hasToolUse: boolean; hasToolResult: boolean; timestamp: number; } >(); for (const event of events) { const existing = toolsById.get(event.toolUseId); if (event.type === 'tool_use') { if (!existing) { toolsById.set(event.toolUseId, { toolCall: { id: event.toolUseId, name: event.toolName, input: { ...event.toolInput }, status: 'running', isExpanded: false, }, hasToolUse: true, hasToolResult: false, timestamp: event.timestamp, }); } else { existing.toolCall.name = event.toolName; existing.toolCall.input = { ...event.toolInput }; existing.hasToolUse = true; existing.timestamp = event.timestamp; } continue; } if (!existing) { toolsById.set(event.toolUseId, { toolCall: { id: event.toolUseId, name: 'Unknown', input: {}, status: event.isError ? 'error' : 'completed', result: event.content, isExpanded: false, }, hasToolUse: false, hasToolResult: true, timestamp: event.timestamp, }); continue; } existing.toolCall.status = event.isError ? 'error' : 'completed'; existing.toolCall.result = event.content; existing.hasToolResult = true; } return Array.from(toolsById.values()) .filter(entry => entry.hasToolUse) .sort((a, b) => a.timestamp - b.timestamp) .map(entry => entry.toolCall); } function getSubagentSidecarPath( vaultPath: string, sessionId: string, agentId: string ): string | null { if (!isValidSessionId(sessionId) || !isValidAgentId(agentId)) { return null; } const encodedVault = encodeVaultPathForSDK(vaultPath); return path.join( getSDKProjectsPath(), encodedVault, sessionId, 'subagents', `agent-${agentId}.jsonl` ); } /** * Loads tool calls executed inside a subagent from SDK sidechain logs. * * File location: * ~/.claude/projects/{encoded-vault}/{sessionId}/subagents/agent-{agentId}.jsonl */ export async function loadSubagentToolCalls( vaultPath: string, sessionId: string, agentId: string ): Promise { const subagentFilePath = getSubagentSidecarPath(vaultPath, sessionId, agentId); if (!subagentFilePath) return []; try { if (!existsSync(subagentFilePath)) return []; const content = await fs.readFile(subagentFilePath, 'utf-8'); const lines = content.split('\n').filter(line => line.trim()); const events: SubagentToolEvent[] = []; const seen = new Set(); for (const line of lines) { let raw: unknown; try { raw = JSON.parse(line); } catch { continue; } for (const event of parseSubagentEvents(raw)) { const key = `${event.type}:${event.toolUseId}`; if (seen.has(key)) continue; seen.add(key); events.push(event); } } if (events.length === 0) return []; return buildToolCallsFromSubagentEvents(events); } catch { return []; } } /** * Loads the final textual result produced by a subagent from its sidecar JSONL. * Prefers the latest assistant text block; falls back to a top-level result field. */ export async function loadSubagentFinalResult( vaultPath: string, sessionId: string, agentId: string ): Promise { const subagentFilePath = getSubagentSidecarPath(vaultPath, sessionId, agentId); if (!subagentFilePath) return null; try { if (!existsSync(subagentFilePath)) return null; const content = await fs.readFile(subagentFilePath, 'utf-8'); return extractFinalResultFromSubagentJsonl(content); } catch { return null; } } /** * Validates a session ID to prevent path traversal attacks. * Accepts alphanumeric strings with hyphens and underscores (max 128 chars). * Common formats: SDK UUIDs, Claudian IDs (conv-TIMESTAMP-RANDOM). */ export function isValidSessionId(sessionId: string): boolean { if (!sessionId || sessionId.length === 0 || sessionId.length > 128) { return false; } // Reject path traversal attempts and path separators if (sessionId.includes('..') || sessionId.includes('/') || sessionId.includes('\\')) { return false; } // Allow only alphanumeric characters, hyphens, and underscores return /^[a-zA-Z0-9_-]+$/.test(sessionId); } /** * Gets the full path to an SDK session file. * * @param vaultPath - The vault's absolute path * @param sessionId - The SDK session ID (may equal conversation ID for new native sessions) * @returns Full path to the session JSONL file * @throws Error if sessionId is invalid (path traversal protection) */ export function getSDKSessionPath(vaultPath: string, sessionId: string): string { if (!isValidSessionId(sessionId)) { throw new Error(`Invalid session ID: ${sessionId}`); } const projectsPath = getSDKProjectsPath(); const encodedVault = encodeVaultPathForSDK(vaultPath); return path.join(projectsPath, encodedVault, `${sessionId}.jsonl`); } export function sdkSessionExists(vaultPath: string, sessionId: string): boolean { try { const sessionPath = getSDKSessionPath(vaultPath, sessionId); return existsSync(sessionPath); } catch { return false; } } export async function deleteSDKSession(vaultPath: string, sessionId: string): Promise { try { const sessionPath = getSDKSessionPath(vaultPath, sessionId); if (!existsSync(sessionPath)) return; await fs.unlink(sessionPath); } catch { // Best-effort deletion } } export async function readSDKSession(vaultPath: string, sessionId: string): Promise { try { const sessionPath = getSDKSessionPath(vaultPath, sessionId); if (!existsSync(sessionPath)) { return { messages: [], skippedLines: 0 }; } const content = await fs.readFile(sessionPath, 'utf-8'); const lines = content.split('\n').filter(line => line.trim()); const messages: SDKNativeMessage[] = []; let skippedLines = 0; for (const line of lines) { try { const msg = JSON.parse(line) as SDKNativeMessage; messages.push(msg); } catch { skippedLines++; } } return { messages, skippedLines }; } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); return { messages: [], skippedLines: 0, error: errorMsg }; } } function extractTextContent(content: string | SDKNativeContentBlock[] | undefined): string { if (!content) return ''; if (typeof content === 'string') return content; return content .filter((block): block is SDKNativeContentBlock & { type: 'text'; text: string } => block.type === 'text' && typeof block.text === 'string' && block.text.trim() !== '(no content)' ) .map(block => block.text) .join('\n'); } /** * Checks if user message content represents rebuilt context (history sent to SDK when session reset). * These start with a conversation role prefix and contain conversation history markers. * Handles both normal history (starting with User:) and truncated/malformed history (starting with Assistant:). */ function isRebuiltContextContent(textContent: string): boolean { // Must start with a conversation role prefix if (!/^(User|Assistant):\s/.test(textContent)) return false; // Must contain conversation continuation markers return textContent.includes('\n\nUser:') || textContent.includes('\n\nAssistant:') || textContent.includes('\n\nA:'); } function extractDisplayContent(textContent: string): string | undefined { return extractContentBeforeXmlContext(textContent); } function extractImages(content: string | SDKNativeContentBlock[] | undefined): ImageAttachment[] | undefined { if (!content || typeof content === 'string') return undefined; const imageBlocks = content.filter( (block): block is SDKNativeContentBlock & { type: 'image'; source: { type: 'base64'; media_type: string; data: string }; } => block.type === 'image' && !!block.source?.data ); if (imageBlocks.length === 0) return undefined; return imageBlocks.map((block, index) => ({ id: `sdk-img-${Date.now()}-${index}`, name: `image-${index + 1}`, mediaType: block.source.media_type as ImageMediaType, data: block.source.data, size: Math.ceil(block.source.data.length * 0.75), // Approximate original size from base64 source: 'paste' as const, })); } /** * Extracts tool calls from SDK content blocks. * * @param content - The content blocks from the assistant message * @param toolResults - Pre-collected tool results from all messages (for cross-message matching) */ function extractToolCalls( content: string | SDKNativeContentBlock[] | undefined, toolResults?: Map ): ToolCallInfo[] | undefined { if (!content || typeof content === 'string') return undefined; const toolUses = content.filter( (block): block is SDKNativeContentBlock & { type: 'tool_use'; id: string; name: string } => block.type === 'tool_use' && !!block.id && !!block.name ); if (toolUses.length === 0) return undefined; // Use provided results map, or build one from same-message results (fallback) const results = toolResults ?? new Map(); if (!toolResults) { for (const block of content) { if (block.type === 'tool_result' && block.tool_use_id) { results.set(block.tool_use_id, { content: extractToolResultContent(block.content), isError: block.is_error ?? false, }); } } } return toolUses.map(block => { const result = results.get(block.id); return { id: block.id, name: block.name, input: block.input ?? {}, status: result ? (result.isError ? 'error' : 'completed') : 'running', result: result?.content, isExpanded: false, }; }); } function mapContentBlocks(content: string | SDKNativeContentBlock[] | undefined): ContentBlock[] | undefined { if (!content || typeof content === 'string') return undefined; const blocks: ContentBlock[] = []; for (const block of content) { switch (block.type) { case 'text': { // Skip "(no content)" placeholder the SDK writes as the first assistant entry const text = block.text; const trimmed = text?.trim(); if (text && trimmed && trimmed !== '(no content)') { blocks.push({ type: 'text', content: text }); } break; } case 'thinking': if (block.thinking) { blocks.push({ type: 'thinking', content: block.thinking }); } break; case 'tool_use': if (block.id) { blocks.push({ type: 'tool_use', toolId: block.id }); } break; // tool_result blocks are part of tool calls, not content blocks } } return blocks.length > 0 ? blocks : undefined; } /** * Converts an SDK native message to a ChatMessage. * * @param sdkMsg - The SDK native message * @param toolResults - Optional pre-collected tool results for cross-message matching. * If not provided, only matches tool_result in the same message as tool_use. * For full cross-message matching, use loadSDKSessionMessages() which performs three-pass parsing. * @returns ChatMessage or null if the message should be skipped */ export function parseSDKMessageToChat( sdkMsg: SDKNativeMessage, toolResults?: Map ): ChatMessage | null { if (sdkMsg.type === 'file-history-snapshot') return null; if (sdkMsg.type === 'system') { if (sdkMsg.subtype === 'compact_boundary') { const timestamp = sdkMsg.timestamp ? new Date(sdkMsg.timestamp).getTime() : Date.now(); return { id: sdkMsg.uuid || `compact-${timestamp}-${Math.random().toString(36).slice(2)}`, role: 'assistant', content: '', timestamp, contentBlocks: [{ type: 'compact_boundary' }], }; } return null; } if (sdkMsg.type === 'result') return null; if (sdkMsg.type !== 'user' && sdkMsg.type !== 'assistant') return null; const content = sdkMsg.message?.content; const textContent = extractTextContent(content); const images = sdkMsg.type === 'user' ? extractImages(content) : undefined; const hasToolUse = Array.isArray(content) && content.some(b => b.type === 'tool_use'); const hasImages = images && images.length > 0; if (!textContent && !hasToolUse && !hasImages && (!content || typeof content === 'string')) return null; const timestamp = sdkMsg.timestamp ? new Date(sdkMsg.timestamp).getTime() : Date.now(); // SDK wraps slash commands in XML tags — restore clean display (e.g., /compact, /md2docx) const commandNameMatch = sdkMsg.type === 'user' ? textContent.match(/(\/[^<]+)<\/command-name>/) : null; let displayContent: string | undefined; if (sdkMsg.type === 'user') { displayContent = commandNameMatch ? commandNameMatch[1] : extractDisplayContent(textContent); } const isInterrupt = sdkMsg.type === 'user' && isInterruptSignalText(textContent); const isRebuiltContext = sdkMsg.type === 'user' && isRebuiltContextContent(textContent); return { id: sdkMsg.uuid || `sdk-${timestamp}-${Math.random().toString(36).slice(2)}`, role: sdkMsg.type, content: textContent, displayContent, timestamp, toolCalls: sdkMsg.type === 'assistant' ? extractToolCalls(content, toolResults) : undefined, contentBlocks: sdkMsg.type === 'assistant' ? mapContentBlocks(content) : undefined, images, ...(sdkMsg.type === 'user' && sdkMsg.uuid && { sdkUserUuid: sdkMsg.uuid }), ...(sdkMsg.type === 'assistant' && sdkMsg.uuid && { sdkAssistantUuid: sdkMsg.uuid }), ...(isInterrupt && { isInterrupt: true }), ...(isRebuiltContext && { isRebuiltContext: true }), }; } /** tool_result often appears in user message following assistant's tool_use. */ function collectToolResults(sdkMessages: SDKNativeMessage[]): Map { const results = new Map(); for (const sdkMsg of sdkMessages) { const content = sdkMsg.message?.content; if (!content || typeof content === 'string') continue; for (const block of content) { if (block.type === 'tool_result' && block.tool_use_id) { results.set(block.tool_use_id, { content: extractToolResultContent(block.content), isError: block.is_error ?? false, }); } } } return results; } /** Contains structuredPatch data for Write/Edit diff rendering. */ function collectStructuredPatchResults(sdkMessages: SDKNativeMessage[]): Map { const results = new Map(); for (const sdkMsg of sdkMessages) { if (sdkMsg.type !== 'user' || !sdkMsg.toolUseResult) continue; const content = sdkMsg.message?.content; if (!content || typeof content === 'string') continue; for (const block of content) { if (block.type === 'tool_result' && block.tool_use_id) { results.set(block.tool_use_id, sdkMsg.toolUseResult); } } } return results; } interface AsyncSubagentResult { result: string; status: string; } /** * Collects full async subagent results from queue-operation enqueue entries. * * The SDK stores a `queue-operation` entry with `operation: 'enqueue'` and a `content` * field containing `` XML when a background agent completes. * The XML includes ``, ``, and `` tags. * * @returns Map keyed by task-id (agentId) → full result + status */ export function collectAsyncSubagentResults( sdkMessages: SDKNativeMessage[] ): Map { const results = new Map(); for (const sdkMsg of sdkMessages) { if (sdkMsg.type !== 'queue-operation') continue; if (sdkMsg.operation !== 'enqueue') continue; if (typeof sdkMsg.content !== 'string') continue; if (!sdkMsg.content.includes('')) continue; const taskId = extractXmlTag(sdkMsg.content, 'task-id'); const status = extractXmlTag(sdkMsg.content, 'status'); const result = extractXmlTag(sdkMsg.content, 'result'); if (!taskId || !result) continue; results.set(taskId, { result, status: status ?? 'completed', }); } return results; } export function extractXmlTag(content: string, tagName: string): string | null { const regex = new RegExp(`<${tagName}>\\s*([\\s\\S]*?)\\s*`, 'i'); const match = content.match(regex); if (!match || !match[1]) return null; const trimmed = match[1].trim(); return trimmed.length > 0 ? trimmed : null; } /** * Checks if a user message is system-injected (not actual user input). * These include: * - Tool result messages (`toolUseResult` field) * - Skill prompt injections (`sourceToolUseID` field) * - Meta messages (`isMeta` field) * - Compact summary messages (SDK-generated context after /compact) * - Slash command invocations (``) * - Command stdout (``) * Such messages should be skipped as they're internal SDK communication. */ function isSystemInjectedMessage(sdkMsg: SDKNativeMessage): boolean { if (sdkMsg.type !== 'user') return false; if ('toolUseResult' in sdkMsg || 'sourceToolUseID' in sdkMsg || !!sdkMsg.isMeta) { return true; } const text = extractTextContent(sdkMsg.message?.content); if (!text) return false; // Preserve user-invoked slash commands (have both and ) if (text.includes('') && text.includes('')) return false; if (isCompactionCanceledStderr(text)) return false; // Filter system-injected messages if (text.startsWith('This session is being continued from a previous conversation')) return true; if (text.includes('')) return true; if (text.includes('') || text.includes('')) return true; if (text.includes('')) return true; return false; } /** * After rewind + follow-up, the JSONL forms a tree via parentUuid. Walks backward * from the newest branch leaf to collect only active entries. Without branching, * resumeSessionAt truncates the linear chain at that UUID. */ export function filterActiveBranch( entries: SDKNativeMessage[], resumeSessionAt?: string ): SDKNativeMessage[] { if (entries.length === 0) return []; function isRealUserBranchChild(entry: SDKNativeMessage | undefined): boolean { return ( !!entry && entry.type === 'user' && !('toolUseResult' in entry) && !entry.isMeta && !('sourceToolUseID' in entry) ); } function isDirectRealUserBranchChild( parentUuid: string, entry: SDKNativeMessage | undefined ): boolean { return !!entry && entry.parentUuid === parentUuid && isRealUserBranchChild(entry); } // SDK may write duplicates around compaction, which inflates child counts const seen = new Set(); const deduped: SDKNativeMessage[] = []; for (const entry of entries) { if (entry.uuid) { if (seen.has(entry.uuid)) continue; seen.add(entry.uuid); } deduped.push(entry); } // Strip progress entries (subagent execution logs) from the tree. // They have uuid/parentUuid chains that create false branching. // Entries whose parentUuid points into a progress chain get reparented // to the chain's conversation-level ancestor. const progressUuids = new Set(); const progressParentOf = new Map(); for (const entry of deduped) { if ((entry.type as string) === 'progress' && entry.uuid) { progressUuids.add(entry.uuid); progressParentOf.set(entry.uuid, entry.parentUuid ?? null); } } function resolveParent(parentUuid: string | null | undefined): string | null | undefined { if (!parentUuid) return parentUuid; let cur: string | null = parentUuid; let guard = progressUuids.size + 1; while (cur && progressUuids.has(cur)) { if (--guard < 0) break; cur = progressParentOf.get(cur) ?? null; } return cur; } // Build maps from conversation entries only (excluding progress) const convEntries: SDKNativeMessage[] = []; for (const entry of deduped) { if ((entry.type as string) === 'progress') continue; convEntries.push(entry); } const byUuid = new Map(); const childrenOf = new Map>(); for (const entry of convEntries) { if (entry.uuid) { byUuid.set(entry.uuid, entry); } const effectiveParent = resolveParent(entry.parentUuid) ?? null; if (effectiveParent && entry.uuid) { let children = childrenOf.get(effectiveParent); if (!children) { children = new Set(); childrenOf.set(effectiveParent, children); } children.add(entry.uuid); } } function findLatestLeaf(): SDKNativeMessage | undefined { for (let i = convEntries.length - 1; i >= 0; i--) { const uuid = convEntries[i].uuid; if (uuid && !childrenOf.has(uuid)) { return convEntries[i]; } } return undefined; } const latestLeaf = findLatestLeaf(); const latestBranchUuids = new Set(); const activeChildOf = new Map(); let currentLatest: SDKNativeMessage | undefined = latestLeaf; while (currentLatest?.uuid) { latestBranchUuids.add(currentLatest.uuid); const ep = resolveParent(currentLatest.parentUuid); if (ep) { activeChildOf.set(ep, currentLatest.uuid); } currentLatest = ep ? byUuid.get(ep) : undefined; } const conversationContentCache = new Map(); function hasConversationContent(uuid: string): boolean { const cached = conversationContentCache.get(uuid); if (cached !== undefined) return cached; const entry = byUuid.get(uuid); let result = false; if (entry?.type === 'assistant') { result = true; } else if (entry?.type === 'user' && !entry.isMeta && !('sourceToolUseID' in entry)) { result = true; } else { const children = childrenOf.get(uuid); if (children) { for (const childUuid of children) { if (hasConversationContent(childUuid)) { result = true; break; } } } } conversationContentCache.set(uuid, result); return result; } // A real rewind shows up along the latest branch as: // 1. at least one genuine user child from a parent on that branch, and // 2. another sibling subtree with conversation content that the latest branch did not take. // This catches rewinds where the abandoned path continues through assistant/tool nodes, // while still ignoring parallel tool calls that never create a user branch. const hasBranching = [...latestBranchUuids].some(uuid => { const children = childrenOf.get(uuid); if (!children || children.size <= 1) return false; const activeChildUuid = activeChildOf.get(uuid); let sawRealUserChild = false; let sawAlternateConversationChild = false; for (const childUuid of children) { const child = byUuid.get(childUuid); if (isDirectRealUserBranchChild(uuid, child)) { sawRealUserChild = true; } if (childUuid !== activeChildUuid && hasConversationContent(childUuid)) { sawAlternateConversationChild = true; } if (sawRealUserChild && sawAlternateConversationChild) { return true; } } return false; }); let leaf: SDKNativeMessage | undefined; if (hasBranching) { leaf = latestLeaf; // When resumeSessionAt is also set (rewind on the latest branch without follow-up), // truncate at that point instead of using the full branch leaf if (resumeSessionAt && leaf?.uuid && byUuid.has(resumeSessionAt)) { // Check if resumeSessionAt is an ancestor of the leaf — if so, truncate there let current: SDKNativeMessage | undefined = leaf; while (current?.uuid) { if (current.uuid === resumeSessionAt) { leaf = current; break; } const ep = resolveParent(current.parentUuid); current = ep ? byUuid.get(ep) : undefined; } } } else if (resumeSessionAt) { leaf = byUuid.get(resumeSessionAt); } else { return convEntries; } if (!leaf || !leaf.uuid) return convEntries; const activeUuids = new Set(); let current: SDKNativeMessage | undefined = leaf; while (current?.uuid) { activeUuids.add(current.uuid); const ep = resolveParent(current.parentUuid); current = ep ? byUuid.get(ep) : undefined; } // When no real branching was detected but resumeSessionAt truncated, // the active set only has the chain up to the leaf. For no-branching // with truncation, this is correct. For branching, we also need to // include sibling entries that are part of the same turn (parallel tool // calls, tool results from the same parent) and their non-branching // descendants. if (hasBranching) { // Seed: collect non-branch siblings of active nodes (parallel tool calls, // tool results) that the main ancestor walk didn't pick up. const ancestorUuids = [...activeUuids]; const pending: string[] = []; for (const uuid of ancestorUuids) { const children = childrenOf.get(uuid); if (!children || children.size <= 1) continue; const activeChildUuid = activeChildOf.get(uuid); if (activeChildUuid && isDirectRealUserBranchChild(uuid, byUuid.get(activeChildUuid))) { continue; } for (const childUuid of children) { if (activeUuids.has(childUuid)) continue; const child = byUuid.get(childUuid); if (!child || isRealUserBranchChild(child)) continue; activeUuids.add(childUuid); pending.push(childUuid); } } // BFS: include non-branching descendants of seeded siblings while (pending.length > 0) { const parentUuid = pending.pop()!; const children = childrenOf.get(parentUuid); if (!children) continue; for (const childUuid of children) { if (activeUuids.has(childUuid)) continue; const child = byUuid.get(childUuid); if (!child || isRealUserBranchChild(child)) continue; activeUuids.add(childUuid); pending.push(childUuid); } } } // O(n) sweep: include no-uuid entries only if both nearest uuid neighbors are active const n = convEntries.length; const prevIsActive = new Array(n); const nextIsActive = new Array(n); let lastPrevActive = false; for (let i = 0; i < n; i++) { if (convEntries[i].uuid) { lastPrevActive = activeUuids.has(convEntries[i].uuid!); } prevIsActive[i] = lastPrevActive; } let lastNextActive = false; for (let i = n - 1; i >= 0; i--) { if (convEntries[i].uuid) { lastNextActive = activeUuids.has(convEntries[i].uuid!); } nextIsActive[i] = lastNextActive; } return convEntries.filter((entry, idx) => { if (entry.uuid) return activeUuids.has(entry.uuid); return prevIsActive[idx] && nextIsActive[idx]; }); } export interface SDKSessionLoadResult { messages: ChatMessage[]; skippedLines: number; error?: string; } /** * Merges content from a source assistant message into a target message. * Used to combine multiple SDK messages from the same API turn (same requestId). */ function mergeAssistantMessage(target: ChatMessage, source: ChatMessage): void { // Merge text content (with separator if both have content) if (source.content) { if (target.content) { target.content = target.content + '\n\n' + source.content; } else { target.content = source.content; } } // Merge tool calls if (source.toolCalls) { target.toolCalls = [...(target.toolCalls || []), ...source.toolCalls]; } // Merge content blocks if (source.contentBlocks) { target.contentBlocks = [...(target.contentBlocks || []), ...source.contentBlocks]; } if (source.sdkAssistantUuid) { target.sdkAssistantUuid = source.sdkAssistantUuid; } } /** * Loads and converts all messages from an SDK native session. * * Uses three-pass approach: * 1. First pass: collect all tool_result and toolUseResult from all messages * 2. Second pass: convert messages and attach results to tool calls * 3. Third pass: attach diff data from toolUseResults to tool calls * * Consecutive assistant messages with the same requestId are merged into one, * as the SDK stores multiple JSONL entries for a single API turn (text, then tool_use, etc). * * @param vaultPath - The vault's absolute path * @param sessionId - The session ID * @returns Result object with messages, skipped line count, and any error */ /** * Extracts the agentId from an Agent tool's toolUseResult (async launch shape). * The SDK stores `{ isAsync: true, agentId: '...' }` on the tool result. */ export function extractAgentIdFromToolUseResult(toolUseResult: unknown): string | null { if (!toolUseResult || typeof toolUseResult !== 'object') return null; const record = toolUseResult as Record; const directAgentId = record.agentId ?? record.agent_id; if (typeof directAgentId === 'string' && directAgentId.length > 0) { return directAgentId; } const data = record.data; if (data && typeof data === 'object') { const nested = data as Record; const nestedAgentId = nested.agent_id ?? nested.agentId; if (typeof nestedAgentId === 'string' && nestedAgentId.length > 0) { return nestedAgentId; } } return null; } export type ResolvedAsyncStatus = Exclude; /** * Both the streaming layer (SubagentManager) and the session-load layer (buildAsyncSubagentInfo) * need to interpret the same SDK response shapes — this centralizes that logic. */ export function resolveToolUseResultStatus( toolUseResult: unknown, fallbackStatus: ResolvedAsyncStatus ): ResolvedAsyncStatus { if (!toolUseResult || typeof toolUseResult !== 'object') return fallbackStatus; const record = toolUseResult as Record; const rawStatus = record.retrieval_status ?? record.status; const status = typeof rawStatus === 'string' ? rawStatus.toLowerCase() : ''; if (status === 'error') return 'error'; if (status === 'completed' || status === 'success') return 'completed'; if (record.isAsync === true || status === 'async_launched') return 'running'; return fallbackStatus; } /** * Builds a SubagentInfo for an async Agent tool call from stored data. * Uses the toolUseResult (launch shape → agentId) and queue-operation results (full result). */ function buildAsyncSubagentInfo( toolCall: ToolCallInfo, toolUseResult: unknown, asyncResults: Map ): SubagentInfo | null { const agentId = extractAgentIdFromToolUseResult(toolUseResult); if (!agentId) return null; const queueResult = asyncResults.get(agentId); const description = (toolCall.input?.description as string) || 'Background task'; const prompt = (toolCall.input?.prompt as string) || ''; // Determine final result: prefer queue-operation result (full), fall back to tool_result content const finalResult = queueResult?.result ?? toolCall.result; let toolCallFallback: ResolvedAsyncStatus = 'running'; if (toolCall.status === 'error') toolCallFallback = 'error'; else if (toolCall.status === 'completed') toolCallFallback = 'completed'; // Queue-operation status reflects the actual async task outcome and must win over // the Task tool_result block, whose status only describes launch success. const status = queueResult ? resolveToolUseResultStatus({ status: queueResult.status }, 'completed') : resolveToolUseResultStatus(toolUseResult, toolCallFallback); const taskStatus = status === 'orphaned' ? 'error' : status; return { id: toolCall.id, description, prompt, mode: 'async', isExpanded: false, status: taskStatus, toolCalls: [], asyncStatus: status, agentId, result: finalResult, }; } export async function loadSDKSessionMessages( vaultPath: string, sessionId: string, resumeSessionAt?: string ): Promise { const result = await readSDKSession(vaultPath, sessionId); if (result.error) { return { messages: [], skippedLines: result.skippedLines, error: result.error }; } const filteredEntries = filterActiveBranch(result.messages, resumeSessionAt); const toolResults = collectToolResults(filteredEntries); const toolUseResults = collectStructuredPatchResults(filteredEntries); const asyncSubagentResults = collectAsyncSubagentResults(filteredEntries); const chatMessages: ChatMessage[] = []; let pendingAssistant: ChatMessage | null = null; // Merge consecutive assistant messages until an actual user message appears for (const sdkMsg of filteredEntries) { if (isSystemInjectedMessage(sdkMsg)) continue; // Skip synthetic assistant messages (e.g., "No response requested." after /compact) if (sdkMsg.type === 'assistant' && sdkMsg.message?.model === '') continue; const chatMsg = parseSDKMessageToChat(sdkMsg, toolResults); if (!chatMsg) continue; if (chatMsg.role === 'assistant') { // compact_boundary must not merge with previous assistant (it's a standalone separator) const isCompactBoundary = chatMsg.contentBlocks?.some(b => b.type === 'compact_boundary'); if (isCompactBoundary) { if (pendingAssistant) { chatMessages.push(pendingAssistant); } chatMessages.push(chatMsg); pendingAssistant = null; } else if (pendingAssistant) { mergeAssistantMessage(pendingAssistant, chatMsg); } else { pendingAssistant = chatMsg; } } else { if (pendingAssistant) { chatMessages.push(pendingAssistant); pendingAssistant = null; } chatMessages.push(chatMsg); } } if (pendingAssistant) { chatMessages.push(pendingAssistant); } if (toolUseResults.size > 0) { for (const msg of chatMessages) { if (msg.role !== 'assistant' || !msg.toolCalls) continue; for (const toolCall of msg.toolCalls) { const toolUseResult = toolUseResults.get(toolCall.id); if (!toolUseResult) continue; if (!toolCall.diffData) { toolCall.diffData = extractDiffData(toolUseResult, toolCall); } if (toolCall.name === TOOL_ASK_USER_QUESTION) { const answers = extractResolvedAnswers(toolUseResult) ?? extractResolvedAnswersFromResultText(toolCall.result); if (answers) toolCall.resolvedAnswers = answers; } } } } for (const msg of chatMessages) { if (msg.role !== 'assistant' || !msg.toolCalls) continue; for (const toolCall of msg.toolCalls) { if (toolCall.name !== TOOL_ASK_USER_QUESTION || toolCall.resolvedAnswers) continue; const answers = extractResolvedAnswersFromResultText(toolCall.result); if (answers) toolCall.resolvedAnswers = answers; } } // Build SubagentInfo for async Agent tool calls from toolUseResult + queue-operation data if (toolUseResults.size > 0 || asyncSubagentResults.size > 0) { const sidecarLoads: Array<{ subagent: SubagentInfo; promise: Promise }> = []; for (const msg of chatMessages) { if (msg.role !== 'assistant' || !msg.toolCalls) continue; for (const toolCall of msg.toolCalls) { if (!isSubagentToolName(toolCall.name)) continue; if (toolCall.subagent) continue; if (toolCall.input?.run_in_background !== true) continue; const toolUseResult = toolUseResults.get(toolCall.id); const subagent = buildAsyncSubagentInfo( toolCall, toolUseResult, asyncSubagentResults ); if (subagent) { toolCall.subagent = subagent; if (subagent.result !== undefined) { toolCall.result = subagent.result; } toolCall.status = subagent.status; // Load tool calls from subagent sidecar JSONL in parallel if (subagent.agentId && isValidAgentId(subagent.agentId)) { sidecarLoads.push({ subagent, promise: loadSubagentToolCalls(vaultPath, sessionId, subagent.agentId), }); } } } } // Hydrate subagent tool calls from sidecar files if (sidecarLoads.length > 0) { const results = await Promise.all(sidecarLoads.map(s => s.promise)); for (let i = 0; i < sidecarLoads.length; i++) { const toolCalls = results[i]; if (toolCalls.length > 0) { sidecarLoads[i].subagent.toolCalls = toolCalls; } } } } chatMessages.sort((a, b) => a.timestamp - b.timestamp); return { messages: chatMessages, skippedLines: result.skippedLines }; } ================================================ FILE: src/utils/session.ts ================================================ /** * Claudian - Session Utilities * * Session recovery and history reconstruction. */ import type { ChatMessage, ToolCallInfo } from '../core/types'; import { extractUserQuery, formatCurrentNote } from './context'; // ============================================ // Session Recovery // ============================================ const SESSION_ERROR_PATTERNS = [ 'session expired', 'session not found', 'invalid session', 'session invalid', 'process exited with code', ] as const; const SESSION_ERROR_COMPOUND_PATTERNS = [ { includes: ['session', 'expired'] }, { includes: ['resume', 'failed'] }, { includes: ['resume', 'error'] }, ] as const; export function isSessionExpiredError(error: unknown): boolean { const msg = error instanceof Error ? error.message.toLowerCase() : ''; for (const pattern of SESSION_ERROR_PATTERNS) { if (msg.includes(pattern)) { return true; } } for (const { includes } of SESSION_ERROR_COMPOUND_PATTERNS) { if (includes.every(part => msg.includes(part))) { return true; } } return false; } // ============================================ // History Reconstruction // ============================================ /** * Formats tool input for inclusion in rebuilt context. * Includes all non-null parameters, truncates long string values. */ function formatToolInput(input: Record, maxLength = 200): string { if (!input || Object.keys(input).length === 0) return ''; try { const parts: string[] = []; for (const [key, value] of Object.entries(input)) { if (value === undefined || value === null) continue; let valueStr: string; if (typeof value === 'string') { valueStr = value.length > 100 ? `${value.slice(0, 100)}...` : value; } else if (typeof value === 'object') { valueStr = '[object]'; } else { valueStr = String(value); } parts.push(`${key}=${valueStr}`); } const result = parts.join(', '); return result.length > maxLength ? `${result.slice(0, maxLength)}...` : result; } catch { return '[input formatting error]'; } } /** * Formats a tool call for inclusion in rebuilt context. * * Strategy: * - Always include tool name and input (so Claude knows what was attempted) * - Only include results for failed tools (errors are important to remember) * - Successful tools can be re-executed if needed */ export function formatToolCallForContext(toolCall: ToolCallInfo, maxErrorLength = 500): string { const status = toolCall.status ?? 'completed'; const isFailed = status === 'error' || status === 'blocked'; const inputStr = formatToolInput(toolCall.input); const inputPart = inputStr ? ` input: ${inputStr}` : ''; if (!isFailed) { return `[Tool ${toolCall.name}${inputPart} status=${status}]`; } const hasResult = typeof toolCall.result === 'string' && toolCall.result.trim().length > 0; if (!hasResult) { return `[Tool ${toolCall.name}${inputPart} status=${status}]`; } const errorMsg = truncateToolResult(toolCall.result as string, maxErrorLength); return `[Tool ${toolCall.name}${inputPart} status=${status}] error: ${errorMsg}`; } export function truncateToolResult(result: string, maxLength = 500): string { if (result.length > maxLength) { return `${result.slice(0, maxLength)}... (truncated)`; } return result; } export function formatContextLine(message: ChatMessage): string | null { if (!message.currentNote) { return null; } return formatCurrentNote(message.currentNote); } /** * Formats thinking blocks for inclusion in rebuilt context. * Just indicates that thinking occurred (content not included - Claude will think anew). */ function formatThinkingBlocks(message: ChatMessage): string[] { if (!message.contentBlocks) return []; const thinkingBlocks = message.contentBlocks.filter( (block): block is { type: 'thinking'; content: string; durationSeconds?: number } => block.type === 'thinking' ); if (thinkingBlocks.length === 0) return []; const totalDuration = thinkingBlocks.reduce( (sum, block) => sum + (block.durationSeconds ?? 0), 0 ); const durationPart = totalDuration > 0 ? `, ${totalDuration.toFixed(1)}s total` : ''; return [`[Thinking: ${thinkingBlocks.length} block(s)${durationPart}]`]; } export function buildContextFromHistory(messages: ChatMessage[]): string { const parts: string[] = []; for (const message of messages) { if (message.role !== 'user' && message.role !== 'assistant') { continue; } if (message.isInterrupt) { continue; } if (message.role === 'assistant') { const hasContent = message.content && message.content.trim().length > 0; const hasToolCalls = message.toolCalls && message.toolCalls.length > 0; const hasThinking = message.contentBlocks?.some(b => b.type === 'thinking'); if (!hasContent && !hasToolCalls && !hasThinking) { continue; } } const role = message.role === 'user' ? 'User' : 'Assistant'; const lines: string[] = []; const content = message.content?.trim(); const contextLine = formatContextLine(message); const userPayload = contextLine ? content ? `${contextLine}\n\n${content}` : contextLine : content; lines.push(userPayload ? `${role}: ${userPayload}` : `${role}:`); if (message.role === 'assistant') { const thinkingLines = formatThinkingBlocks(message); if (thinkingLines.length > 0) { lines.push(...thinkingLines); } } if (message.role === 'assistant' && message.toolCalls?.length) { const toolLines = message.toolCalls .map(tc => formatToolCallForContext(tc)) .filter(Boolean) as string[]; if (toolLines.length > 0) { lines.push(...toolLines); } } parts.push(lines.join('\n')); } return parts.join('\n\n'); } export function getLastUserMessage(messages: ChatMessage[]): ChatMessage | undefined { for (let i = messages.length - 1; i >= 0; i--) { if (messages[i].role === 'user') { return messages[i]; } } return undefined; } /** * Builds a prompt with history context for session recovery. * Avoids duplicating the current prompt if it's already the last user message. */ export function buildPromptWithHistoryContext( historyContext: string | null, prompt: string, actualPrompt: string, conversationHistory: ChatMessage[] ): string { if (!historyContext) return prompt; const lastUserMessage = getLastUserMessage(conversationHistory); // Compare actual user queries, not XML-wrapped versions const lastUserQuery = lastUserMessage?.displayContent ?? extractUserQuery(lastUserMessage?.content ?? ''); const currentUserQuery = extractUserQuery(actualPrompt); const shouldAppendPrompt = !lastUserMessage || lastUserQuery.trim() !== currentUserQuery.trim(); return shouldAppendPrompt ? `${historyContext}\n\nUser: ${prompt}` : historyContext; } ================================================ FILE: src/utils/slashCommand.ts ================================================ import type { ClaudeModel, SlashCommand } from '../core/types'; import { extractBoolean, extractString, extractStringArray, isRecord, parseFrontmatter, validateSlugName, } from './frontmatter'; export interface ParsedSlashCommandContent { description?: string; argumentHint?: string; allowedTools?: string[]; model?: string; promptContent: string; // Skill fields disableModelInvocation?: boolean; userInvocable?: boolean; context?: 'fork'; agent?: string; hooks?: Record; } export function extractFirstParagraph(content: string): string | undefined { const paragraph = content.split(/\n\s*\n/).find(p => p.trim()); if (!paragraph) return undefined; return paragraph.trim().replace(/\n/g, ' '); } export function validateCommandName(name: string): string | null { return validateSlugName(name, 'Command'); } export function isSkill(cmd: SlashCommand): boolean { return cmd.id.startsWith('skill-'); } export function parsedToSlashCommand( parsed: ParsedSlashCommandContent, identity: Pick & { source?: SlashCommand['source'] }, ): SlashCommand { return { ...identity, description: parsed.description, argumentHint: parsed.argumentHint, allowedTools: parsed.allowedTools, model: parsed.model as ClaudeModel | undefined, content: parsed.promptContent, disableModelInvocation: parsed.disableModelInvocation, userInvocable: parsed.userInvocable, context: parsed.context, agent: parsed.agent, hooks: parsed.hooks, }; } export function parseSlashCommandContent(content: string): ParsedSlashCommandContent { const parsed = parseFrontmatter(content); if (!parsed) { return { promptContent: content }; } const fm = parsed.frontmatter; return { // Existing fields — support both kebab-case (file format) and camelCase description: extractString(fm, 'description'), argumentHint: extractString(fm, 'argument-hint') ?? extractString(fm, 'argumentHint'), allowedTools: extractStringArray(fm, 'allowed-tools') ?? extractStringArray(fm, 'allowedTools'), model: extractString(fm, 'model'), promptContent: parsed.body, // Skill fields — kebab-case preferred (CC file format), camelCase for backwards compat disableModelInvocation: extractBoolean(fm, 'disable-model-invocation') ?? extractBoolean(fm, 'disableModelInvocation'), userInvocable: extractBoolean(fm, 'user-invocable') ?? extractBoolean(fm, 'userInvocable'), context: extractString(fm, 'context') === 'fork' ? 'fork' : undefined, agent: extractString(fm, 'agent'), hooks: isRecord(fm.hooks) ? fm.hooks : undefined, }; } export function normalizeArgumentHint(hint: string): string { if (!hint) return hint; if (hint.includes('[') || hint.includes('<')) return hint; return `[${hint}]`; } export function yamlString(value: string): string { if (value.includes(':') || value.includes('#') || value.includes('\n') || value.startsWith(' ') || value.endsWith(' ') || value.startsWith('[') || value.startsWith('{')) { return `"${value.replace(/"/g, '\\"')}"`; } return value; } /** Strips any frontmatter from `cmd.content` and re-serializes the command as Markdown. */ export function serializeCommand(cmd: SlashCommand): string { const parsed = parseSlashCommandContent(cmd.content); return serializeSlashCommandMarkdown(cmd, parsed.promptContent); } /** All frontmatter keys are serialized in kebab-case. */ export function serializeSlashCommandMarkdown(cmd: Partial, body: string): string { const lines: string[] = ['---']; if (cmd.name) { lines.push(`name: ${cmd.name}`); } if (cmd.description) { lines.push(`description: ${yamlString(cmd.description)}`); } if (cmd.argumentHint) { lines.push(`argument-hint: ${yamlString(cmd.argumentHint)}`); } if (cmd.allowedTools && cmd.allowedTools.length > 0) { lines.push('allowed-tools:'); for (const tool of cmd.allowedTools) { lines.push(` - ${yamlString(tool)}`); } } if (cmd.model) { lines.push(`model: ${cmd.model}`); } if (cmd.disableModelInvocation !== undefined) { lines.push(`disable-model-invocation: ${cmd.disableModelInvocation}`); } if (cmd.userInvocable !== undefined) { lines.push(`user-invocable: ${cmd.userInvocable}`); } if (cmd.context) { lines.push(`context: ${cmd.context}`); } if (cmd.agent) { lines.push(`agent: ${cmd.agent}`); } if (cmd.hooks !== undefined) { lines.push(`hooks: ${JSON.stringify(cmd.hooks)}`); } // Ensure at least one blank line between --- markers when no metadata exists // (the frontmatter regex requires \n before the closing ---) if (lines.length === 1) { lines.push(''); } lines.push('---'); lines.push(body); return lines.join('\n'); } ================================================ FILE: src/utils/subagentJsonl.ts ================================================ /** * Extracts the final textual result from subagent JSONL output. * Prefers the latest assistant text block and falls back to top-level result. */ export function extractFinalResultFromSubagentJsonl(content: string): string | null { const lines = content .split('\n') .map((line) => line.trim()) .filter((line) => line.length > 0 && line.startsWith('{')); let lastAssistantText: string | null = null; let lastResultText: string | null = null; for (const line of lines) { let raw: unknown; try { raw = JSON.parse(line); } catch { continue; } if (!raw || typeof raw !== 'object') { continue; } const record = raw as { result?: unknown; message?: { role?: unknown; content?: unknown }; }; if (typeof record.result === 'string' && record.result.trim().length > 0) { lastResultText = record.result.trim(); } if (record.message?.role !== 'assistant' || !Array.isArray(record.message.content)) { continue; } for (const blockRaw of record.message.content) { if (!blockRaw || typeof blockRaw !== 'object') { continue; } const block = blockRaw as { type?: unknown; text?: unknown }; if (block.type === 'text' && typeof block.text === 'string' && block.text.trim().length > 0) { lastAssistantText = block.text.trim(); } } } return lastAssistantText ?? lastResultText; } ================================================ FILE: tests/__mocks__/claude-agent-sdk.ts ================================================ // Mock for @anthropic-ai/claude-agent-sdk export interface HookCallbackMatcher { matcher?: string; hooks: Array<(hookInput: any, toolUseID: string, options: any) => Promise<{ continue: boolean; hookSpecificOutput?: any }>>; } export interface SpawnOptions { command: string; args: string[]; cwd?: string; env: { [envVar: string]: string | undefined; }; signal: AbortSignal; } export interface SpawnedProcess { stdin: NodeJS.WritableStream; stdout: NodeJS.ReadableStream; stderr?: NodeJS.ReadableStream | null; killed: boolean; exitCode: number | null; kill: (signal?: NodeJS.Signals) => void; on: (event: 'exit' | 'error', listener: (...args: any[]) => void) => void; once: (event: 'exit' | 'error', listener: (...args: any[]) => void) => void; off: (event: 'exit' | 'error', listener: (...args: any[]) => void) => void; } export interface Options { cwd?: string; permissionMode?: string; allowDangerouslySkipPermissions?: boolean; model?: string; tools?: string[]; allowedTools?: string[]; disallowedTools?: string[]; abortController?: AbortController; pathToClaudeCodeExecutable?: string; resume?: string; maxThinkingTokens?: number; thinking?: { type: string; budgetTokens?: number }; effort?: 'low' | 'medium' | 'high' | 'max'; canUseTool?: CanUseTool; systemPrompt?: string | { content: string; cacheControl?: { type: string } }; mcpServers?: Record; settingSources?: ('user' | 'project' | 'local')[]; spawnClaudeCodeProcess?: (options: SpawnOptions) => SpawnedProcess; hooks?: { PreToolUse?: HookCallbackMatcher[]; PostToolUse?: HookCallbackMatcher[]; Stop?: HookCallbackMatcher[]; }; agents?: Record; } // Type exports that match the real SDK export type AgentDefinition = { description: string; tools?: string[]; disallowedTools?: string[]; prompt: string; model?: 'sonnet' | 'opus' | 'haiku' | 'inherit'; mcpServers?: unknown[]; skills?: string[]; maxTurns?: number; hooks?: Record; }; export type AgentMcpServerSpec = string | Record; export type McpServerConfig = Record; export type PermissionBehavior = 'allow' | 'deny' | 'ask'; export type PermissionRuleValue = { toolName: string; ruleContent?: string; }; export type PermissionUpdateDestination = 'userSettings' | 'projectSettings' | 'localSettings' | 'session' | 'cliArg'; export type PermissionMode = 'acceptEdits' | 'bypassPermissions' | 'default' | 'delegate' | 'dontAsk' | 'plan'; export type PermissionUpdate = | { type: 'addRules'; rules: PermissionRuleValue[]; behavior: PermissionBehavior; destination: PermissionUpdateDestination } | { type: 'replaceRules'; rules: PermissionRuleValue[]; behavior: PermissionBehavior; destination: PermissionUpdateDestination } | { type: 'removeRules'; rules: PermissionRuleValue[]; behavior: PermissionBehavior; destination: PermissionUpdateDestination } | { type: 'setMode'; mode: PermissionMode; destination: PermissionUpdateDestination } | { type: 'addDirectories'; directories: string[]; destination: PermissionUpdateDestination } | { type: 'removeDirectories'; directories: string[]; destination: PermissionUpdateDestination }; export type CanUseTool = (toolName: string, input: Record, options: { signal: AbortSignal; suggestions?: PermissionUpdate[]; blockedPath?: string; decisionReason?: string; toolUseID: string; agentID?: string; }) => Promise; export type PermissionResult = | { behavior: 'allow'; updatedInput?: Record; updatedPermissions?: PermissionUpdate[]; toolUseID?: string } | { behavior: 'deny'; message: string; interrupt?: boolean; toolUseID?: string }; // Default mock messages for testing const mockMessages = [ { type: 'system', subtype: 'init', session_id: 'test-session-123' }, { type: 'assistant', message: { content: [{ type: 'text', text: 'Hello, I am Claude!' }] } }, { type: 'result', subtype: 'success', result: 'completed' }, ]; let customMockMessages: any[] | null = null; let appendResultMessage = true; let lastOptions: Options | undefined; let lastResponse: (AsyncGenerator & { interrupt: jest.Mock; setModel: jest.Mock; setMaxThinkingTokens: jest.Mock; setPermissionMode: jest.Mock; setMcpServers: jest.Mock; }) | null = null; // Crash simulation control let shouldThrowOnIteration = false; let throwAfterChunks = 0; let queryCallCount = 0; // Allow tests to set custom mock messages export function setMockMessages(messages: any[], options?: { appendResult?: boolean }) { customMockMessages = messages; appendResultMessage = options?.appendResult ?? true; } export function resetMockMessages() { customMockMessages = null; appendResultMessage = true; lastOptions = undefined; lastResponse = null; shouldThrowOnIteration = false; throwAfterChunks = 0; queryCallCount = 0; } /** * Configure the mock to throw an error during iteration. * @param afterChunks - Number of chunks to emit before throwing (0 = throw immediately) */ export function simulateCrash(afterChunks = 0) { shouldThrowOnIteration = true; throwAfterChunks = afterChunks; } /** * Get the number of times query() was called (useful for verifying restart behavior). */ export function getQueryCallCount(): number { return queryCallCount; } export function getLastOptions(): Options | undefined { return lastOptions; } export function getLastResponse(): typeof lastResponse { return lastResponse; } // Helper to run PreToolUse hooks async function runPreToolUseHooks( hooks: HookCallbackMatcher[] | undefined, toolName: string, toolInput: Record, toolId: string ): Promise<{ blocked: boolean; reason?: string }> { if (!hooks) return { blocked: false }; for (const hookMatcher of hooks) { // Check if matcher matches the tool (no matcher = match all) if (hookMatcher.matcher && hookMatcher.matcher !== toolName) { continue; } for (const hookFn of hookMatcher.hooks) { const hookInput = { tool_name: toolName, tool_input: toolInput }; const result = await hookFn(hookInput, toolId, {}); if (!result.continue) { const reason = result.hookSpecificOutput?.permissionDecisionReason || 'Blocked by hook'; return { blocked: true, reason }; } } } return { blocked: false }; } // Mock query function that returns an async generator function isAsyncIterable(value: any): value is AsyncIterable { return !!value && typeof value[Symbol.asyncIterator] === 'function'; } function getMessagesForPrompt(): any[] { const baseMessages = customMockMessages || mockMessages; const messages = [...baseMessages]; if (appendResultMessage && !messages.some((msg) => msg.type === 'result')) { messages.push({ type: 'result', subtype: 'success' }); } return messages; } async function* emitMessages(messages: any[], options: Options) { let chunksEmitted = 0; for (const msg of messages) { // Check if we should throw (crash simulation) if (shouldThrowOnIteration && chunksEmitted >= throwAfterChunks) { // Reset for next query (allows recovery to work) shouldThrowOnIteration = false; throw new Error('Simulated consumer crash'); } // Check for tool_use in assistant messages and run hooks if (msg.type === 'assistant' && msg.message?.content) { let wasBlocked = false; for (const block of msg.message.content) { if (block.type === 'tool_use') { const hookResult = await runPreToolUseHooks( options.hooks?.PreToolUse, block.name, block.input, block.id || `tool-${Date.now()}` ); if (hookResult.blocked) { // Yield the assistant message first (with tool_use) yield msg; chunksEmitted++; // Then yield a blocked indicator as a user message with error yield { type: 'user', parent_tool_use_id: block.id, tool_use_result: `BLOCKED: ${hookResult.reason}`, message: { content: [] }, _blocked: true, _blockReason: hookResult.reason, }; chunksEmitted++; wasBlocked = true; break; // Exit inner loop since we already handled this message } } } // If the message was blocked, don't yield it again if (wasBlocked) { continue; } } yield msg; chunksEmitted++; } } export function query({ prompt, options }: { prompt: any; options: Options }): AsyncGenerator & { interrupt: () => Promise } { lastOptions = options; queryCallCount++; const generator = async function* () { if (isAsyncIterable(prompt)) { for await (const _ of prompt) { void _; // Consume async iterable input const messages = getMessagesForPrompt(); yield* emitMessages(messages, options); } return; } const messages = getMessagesForPrompt(); yield* emitMessages(messages, options); }; const gen = generator() as AsyncGenerator & { interrupt: jest.Mock; setModel: jest.Mock; setMaxThinkingTokens: jest.Mock; setPermissionMode: jest.Mock; setMcpServers: jest.Mock; }; gen.interrupt = jest.fn().mockResolvedValue(undefined); // Dynamic update methods for persistent queries gen.setModel = jest.fn().mockResolvedValue(undefined); gen.setMaxThinkingTokens = jest.fn().mockResolvedValue(undefined); gen.setPermissionMode = jest.fn().mockResolvedValue(undefined); gen.setMcpServers = jest.fn().mockResolvedValue({ added: [], removed: [], errors: {} }); lastResponse = gen; return gen; } ================================================ FILE: tests/__mocks__/obsidian.ts ================================================ // Mock for Obsidian API export class Plugin { app: any; manifest: any; constructor(app?: any, manifest?: any) { this.app = app; this.manifest = manifest; } addRibbonIcon = jest.fn(); addCommand = jest.fn(); addSettingTab = jest.fn(); registerView = jest.fn(); loadData = jest.fn().mockResolvedValue({}); saveData = jest.fn().mockResolvedValue(undefined); } export class PluginSettingTab { app: any; plugin: any; containerEl: any = { empty: jest.fn(), createEl: jest.fn().mockReturnValue({ createEl: jest.fn(), createDiv: jest.fn() }), createDiv: jest.fn().mockReturnValue({ createEl: jest.fn(), createDiv: jest.fn() }), }; constructor(app: any, plugin: any) { this.app = app; this.plugin = plugin; } display() {} } export class ItemView { app: any; leaf: any; containerEl: any = { children: [{}, { empty: jest.fn(), addClass: jest.fn(), createDiv: jest.fn().mockReturnValue({ createEl: jest.fn().mockReturnValue({ addEventListener: jest.fn(), setAttribute: jest.fn() }), createDiv: jest.fn().mockReturnValue({ createEl: jest.fn().mockReturnValue({ addEventListener: jest.fn() }) }), }) }], }; constructor(leaf: any) { this.leaf = leaf; } getViewType(): string { return ''; } getDisplayText(): string { return ''; } getIcon(): string { return ''; } } export class WorkspaceLeaf {} export class App { vault: any = { adapter: { basePath: '/mock/vault/path', }, }; workspace: any = { getLeavesOfType: jest.fn().mockReturnValue([]), getRightLeaf: jest.fn().mockReturnValue({ setViewState: jest.fn().mockResolvedValue(undefined), }), revealLeaf: jest.fn(), }; } export class MarkdownView { editor: any; file?: any; constructor(editor?: any, file?: any) { this.editor = editor; this.file = file; } } export class Setting { constructor(containerEl: any) {} setName = jest.fn().mockReturnThis(); setDesc = jest.fn().mockReturnThis(); addToggle = jest.fn().mockReturnThis(); addTextArea = jest.fn().mockReturnThis(); } export class TextAreaComponent { inputEl: any; private _value = ''; constructor(_container?: any) { this.inputEl = { addClass: jest.fn(), rows: 0, placeholder: '', focus: jest.fn(), addEventListener: jest.fn(), removeEventListener: jest.fn(), }; } setValue(value: string): this { this._value = value; return this; } getValue(): string { return this._value; } } export class Modal { app: any; containerEl: any = { createDiv: jest.fn().mockReturnValue({ createEl: jest.fn().mockReturnValue({ addEventListener: jest.fn() }), createDiv: jest.fn().mockReturnValue({ createEl: jest.fn().mockReturnValue({ addEventListener: jest.fn() }), createDiv: jest.fn().mockReturnValue({ createEl: jest.fn(), }), setText: jest.fn(), }), addClass: jest.fn(), setText: jest.fn(), }), empty: jest.fn(), addClass: jest.fn(), }; contentEl: any = { createDiv: jest.fn().mockReturnValue({ createEl: jest.fn().mockReturnValue({ addEventListener: jest.fn() }), createDiv: jest.fn().mockReturnValue({ createEl: jest.fn().mockReturnValue({ addEventListener: jest.fn() }), createDiv: jest.fn().mockReturnValue({ createEl: jest.fn(), }), setText: jest.fn(), }), addClass: jest.fn(), setText: jest.fn(), }), empty: jest.fn(), addClass: jest.fn(), }; constructor(app: any) { this.app = app; } open = jest.fn(); close = jest.fn(); onOpen = jest.fn(); onClose = jest.fn(); } export const MarkdownRenderer = { renderMarkdown: jest.fn().mockResolvedValue(undefined), }; export const setIcon = jest.fn(); // Notice mock that tracks constructor calls export const Notice = jest.fn().mockImplementation((_message: string, _timeout?: number) => {}); function unquoteYaml(value: string): string { if ( (value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")) ) { return value.slice(1, -1); } return value; } function parseYamlValue(rawValue: string): unknown { if (!rawValue) return null; if (rawValue.startsWith('{') && rawValue.endsWith('}')) { try { return JSON.parse(rawValue); } catch { /* fall through */ } } if (rawValue.startsWith('[') && rawValue.endsWith(']')) { return rawValue.slice(1, -1).split(',').map(item => unquoteYaml(item.trim())).filter(Boolean); } if (rawValue === 'true' || rawValue === 'false') { return rawValue === 'true'; } const numberValue = Number(rawValue); if (!Number.isNaN(numberValue) && rawValue !== '') { return numberValue; } return unquoteYaml(rawValue); } export function parseYaml(content: string): Record { const result: Record = {}; const lines = content.split(/\r?\n/); let currentArrayKey: string | null = null; let currentArray: string[] = []; let blockScalarKey: string | null = null; let blockScalarStyle: 'literal' | 'folded' | null = null; let blockScalarLines: string[] = []; let blockScalarIndent: number | null = null; const flushArray = () => { if (currentArrayKey) { result[currentArrayKey] = currentArray; currentArrayKey = null; currentArray = []; } }; const flushBlockScalar = () => { if (!blockScalarKey) return; let value: string; if (blockScalarStyle === 'literal') { value = blockScalarLines.join('\n'); } else { value = blockScalarLines.join('\n').replace(/(?= blockScalarIndent) { blockScalarLines.push(line.slice(blockScalarIndent)); continue; } else { flushBlockScalar(); // fall through } } // Handle YAML list items (- value) if (currentArrayKey && trimmed.startsWith('- ')) { currentArray.push(unquoteYaml(trimmed.slice(2).trim())); continue; } // Not a list item — flush any pending array if (currentArrayKey && trimmed !== '') { flushArray(); } if (!trimmed) continue; const match = trimmed.match(/^([^:]+):\s*(.*)$/); if (!match) continue; const key = match[1].trim(); const rawValue = match[2].trim(); if (!key) continue; // Check for block scalar indicator (| or >) with optional chomping const blockMatch = rawValue.match(/^([|>])([+-])?$/); if (blockMatch) { blockScalarKey = key; blockScalarStyle = blockMatch[1] === '|' ? 'literal' : 'folded'; blockScalarLines = []; blockScalarIndent = null; continue; } if (!rawValue) { // Could be start of a YAML list or a null value — peek ahead currentArrayKey = key; currentArray = []; continue; } result[key] = parseYamlValue(rawValue); } if (blockScalarKey) flushBlockScalar(); flushArray(); return result; } // TFile class for instanceof checks export class TFile { path: string; name: string; basename: string; extension: string; constructor(path: string = '') { this.path = path; this.name = path.split('/').pop() || ''; this.basename = this.name.replace(/\.[^.]+$/, ''); this.extension = this.name.split('.').pop() || ''; } } export class TFolder { path: string; name: string; children: any[] = []; constructor(path: string = '') { this.path = path; this.name = path.split('/').pop() || ''; } } ================================================ FILE: tests/helpers/mockElement.ts ================================================ export interface MockElement { tagName: string; children: MockElement[]; style: Record; dataset: Record; scrollTop: number; scrollHeight: number; innerHTML: string; textContent: string; className: string; classList: { add: (cls: string) => void; remove: (cls: string) => void; contains: (cls: string) => boolean; toggle: (cls: string, force?: boolean) => boolean; }; addClass: (cls: string) => MockElement; removeClass: (cls: string) => MockElement; hasClass: (cls: string) => boolean; getClasses: () => string[]; createDiv: (opts?: { cls?: string; text?: string }) => MockElement; createSpan: (opts?: { cls?: string; text?: string }) => MockElement; createEl: (tag: string, opts?: { cls?: string; text?: string; attr?: Record }) => MockElement; appendChild: (child: any) => any; insertBefore: (el: MockElement, ref: MockElement | null) => void; firstChild: MockElement | null; remove: () => void; empty: () => void; contains: (node: any) => boolean; scrollIntoView: () => void; setAttribute: (name: string, value: string) => void; getAttribute: (name: string) => string | undefined | null; addEventListener: (event: string, handler: (...args: any[]) => void) => void; removeEventListener: (event: string, handler: (...args: any[]) => void) => void; dispatchEvent: (eventOrType: string | { type: string; [key: string]: any }, extraArg?: any) => void; click: () => void; getEventListenerCount: (event: string) => number; querySelector: (selector: string) => MockElement | null; querySelectorAll: (selector: string) => MockElement[]; getBoundingClientRect: () => { top: number; left: number; width: number; height: number; right: number; bottom: number; x: number; y: number; toJSON: () => void }; setText: (text: string) => void; _classes: Set; _classList: Set; _attributes: Map; _eventListeners: Map void>>; _children: MockElement[]; [key: string]: any; } export function createMockEl(tag = 'div'): any { const children: MockElement[] = []; const classes = new Set(); const attributes = new Map(); const eventListeners = new Map void>>(); const dataset: Record = {}; const style: Record = {}; let textContent = ''; const element: MockElement = { tagName: tag.toUpperCase(), children, style, dataset, scrollTop: 0, scrollHeight: 0, innerHTML: '', get textContent() { return textContent; }, set textContent(value: string) { textContent = value; }, get className() { return Array.from(classes).join(' '); }, set className(value: string) { classes.clear(); if (value) { value.split(' ').filter(Boolean).forEach(c => classes.add(c)); } }, classList: { add: (cls: string) => classes.add(cls), remove: (cls: string) => classes.delete(cls), contains: (cls: string) => classes.has(cls), toggle: (cls: string, force?: boolean) => { if (force === undefined) { if (classes.has(cls)) { classes.delete(cls); return false; } classes.add(cls); return true; } if (force) { classes.add(cls); } else { classes.delete(cls); } return force; }, }, addClass(cls: string) { cls.split(/\s+/).filter(Boolean).forEach(c => classes.add(c)); return element; }, removeClass(cls: string) { cls.split(/\s+/).filter(Boolean).forEach(c => classes.delete(c)); return element; }, hasClass: (cls: string) => classes.has(cls), getClasses: () => Array.from(classes), createDiv(opts?: { cls?: string; text?: string }) { const child = createMockEl('div'); if (opts?.cls) child.addClass(opts.cls); if (opts?.text) child.textContent = opts.text; children.push(child); return child; }, createSpan(opts?: { cls?: string; text?: string }) { const child = createMockEl('span'); if (opts?.cls) child.addClass(opts.cls); if (opts?.text) child.textContent = opts.text; children.push(child); return child; }, createEl(tagName: string, opts?: { cls?: string; text?: string; attr?: Record }) { const child = createMockEl(tagName); if (opts?.cls) child.addClass(opts.cls); if (opts?.text) child.textContent = opts.text; children.push(child); return child; }, appendChild(child: any) { children.push(child); return child; }, insertBefore(el: MockElement, _ref: MockElement | null) { children.unshift(el); }, get firstChild() { return children[0] || null; }, remove() {}, empty() { children.length = 0; element.innerHTML = ''; textContent = ''; }, contains(node: any) { if (node === element) return true; return children.some(child => (child as any).contains?.(node)); }, scrollIntoView() {}, focus() {}, blur() {}, setAttribute(name: string, value: string) { attributes.set(name, value); }, getAttribute(name: string) { return attributes.get(name) ?? null; }, addEventListener(event: string, handler: (...args: any[]) => void) { if (!eventListeners.has(event)) eventListeners.set(event, []); eventListeners.get(event)!.push(handler); }, removeEventListener(event: string, handler: (...args: any[]) => void) { const handlers = eventListeners.get(event); if (handlers) { const idx = handlers.indexOf(handler); if (idx !== -1) handlers.splice(idx, 1); } }, dispatchEvent(eventOrType: string | { type: string; [key: string]: any }, extraArg?: any) { if (typeof eventOrType === 'string') { const handlers = eventListeners.get(eventOrType) || []; handlers.forEach(h => h(extraArg)); } else { const handlers = eventListeners.get(eventOrType.type) || []; handlers.forEach(h => h(eventOrType)); } }, click() { const handlers = eventListeners.get('click') || []; handlers.forEach(h => h({ type: 'click', target: element, stopPropagation: () => {} })); }, getEventListenerCount(event: string) { return eventListeners.get(event)?.length ?? 0; }, querySelector(selector: string) { const cls = selector.replace('.', ''); const find = (el: any): MockElement | null => { if (el.hasClass?.(cls)) return el; for (const child of el.children || []) { const found = find(child); if (found) return found; } return null; }; return find(element); }, querySelectorAll(selector: string) { const cls = selector.replace('.', ''); const results: MockElement[] = []; const collect = (el: any) => { if (el.hasClass?.(cls)) results.push(el); for (const child of el.children || []) collect(child); }; for (const child of children) collect(child); return results; }, getBoundingClientRect() { return { top: 0, left: 0, width: 0, height: 0, right: 0, bottom: 0, x: 0, y: 0, toJSON() {} }; }, setText(text: string) { textContent = text; }, setAttr(name: string, value: string) { attributes.set(name, value); }, toggleClass(cls: string, force: boolean) { if (force) { classes.add(cls); } else { classes.delete(cls); } }, value: '', closest() { return { clientHeight: 600 }; }, getEventListeners() { return eventListeners; }, _classes: classes, _classList: classes, _attributes: attributes, _eventListeners: eventListeners, _children: children, }; return element; } ================================================ FILE: tests/helpers/sdkMessages.ts ================================================ import type { SDKAssistantMessage, SDKAuthStatusMessage, SDKCompactBoundaryMessage, SDKMessage, SDKPartialAssistantMessage, SDKResultError, SDKResultSuccess, SDKStatusMessage, SDKSystemMessage, SDKToolProgressMessage, SDKUserMessage, } from '@anthropic-ai/claude-agent-sdk'; const TEST_UUID = '00000000-0000-4000-8000-000000000001'; const TEST_SESSION_ID = 'test-session'; const DEFAULT_RESULT_USAGE = ({ input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0, } as unknown) as SDKResultSuccess['usage']; const DEFAULT_MODEL_USAGE: SDKResultSuccess['modelUsage'] = { 'claude-sonnet-test': { inputTokens: 0, outputTokens: 0, cacheReadInputTokens: 0, cacheCreationInputTokens: 0, webSearchRequests: 0, costUSD: 0, contextWindow: 200000, maxOutputTokens: 8192, }, }; export type SystemInitMessageInput = { type: 'system'; subtype: 'init'; } & Partial>; export type SystemStatusMessageInput = { type: 'system'; subtype: 'status'; } & Partial>; export type CompactBoundaryMessageInput = { type: 'system'; subtype: 'compact_boundary'; } & Partial>; export type AssistantMessageInput = { type: 'assistant'; } & Partial>; export type UserMessageInput = { type: 'user'; _blocked?: boolean; _blockReason?: string; } & Partial>; export type StreamEventMessageInput = { type: 'stream_event'; } & Partial>; export type ResultSuccessMessageInput = { type: 'result'; subtype?: 'success'; } & Partial>; export type ResultErrorMessageInput = { type: 'result'; subtype: SDKResultError['subtype']; } & Partial>; export type ToolProgressMessageInput = { type: 'tool_progress'; } & Partial>; export type AuthStatusMessageInput = { type: 'auth_status'; } & Partial>; export type SDKTestMessageInput = | SystemInitMessageInput | SystemStatusMessageInput | CompactBoundaryMessageInput | AssistantMessageInput | UserMessageInput | StreamEventMessageInput | ResultSuccessMessageInput | ResultErrorMessageInput | ToolProgressMessageInput | AuthStatusMessageInput; export function buildSystemInitMessage(overrides: Partial> = {}): SDKSystemMessage { return { type: 'system', subtype: 'init', apiKeySource: 'user', claude_code_version: 'test-version', cwd: '/test/cwd', tools: [], mcp_servers: [], model: 'claude-sonnet-4-5', permissionMode: 'default', slash_commands: [], output_style: 'default', skills: [], plugins: [], uuid: TEST_UUID, session_id: TEST_SESSION_ID, ...overrides, }; } export function buildSystemStatusMessage(overrides: Partial> = {}): SDKStatusMessage { return { type: 'system', subtype: 'status', status: null, uuid: TEST_UUID, session_id: TEST_SESSION_ID, ...overrides, }; } export function buildCompactBoundaryMessage( overrides: Partial> = {} ): SDKCompactBoundaryMessage { return { type: 'system', subtype: 'compact_boundary', compact_metadata: { trigger: 'manual', pre_tokens: 0, }, uuid: TEST_UUID, session_id: TEST_SESSION_ID, ...overrides, }; } export function buildAssistantMessage(overrides: Partial> = {}): SDKAssistantMessage { return { type: 'assistant', message: ({ content: [] } as unknown) as SDKAssistantMessage['message'], parent_tool_use_id: null, uuid: TEST_UUID, session_id: TEST_SESSION_ID, ...overrides, }; } export function buildUserMessage(overrides: Partial> = {}): SDKUserMessage { return { type: 'user', message: ({ content: [] } as unknown) as SDKUserMessage['message'], parent_tool_use_id: null, session_id: TEST_SESSION_ID, ...overrides, }; } export function buildStreamEventMessage( overrides: Partial> = {} ): SDKPartialAssistantMessage { return { type: 'stream_event', event: ({ type: 'content_block_delta', delta: { type: 'text_delta', text: '' }, } as unknown) as SDKPartialAssistantMessage['event'], parent_tool_use_id: null, uuid: TEST_UUID, session_id: TEST_SESSION_ID, ...overrides, }; } export function buildResultSuccessMessage( overrides: Partial> = {} ): SDKResultSuccess { return { type: 'result', subtype: 'success', duration_ms: 0, duration_api_ms: 0, is_error: false, num_turns: 1, result: 'completed', stop_reason: null, total_cost_usd: 0, usage: DEFAULT_RESULT_USAGE, modelUsage: DEFAULT_MODEL_USAGE, permission_denials: [], uuid: TEST_UUID, session_id: TEST_SESSION_ID, ...overrides, }; } export function buildResultErrorMessage( overrides: Partial> & Pick ): SDKResultError { const { subtype, ...rest } = overrides; return { type: 'result', subtype, duration_ms: 0, duration_api_ms: 0, is_error: true, num_turns: 1, stop_reason: null, total_cost_usd: 0, usage: DEFAULT_RESULT_USAGE, modelUsage: DEFAULT_MODEL_USAGE, permission_denials: [], errors: ['SDK reported an execution error'], uuid: TEST_UUID, session_id: TEST_SESSION_ID, ...rest, }; } export function buildToolProgressMessage( overrides: Partial> = {} ): SDKToolProgressMessage { return { type: 'tool_progress', tool_use_id: 'tool-1', tool_name: 'Bash', parent_tool_use_id: null, elapsed_time_seconds: 0, uuid: TEST_UUID, session_id: TEST_SESSION_ID, ...overrides, }; } export function buildAuthStatusMessage( overrides: Partial> = {} ): SDKAuthStatusMessage { return { type: 'auth_status', isAuthenticating: false, output: [], uuid: TEST_UUID, session_id: TEST_SESSION_ID, ...overrides, }; } function isResultErrorInput(input: ResultSuccessMessageInput | ResultErrorMessageInput): input is ResultErrorMessageInput { return input.subtype !== undefined && input.subtype !== 'success'; } export function buildSDKMessage(input: SDKTestMessageInput): SDKMessage { switch (input.type) { case 'system': if (input.subtype === 'init') return buildSystemInitMessage(input); if (input.subtype === 'status') return buildSystemStatusMessage(input); return buildCompactBoundaryMessage(input); case 'assistant': return buildAssistantMessage(input); case 'user': { const message = buildUserMessage(input); if (input._blocked === true) { return { ...message, _blocked: true, _blockReason: input._blockReason ?? 'Blocked by hook', } as SDKMessage; } return message; } case 'stream_event': return buildStreamEventMessage(input); case 'result': if (isResultErrorInput(input)) { return buildResultErrorMessage(input); } return buildResultSuccessMessage(input); case 'tool_progress': return buildToolProgressMessage(input); case 'auth_status': return buildAuthStatusMessage(input); } } ================================================ FILE: tests/integration/core/agent/ClaudianService.test.ts ================================================ // eslint-disable-next-line jest/no-mocks-import import { getLastOptions, getLastResponse, getQueryCallCount, resetMockMessages, setMockMessages, simulateCrash, } from '@test/__mocks__/claude-agent-sdk'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; // Mock fs module jest.mock('fs'); jest.mock('@/core/types', () => { const actual = jest.requireActual('@/core/types'); return { __esModule: true, ...actual, getCurrentPlatformBlockedCommands: (commands: { unix: string[] }) => commands.unix, }; }); // Now import after all mocks are set up import { buildResultErrorMessage } from '@test/helpers/sdkMessages'; import { ClaudianService } from '@/core/agent/ClaudianService'; import { createVaultRestrictionHook } from '@/core/hooks/SecurityHooks'; import { transformSDKMessage } from '@/core/sdk'; import { getActionDescription, getActionPattern } from '@/core/security/ApprovalManager'; import { getPathFromToolInput } from '@/core/tools/toolInput'; import { resolveClaudeCliPath } from '@/utils/claudeCli'; import { buildContextFromHistory, formatToolCallForContext, getLastUserMessage, isSessionExpiredError, truncateToolResult, } from '@/utils/session'; // Helper to create SDK-format assistant message with tool_use function createAssistantWithToolUse(toolName: string, toolInput: Record, toolId = 'tool-123') { return { type: 'assistant', message: { content: [ { type: 'tool_use', id: toolId, name: toolName, input: toolInput }, ], }, }; } // Helper to create SDK-format user message with tool_result function createUserWithToolResult(content: string, parentToolUseId = 'tool-123') { return { type: 'user', parent_tool_use_id: parentToolUseId, tool_use_result: content, message: { content: [] }, }; } function createTextUserMessage(content: string) { return { type: 'user', message: { role: 'user', content, }, parent_tool_use_id: null, session_id: '', }; } // Create a mock MCP server manager function createMockMcpManager() { return { loadServers: jest.fn().mockResolvedValue(undefined), getServers: jest.fn().mockReturnValue([]), getEnabledCount: jest.fn().mockReturnValue(0), getActiveServers: jest.fn().mockReturnValue({}), getDisallowedMcpTools: jest.fn().mockReturnValue([]), getAllDisallowedMcpTools: jest.fn().mockReturnValue([]), hasServers: jest.fn().mockReturnValue(false), } as any; } // Create a mock plugin function createMockPlugin(settings: Record = {}) { // CC permissions storage (allow/deny/ask arrays) const ccPermissions = { allow: [] as string[], deny: [] as string[], ask: [] as string[], }; const mockPlugin = { settings: { enableBlocklist: true, blockedCommands: { unix: [ 'rm -rf', 'rm -r /', 'chmod 777', 'chmod -R 777', 'mkfs', 'dd if=', '> /dev/sd', ], windows: [ 'Remove-Item -Recurse -Force', 'Format-Volume', ], }, permissions: [], // Legacy field (for backwards compat tests) permissionMode: 'yolo', allowedExportPaths: [], loadUserClaudeSettings: false, mediaFolder: '', systemPrompt: '', model: 'claude-sonnet-4-5', thinkingBudget: 'off', ...settings, }, app: { vault: { adapter: { basePath: '/test/vault/path', }, }, }, storage: { getPermissions: jest.fn().mockImplementation(async () => ccPermissions), addAllowRule: jest.fn().mockImplementation(async (rule: string) => { ccPermissions.allow.push(rule); }), addDenyRule: jest.fn().mockImplementation(async (rule: string) => { ccPermissions.deny.push(rule); }), }, // Expose ccPermissions for test assertions _ccPermissions: ccPermissions, saveSettings: jest.fn().mockResolvedValue(undefined), getActiveEnvironmentVariables: jest.fn().mockReturnValue(''), getResolvedClaudeCliPath: jest.fn().mockReturnValue('/mock/claude'), // Mock getView to return null (tests don't have real view) // This allows optional chaining to work safely getView: jest.fn().mockReturnValue(null), // Mock pluginManager for QueryOptionsBuilder pluginManager: { getPluginsKey: jest.fn().mockReturnValue(''), hasEnabledPlugins: jest.fn().mockReturnValue(false), }, } as any; return mockPlugin; } describe('ClaudianService', () => { let service: ClaudianService; let mockPlugin: any; beforeEach(() => { jest.clearAllMocks(); resetMockMessages(); mockPlugin = createMockPlugin(); service = new ClaudianService(mockPlugin, createMockMcpManager()); }); afterEach(() => { // Clean up persistent query to prevent test hangs service.cleanup(); }); describe('shouldBlockCommand', () => { it('should block dangerous rm commands', async () => { (fs.existsSync as jest.Mock).mockReturnValue(true); setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, createAssistantWithToolUse('Bash', { command: 'rm -rf /' }), { type: 'result' }, ]); const chunks: any[] = []; for await (const chunk of service.query('delete everything')) { chunks.push(chunk); } const blockedChunk = chunks.find((c) => c.type === 'blocked'); expect(blockedChunk).toBeDefined(); expect(blockedChunk?.content).toContain('rm -rf'); }); it('should block chmod 777 commands', async () => { (fs.existsSync as jest.Mock).mockReturnValue(true); setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, createAssistantWithToolUse('Bash', { command: 'chmod 777 /etc/passwd' }), { type: 'result' }, ]); const chunks: any[] = []; for await (const chunk of service.query('change permissions')) { chunks.push(chunk); } const blockedChunk = chunks.find((c) => c.type === 'blocked'); expect(blockedChunk).toBeDefined(); expect(blockedChunk?.content).toContain('chmod 777'); }); it('should allow safe commands when blocklist is enabled', async () => { (fs.existsSync as jest.Mock).mockReturnValue(true); setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, createAssistantWithToolUse('Bash', { command: 'ls -la' }), { type: 'result' }, ]); const chunks: any[] = []; for await (const chunk of service.query('list files')) { chunks.push(chunk); } const blockedChunk = chunks.find((c) => c.type === 'blocked'); expect(blockedChunk).toBeUndefined(); const toolUseChunk = chunks.find((c) => c.type === 'tool_use'); expect(toolUseChunk).toBeDefined(); }); it('should not block commands when blocklist is disabled', async () => { mockPlugin = createMockPlugin({ enableBlocklist: false }); service = new ClaudianService(mockPlugin, createMockMcpManager()); (fs.existsSync as jest.Mock).mockReturnValue(true); setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, createAssistantWithToolUse('Bash', { command: 'rm -rf /' }), { type: 'result' }, ]); const chunks: any[] = []; for await (const chunk of service.query('delete everything')) { chunks.push(chunk); } const blockedChunk = chunks.find((c) => c.type === 'blocked'); expect(blockedChunk).toBeUndefined(); const toolUseChunk = chunks.find((c) => c.type === 'tool_use'); expect(toolUseChunk).toBeDefined(); }); it('should block mkfs commands', async () => { (fs.existsSync as jest.Mock).mockReturnValue(true); setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, createAssistantWithToolUse('Bash', { command: 'mkfs.ext4 /dev/sda1' }), { type: 'result' }, ]); const chunks: any[] = []; for await (const chunk of service.query('format disk')) { chunks.push(chunk); } const blockedChunk = chunks.find((c) => c.type === 'blocked'); expect(blockedChunk).toBeDefined(); expect(blockedChunk?.content).toContain('mkfs'); }); it('should block dd if= commands', async () => { (fs.existsSync as jest.Mock).mockReturnValue(true); setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, createAssistantWithToolUse('Bash', { command: 'dd if=/dev/zero of=/dev/sda' }), { type: 'result' }, ]); const chunks: any[] = []; for await (const chunk of service.query('wipe disk')) { chunks.push(chunk); } const blockedChunk = chunks.find((c) => c.type === 'blocked'); expect(blockedChunk).toBeDefined(); expect(blockedChunk?.content).toContain('dd if='); }); }); describe('findClaudeCLI', () => { beforeEach(() => { mockPlugin.getResolvedClaudeCliPath.mockImplementation(() => resolveClaudeCliPath( undefined, // Hostname path (not used in tests) mockPlugin.settings.claudeCliPath, mockPlugin.getActiveEnvironmentVariables() ) ); }); afterEach(() => { (fs.existsSync as jest.Mock).mockReset(); (fs.statSync as jest.Mock).mockReset(); }); it('should find claude CLI in ~/.claude/local/claude', async () => { const homeDir = os.homedir(); const expectedPath = path.join(homeDir, '.claude', 'local', 'claude'); (fs.existsSync as jest.Mock).mockImplementation((p: string) => { return p === expectedPath; }); (fs.statSync as jest.Mock).mockReturnValue({ isFile: () => true }); setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, { type: 'assistant', message: { content: [{ type: 'text', text: 'Hello' }] } }, ]); const chunks: any[] = []; for await (const chunk of service.query('hello')) { chunks.push(chunk); } const errorChunk = chunks.find( (c) => c.type === 'error' && c.content.includes('Claude CLI not found') ); expect(errorChunk).toBeUndefined(); }); it('should return error when claude CLI not found', async () => { (fs.existsSync as jest.Mock).mockReturnValue(false); const chunks: any[] = []; for await (const chunk of service.query('hello')) { chunks.push(chunk); } const errorChunk = chunks.find((c) => c.type === 'error'); expect(errorChunk).toBeDefined(); expect(errorChunk?.content).toContain('Claude CLI not found'); }); it('should use custom CLI path when valid file is specified', async () => { const customPath = '/custom/path/to/claude'; mockPlugin = createMockPlugin({ claudeCliPath: customPath }); mockPlugin.getResolvedClaudeCliPath.mockImplementation(() => resolveClaudeCliPath( undefined, // Hostname path (not used in tests) mockPlugin.settings.claudeCliPath, mockPlugin.getActiveEnvironmentVariables() ) ); service = new ClaudianService(mockPlugin, createMockMcpManager()); (fs.existsSync as jest.Mock).mockImplementation((p: string) => p === customPath); (fs.statSync as jest.Mock).mockReturnValue({ isFile: () => true }); setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, { type: 'assistant', message: { content: [{ type: 'text', text: 'Hello' }] } }, ]); const chunks: any[] = []; for await (const chunk of service.query('hello')) { chunks.push(chunk); } const errorChunk = chunks.find( (c) => c.type === 'error' && c.content.includes('Claude CLI not found') ); expect(errorChunk).toBeUndefined(); }); it('should fall back to auto-detection when custom path is a directory', async () => { const customPath = '/custom/path/to/directory'; mockPlugin = createMockPlugin({ claudeCliPath: customPath }); mockPlugin.getResolvedClaudeCliPath.mockImplementation(() => resolveClaudeCliPath( undefined, // Hostname path (not used in tests) mockPlugin.settings.claudeCliPath, mockPlugin.getActiveEnvironmentVariables() ) ); service = new ClaudianService(mockPlugin, createMockMcpManager()); const homeDir = os.homedir(); const autoDetectedPath = path.join(homeDir, '.claude', 'local', 'claude'); (fs.existsSync as jest.Mock).mockImplementation((p: string) => p === customPath || p === autoDetectedPath ); (fs.statSync as jest.Mock).mockImplementation((p: string) => ({ isFile: () => p !== customPath, // Custom path is a directory })); setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, { type: 'assistant', message: { content: [{ type: 'text', text: 'Hello' }] } }, ]); const chunks: any[] = []; for await (const chunk of service.query('hello')) { chunks.push(chunk); } // CLI path validation is silent - just verifies fallback works expect(chunks.length).toBeGreaterThan(0); }); it('should fall back to auto-detection when custom path does not exist', async () => { const customPath = '/nonexistent/path/claude'; mockPlugin = createMockPlugin({ claudeCliPath: customPath }); mockPlugin.getResolvedClaudeCliPath.mockImplementation(() => resolveClaudeCliPath( undefined, // Hostname path (not used in tests) mockPlugin.settings.claudeCliPath, mockPlugin.getActiveEnvironmentVariables() ) ); service = new ClaudianService(mockPlugin, createMockMcpManager()); const homeDir = os.homedir(); const autoDetectedPath = path.join(homeDir, '.claude', 'local', 'claude'); (fs.existsSync as jest.Mock).mockImplementation((p: string) => p === autoDetectedPath // Custom path does not exist ); (fs.statSync as jest.Mock).mockImplementation((p: string) => ({ isFile: () => p === autoDetectedPath, })); setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, { type: 'assistant', message: { content: [{ type: 'text', text: 'Hello' }] } }, ]); const chunks: any[] = []; for await (const chunk of service.query('hello')) { chunks.push(chunk); } // CLI path validation is silent - just verifies fallback works expect(chunks.length).toBeGreaterThan(0); }); it('should fall back to auto-detection when custom path stat fails', async () => { const customPath = '/custom/path/to/claude'; mockPlugin = createMockPlugin({ claudeCliPath: customPath }); mockPlugin.getResolvedClaudeCliPath.mockImplementation(() => resolveClaudeCliPath( undefined, // Hostname path (not used in tests) mockPlugin.settings.claudeCliPath, mockPlugin.getActiveEnvironmentVariables() ) ); service = new ClaudianService(mockPlugin, createMockMcpManager()); const homeDir = os.homedir(); const autoDetectedPath = path.join(homeDir, '.claude', 'local', 'claude'); (fs.existsSync as jest.Mock).mockImplementation((p: string) => p === customPath || p === autoDetectedPath ); // Custom path stat throws, auto-detected path works (fs.statSync as jest.Mock).mockImplementation((p: string) => { if (p === customPath) { throw new Error('EACCES'); } return { isFile: () => p === autoDetectedPath }; }); setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, { type: 'assistant', message: { content: [{ type: 'text', text: 'Hello' }] } }, ]); const chunks: any[] = []; for await (const chunk of service.query('hello')) { chunks.push(chunk); } const errorChunk = chunks.find( (c) => c.type === 'error' && c.content.includes('Claude CLI not found') ); expect(errorChunk).toBeUndefined(); const options = getLastOptions(); expect(options?.pathToClaudeCodeExecutable).toBe(autoDetectedPath); }); it('should reload CLI path after cleanup', async () => { const firstPath = '/custom/path/to/claude-1'; const secondPath = '/custom/path/to/claude-2'; mockPlugin = createMockPlugin({ claudeCliPath: firstPath }); mockPlugin.getResolvedClaudeCliPath.mockImplementation(() => resolveClaudeCliPath( undefined, // Hostname path (not used in tests) mockPlugin.settings.claudeCliPath, mockPlugin.getActiveEnvironmentVariables() ) ); service = new ClaudianService(mockPlugin, createMockMcpManager()); (fs.existsSync as jest.Mock).mockImplementation((p: string) => p === firstPath); (fs.statSync as jest.Mock).mockReturnValue({ isFile: () => true }); setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, { type: 'assistant', message: { content: [{ type: 'text', text: 'Hello' }] } }, ]); // eslint-disable-next-line @typescript-eslint/no-unused-vars for await (const _chunk of service.query('hello')) { // drain } const firstOptions = getLastOptions(); expect(firstOptions?.pathToClaudeCodeExecutable).toBe(firstPath); mockPlugin.settings.claudeCliPath = secondPath; service.cleanup(); (fs.existsSync as jest.Mock).mockImplementation((p: string) => p === secondPath); (fs.statSync as jest.Mock).mockReturnValue({ isFile: () => true }); setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, { type: 'assistant', message: { content: [{ type: 'text', text: 'Hello again' }] } }, ]); // eslint-disable-next-line @typescript-eslint/no-unused-vars for await (const _chunk of service.query('hello again')) { // drain } const secondOptions = getLastOptions(); expect(secondOptions?.pathToClaudeCodeExecutable).toBe(secondPath); }); }); describe('transformSDKMessage', () => { beforeEach(() => { (fs.existsSync as jest.Mock).mockReturnValue(true); }); it('should transform assistant text messages', async () => { setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, { type: 'assistant', message: { content: [{ type: 'text', text: 'This is a test response' }] }, }, ]); const chunks: any[] = []; for await (const chunk of service.query('hello')) { chunks.push(chunk); } const textChunk = chunks.find((c) => c.type === 'text'); expect(textChunk).toBeDefined(); expect(textChunk?.content).toBe('This is a test response'); }); it('should transform tool_use from assistant message content', async () => { setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, createAssistantWithToolUse('Read', { file_path: '/test/file.txt' }, 'read-tool-1'), ]); const chunks: any[] = []; for await (const chunk of service.query('read file')) { chunks.push(chunk); } const toolUseChunk = chunks.find((c) => c.type === 'tool_use'); expect(toolUseChunk).toBeDefined(); expect(toolUseChunk?.name).toBe('Read'); expect(toolUseChunk?.input).toEqual({ file_path: '/test/file.txt' }); expect(toolUseChunk?.id).toBe('read-tool-1'); }); it('should transform tool_result from user message', async () => { setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, createAssistantWithToolUse('Read', { file_path: '/test/file.txt' }, 'read-tool-1'), createUserWithToolResult('File contents here', 'read-tool-1'), ]); const chunks: any[] = []; for await (const chunk of service.query('read file')) { chunks.push(chunk); } const toolResultChunk = chunks.find((c) => c.type === 'tool_result'); expect(toolResultChunk).toBeDefined(); expect(toolResultChunk?.content).toBe('File contents here'); expect(toolResultChunk?.id).toBe('read-tool-1'); }); it('should transform assistant error messages', async () => { setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, { type: 'assistant', error: 'Something went wrong', message: { content: [] }, }, ]); const chunks: any[] = []; for await (const chunk of service.query('do something')) { chunks.push(chunk); } const errorChunk = chunks.find((c) => c.type === 'error' && c.content === 'Something went wrong'); expect(errorChunk).toBeDefined(); }); it('should capture session ID from init message', async () => { setMockMessages([ { type: 'system', subtype: 'init', session_id: 'my-session-123' }, { type: 'assistant', message: { content: [{ type: 'text', text: 'Hello' }] } }, ]); const chunks: any[] = []; for await (const chunk of service.query('hello')) { chunks.push(chunk); } expect(chunks.some((c) => c.type === 'text')).toBe(true); }); it('should resume previous session on subsequent queries', async () => { (fs.existsSync as jest.Mock).mockReturnValue(true); setMockMessages([ { type: 'system', subtype: 'init', session_id: 'resume-session' }, { type: 'assistant', message: { content: [{ type: 'text', text: 'First run' }] } }, { type: 'result' }, ]); // eslint-disable-next-line @typescript-eslint/no-unused-vars for await (const _chunk of service.query('first')) { // drain } setMockMessages([ { type: 'assistant', message: { content: [{ type: 'text', text: 'Second run' }] } }, { type: 'result' }, ]); // eslint-disable-next-line @typescript-eslint/no-unused-vars for await (const _chunk of service.query('second', undefined, undefined, { forceColdStart: true })) { // drain } const options = getLastOptions(); expect(options?.resume).toBe('resume-session'); expect(service.getSessionId()).toBe('resume-session'); }); it('should extract multiple content blocks from assistant message', async () => { setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, { type: 'assistant', message: { content: [ { type: 'text', text: 'Let me read that file.' }, { type: 'tool_use', id: 'tool-abc', name: 'Read', input: { file_path: '/foo.txt' } }, ], }, }, ]); const chunks: any[] = []; for await (const chunk of service.query('read foo.txt')) { chunks.push(chunk); } const textChunk = chunks.find((c) => c.type === 'text'); expect(textChunk?.content).toBe('Let me read that file.'); const toolUseChunk = chunks.find((c) => c.type === 'tool_use'); expect(toolUseChunk?.name).toBe('Read'); expect(toolUseChunk?.id).toBe('tool-abc'); }); }); describe('cancel', () => { it('should abort ongoing request', async () => { (fs.existsSync as jest.Mock).mockReturnValue(true); setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, { type: 'assistant', message: { content: [{ type: 'text', text: 'Hello' }] } }, ]); const queryGenerator = service.query('hello'); await queryGenerator.next(); expect(() => service.cancel()).not.toThrow(); }); it('should call interrupt on underlying stream when aborted', async () => { (fs.existsSync as jest.Mock).mockReturnValue(true); setMockMessages([ { type: 'system', subtype: 'init', session_id: 'cancel-session' }, { type: 'assistant', message: { content: [{ type: 'text', text: 'Chunk 1' }] } }, { type: 'assistant', message: { content: [{ type: 'text', text: 'Chunk 2' }] } }, { type: 'result' }, ]); const generator = service.query('streaming'); await generator.next(); service.cancel(); const chunks: any[] = []; for await (const chunk of generator) { chunks.push(chunk); } const response = getLastResponse(); expect(response?.interrupt).toHaveBeenCalled(); expect(chunks.some((c) => c.type === 'done')).toBe(true); }); it('should handle cancel when no query is running', () => { expect(() => service.cancel()).not.toThrow(); }); }); // MessageChannel tests moved to tests/unit/core/agent/MessageChannel.test.ts describe('persistent query updates', () => { it('updates model on the active persistent query', async () => { const chunks: any[] = []; for await (const chunk of service.query('hello', undefined, undefined, { model: 'claude-opus-4-5' })) { chunks.push(chunk); } const response = getLastResponse(); expect(response?.setModel).toHaveBeenCalledWith('claude-opus-4-5'); }); }); describe('persistent query error handling', () => { it('yields error from assistant message with error field', async () => { setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, { type: 'assistant', error: 'server_error', message: { content: [] } }, ]); const chunks: any[] = []; for await (const chunk of service.query('trigger error')) { chunks.push(chunk); } expect(chunks.some((c) => c.type === 'error' && c.content === 'server_error')).toBe(true); expect(chunks.some((c) => c.type === 'done')).toBe(true); }); it('yields error from failed result messages', async () => { setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, buildResultErrorMessage({ subtype: 'error_max_turns', errors: ['Max turns reached'], }), ]); const chunks: any[] = []; for await (const chunk of service.query('trigger max turns')) { chunks.push(chunk); } expect(chunks.some((c) => c.type === 'error' && c.content === 'Max turns reached')).toBe(true); expect(chunks.some((c) => c.type === 'done')).toBe(true); }); // Note: Session expiration is handled via thrown errors in catch blocks, // not via message types. The SDK throws on session expiration which is // caught by isSessionExpiredError() in the query error handlers. }); describe('closePersistentQuery with preserveHandlers', () => { afterEach(() => { service.cleanup(); }); it('preserves handlers when preserveHandlers is true', async () => { // Start a query to create handlers const queryPromise = (async () => { const chunks: any[] = []; for await (const chunk of service.query('hello')) { chunks.push(chunk); } return chunks; })(); // Let the query start await new Promise((resolve) => setTimeout(resolve, 10)); // Access internal state to verify handlers exist const handlersBefore = (service as any).responseHandlers?.length ?? 0; // Close with preserveHandlers: true service.closePersistentQuery('test', { preserveHandlers: true }); // Handlers should still exist const handlersAfter = (service as any).responseHandlers?.length ?? 0; expect(handlersAfter).toBe(handlersBefore); // Clean up the promise (it will resolve/reject after close) await queryPromise.catch(() => { }); }); it('clears handlers when preserveHandlers is false (default)', async () => { // Start a query to create handlers const queryPromise = (async () => { const chunks: any[] = []; for await (const chunk of service.query('hello')) { chunks.push(chunk); } return chunks; })(); // Let the query start await new Promise((resolve) => setTimeout(resolve, 10)); // Close without preserveHandlers (default is false) service.closePersistentQuery('test'); // Handlers should be cleared const handlersAfter = (service as any).responseHandlers?.length ?? 0; expect(handlersAfter).toBe(0); // Clean up the promise await queryPromise.catch(() => { }); }); }); describe('crash recovery with simulateCrash', () => { afterEach(() => { service.cleanup(); }); it('restarts persistent query on consumer error when no chunks received', async () => { // Simulate crash before any chunks are emitted simulateCrash(0); const initialCallCount = getQueryCallCount(); const chunks: any[] = []; // The query should recover and eventually succeed for await (const chunk of service.query('hello')) { chunks.push(chunk); } // Query should have been called twice (initial + restart) expect(getQueryCallCount()).toBe(initialCallCount + 2); // Should have received the successful response after recovery const textChunk = chunks.find((c) => c.type === 'text'); expect(textChunk).toBeDefined(); }); it('does not replay message when chunks were already received before crash', async () => { // Simulate crash after 1 chunk is emitted (system init message) simulateCrash(1); const chunks: any[] = []; for await (const chunk of service.query('hello')) { chunks.push(chunk); } // Should have received the system init chunk before error // Note: error is propagated via onError handler which ends the generator expect(chunks.length).toBeGreaterThan(0); }); }); describe('persistent query recovery after close', () => { afterEach(() => { service.cleanup(); }); it('can start new persistent query after closePersistentQuery', async () => { // First query establishes persistent query const chunks1: any[] = []; for await (const chunk of service.query('first')) { chunks1.push(chunk); } expect(chunks1.length).toBeGreaterThan(0); expect((service as any).persistentQuery).not.toBeNull(); // Close the persistent query (simulating session reset) service.closePersistentQuery('test close'); expect((service as any).persistentQuery).toBeNull(); expect((service as any).shuttingDown).toBe(false); // Should be reset // Next query should start a NEW persistent query (not fall back to cold-start) const chunks2: any[] = []; for await (const chunk of service.query('second')) { chunks2.push(chunk); } expect(chunks2.length).toBeGreaterThan(0); // Verify persistent query was recreated expect((service as any).persistentQuery).not.toBeNull(); }); it('can recover after resetSession closes persistent query', async () => { // First query const chunks1: any[] = []; for await (const chunk of service.query('first')) { chunks1.push(chunk); } expect((service as any).persistentQuery).not.toBeNull(); // Reset session (which closes persistent query) service.resetSession(); expect((service as any).persistentQuery).toBeNull(); expect((service as any).shuttingDown).toBe(false); // Next query should work const chunks2: any[] = []; for await (const chunk of service.query('second')) { chunks2.push(chunk); } expect(chunks2.length).toBeGreaterThan(0); expect((service as any).persistentQuery).not.toBeNull(); }); it('can recover after session switch closes persistent query', async () => { // First query const chunks1: any[] = []; for await (const chunk of service.query('first')) { chunks1.push(chunk); } expect((service as any).persistentQuery).not.toBeNull(); // Switch to a different session (which closes persistent query) service.setSessionId('new-session-id'); expect((service as any).persistentQuery).toBeNull(); expect((service as any).shuttingDown).toBe(false); // Next query should work with new session const chunks2: any[] = []; for await (const chunk of service.query('second')) { chunks2.push(chunk); } expect(chunks2.length).toBeGreaterThan(0); expect((service as any).persistentQuery).not.toBeNull(); }); }); // SessionManager tests (resetSession, getSessionId, setSessionId) moved to: // tests/unit/core/agent/SessionManager.test.ts describe('cleanup', () => { it('should call cancel and resetSession', () => { const cancelSpy = jest.spyOn(service, 'cancel'); const resetSessionSpy = jest.spyOn(service, 'resetSession'); service.cleanup(); expect(cancelSpy).toHaveBeenCalled(); expect(resetSessionSpy).toHaveBeenCalled(); }); }); describe('getVaultPath', () => { it('should return error when vault path cannot be determined', async () => { mockPlugin = { ...mockPlugin, app: { vault: { adapter: {}, }, }, }; service = new ClaudianService(mockPlugin, createMockMcpManager()); const chunks: any[] = []; for await (const chunk of service.query('hello')) { chunks.push(chunk); } const errorChunk = chunks.find( (c) => c.type === 'error' && c.content.includes('vault path') ); expect(errorChunk).toBeDefined(); }); }); describe('regex pattern matching in blocklist', () => { it('should handle regex patterns in blocklist', async () => { mockPlugin = createMockPlugin({ blockedCommands: { unix: ['rm\\s+-rf', 'chmod\\s+7{3}'], windows: [] }, }); service = new ClaudianService(mockPlugin, createMockMcpManager()); (fs.existsSync as jest.Mock).mockReturnValue(true); setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, createAssistantWithToolUse('Bash', { command: 'rm -rf /home' }), { type: 'result' }, ]); const chunks: any[] = []; for await (const chunk of service.query('delete')) { chunks.push(chunk); } const blockedChunk = chunks.find((c) => c.type === 'blocked'); expect(blockedChunk).toBeDefined(); }); it('should fallback to includes for invalid regex', async () => { mockPlugin = createMockPlugin({ blockedCommands: { unix: ['[invalid regex'], windows: [] }, }); service = new ClaudianService(mockPlugin, createMockMcpManager()); (fs.existsSync as jest.Mock).mockReturnValue(true); setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, createAssistantWithToolUse('Bash', { command: 'something with [invalid regex inside' }), { type: 'result' }, ]); const chunks: any[] = []; for await (const chunk of service.query('test')) { chunks.push(chunk); } const blockedChunk = chunks.find((c) => c.type === 'blocked'); expect(blockedChunk).toBeDefined(); }); }); describe('query with conversation history', () => { beforeEach(() => { (fs.existsSync as jest.Mock).mockReturnValue(true); }); it('should accept optional conversation history parameter', async () => { setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, { type: 'assistant', message: { content: [{ type: 'text', text: 'Hello!' }] } }, { type: 'result' }, ]); const history = [ { id: 'msg-1', role: 'user' as const, content: 'Previous message', timestamp: Date.now() }, { id: 'msg-2', role: 'assistant' as const, content: 'Previous response', timestamp: Date.now() }, ]; const chunks: any[] = []; for await (const chunk of service.query('new message', undefined, history)) { chunks.push(chunk); } expect(chunks.some((c) => c.type === 'text')).toBe(true); }); it('should work without conversation history', async () => { setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, { type: 'assistant', message: { content: [{ type: 'text', text: 'Hello!' }] } }, { type: 'result' }, ]); const chunks: any[] = []; for await (const chunk of service.query('hello')) { chunks.push(chunk); } expect(chunks.some((c) => c.type === 'text')).toBe(true); }); it('should rebuild history when session is missing but history exists', async () => { const prompts: string[] = []; jest.spyOn(service as any, 'queryViaSDK').mockImplementation((async function* (prompt: string) { prompts.push(prompt); yield { type: 'text', content: 'ok' }; }) as any); const history = [ { id: 'msg-1', role: 'user' as const, content: 'Previous message', timestamp: Date.now() }, { id: 'msg-2', role: 'assistant' as const, content: 'Previous response', timestamp: Date.now() }, ]; const chunks: any[] = []; for await (const chunk of service.query('New message', undefined, history)) { chunks.push(chunk); } expect(prompts).toHaveLength(1); expect(prompts[0]).toContain('User: Previous message'); expect(prompts[0]).toContain('Assistant: Previous response'); expect(prompts[0]).toContain('User: New message'); expect(chunks.some((c) => c.type === 'text')).toBe(true); }); }); describe('session restoration', () => { it('should use restored session ID on subsequent queries', async () => { (fs.existsSync as jest.Mock).mockReturnValue(true); // Simulate restoring a session ID from storage service.setSessionId('restored-session-id'); setMockMessages([ { type: 'assistant', message: { content: [{ type: 'text', text: 'Resumed!' }] } }, { type: 'result' }, ]); // eslint-disable-next-line @typescript-eslint/no-unused-vars for await (const _chunk of service.query('continue')) { // drain } const options = getLastOptions(); expect(options?.resume).toBe('restored-session-id'); }); it('should capture new session ID from SDK', async () => { (fs.existsSync as jest.Mock).mockReturnValue(true); setMockMessages([ { type: 'system', subtype: 'init', session_id: 'new-captured-session' }, { type: 'assistant', message: { content: [{ type: 'text', text: 'Hello' }] } }, { type: 'result' }, ]); // eslint-disable-next-line @typescript-eslint/no-unused-vars for await (const _chunk of service.query('hello')) { // drain } expect(service.getSessionId()).toBe('new-captured-session'); }); }); describe('vault restriction', () => { beforeEach(() => { (fs.existsSync as jest.Mock).mockReturnValue(true); // Mock realpathSync to normalize paths (resolve .. and .) const normalizePath = (p: string) => { // Use path.resolve to normalize path traversal // eslint-disable-next-line @typescript-eslint/no-require-imports const pathModule = require('path'); return pathModule.resolve(p); }; (fs.realpathSync as any) = jest.fn(normalizePath); if (fs.realpathSync) { (fs.realpathSync as any).native = jest.fn(normalizePath); } }); it('should block Read tool accessing files outside vault', async () => { setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, createAssistantWithToolUse('Read', { file_path: '/etc/passwd' }, 'read-outside'), { type: 'result' }, ]); const chunks: any[] = []; for await (const chunk of service.query('read passwd')) { chunks.push(chunk); } const blockedChunk = chunks.find((c) => c.type === 'blocked'); expect(blockedChunk).toBeDefined(); expect(blockedChunk?.content).toContain('outside the vault'); }); it('should allow Read tool accessing files inside vault', async () => { setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, createAssistantWithToolUse('Read', { file_path: '/test/vault/path/notes/test.md' }, 'read-inside'), createUserWithToolResult('File contents', 'read-inside'), { type: 'result' }, ]); const chunks: any[] = []; for await (const chunk of service.query('read file')) { chunks.push(chunk); } const blockedChunk = chunks.find((c) => c.type === 'blocked'); expect(blockedChunk).toBeUndefined(); const toolResultChunk = chunks.find((c) => c.type === 'tool_result'); expect(toolResultChunk).toBeDefined(); }); it('should block Write tool writing outside vault', async () => { setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, createAssistantWithToolUse('Write', { file_path: '/tmp/malicious.sh', content: 'bad stuff' }, 'write-outside'), { type: 'result' }, ]); const chunks: any[] = []; for await (const chunk of service.query('write file')) { chunks.push(chunk); } const blockedChunk = chunks.find((c) => c.type === 'blocked'); expect(blockedChunk).toBeDefined(); expect(blockedChunk?.content).toContain('outside the vault'); }); it('should allow Write tool writing to allowed export path', async () => { mockPlugin.settings.allowedExportPaths = ['/tmp']; setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, createAssistantWithToolUse('Write', { file_path: '/tmp/export.md', content: 'exported' }, 'write-export'), { type: 'result' }, ]); const chunks: any[] = []; for await (const chunk of service.query('export file')) { chunks.push(chunk); } const blockedChunk = chunks.find((c) => c.type === 'blocked'); expect(blockedChunk).toBeUndefined(); }); it('should allow Write tool writing to context path', async () => { setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, createAssistantWithToolUse('Write', { file_path: '/tmp/workspace/out.md', content: 'allowed' }, 'write-context'), { type: 'result' }, ]); const chunks: any[] = []; for await (const chunk of service.query('write context', undefined, undefined, { externalContextPaths: ['/tmp/workspace'], })) { chunks.push(chunk); } const blockedChunk = chunks.find((c) => c.type === 'blocked'); expect(blockedChunk).toBeUndefined(); }); it('should allow Read tool reading from context path under export path', async () => { mockPlugin.settings.allowedExportPaths = ['/tmp']; setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, createAssistantWithToolUse('Read', { file_path: '/tmp/workspace/in.md' }, 'read-context'), createUserWithToolResult('context contents', 'read-context'), { type: 'result' }, ]); const chunks: any[] = []; for await (const chunk of service.query('read context', undefined, undefined, { externalContextPaths: ['/tmp/workspace'], })) { chunks.push(chunk); } const blockedChunk = chunks.find((c) => c.type === 'blocked'); expect(blockedChunk).toBeUndefined(); }); it('should allow Write tool writing to exact overlap path', async () => { mockPlugin.settings.allowedExportPaths = ['/tmp/shared']; setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, createAssistantWithToolUse('Write', { file_path: '/tmp/shared/out.md', content: 'allowed' }, 'write-overlap'), { type: 'result' }, ]); const chunks: any[] = []; for await (const chunk of service.query('write overlap', undefined, undefined, { externalContextPaths: ['/tmp/shared'], })) { chunks.push(chunk); } const blockedChunk = chunks.find((c) => c.type === 'blocked'); expect(blockedChunk).toBeUndefined(); }); it('should block Edit tool editing outside vault', async () => { setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, createAssistantWithToolUse('Edit', { file_path: '/etc/hosts', old_string: 'old', new_string: 'new' }, 'edit-outside'), { type: 'result' }, ]); const chunks: any[] = []; for await (const chunk of service.query('edit file')) { chunks.push(chunk); } const blockedChunk = chunks.find((c) => c.type === 'blocked'); expect(blockedChunk).toBeDefined(); expect(blockedChunk?.content).toContain('outside the vault'); }); it('should block Bash commands with paths outside vault', async () => { setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, createAssistantWithToolUse('Bash', { command: 'cat /etc/passwd' }, 'bash-outside'), { type: 'result' }, ]); const chunks: any[] = []; for await (const chunk of service.query('read passwd')) { chunks.push(chunk); } const blockedChunk = chunks.find((c) => c.type === 'blocked'); expect(blockedChunk).toBeDefined(); expect(blockedChunk?.content).toContain('outside the vault'); }); it('should allow Bash command writing to allowed export path via redirection', async () => { mockPlugin.settings.allowedExportPaths = ['/tmp']; setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, createAssistantWithToolUse('Bash', { command: 'cat ./notes/file.md > /tmp/out.md' }, 'bash-export'), { type: 'result' }, ]); const chunks: any[] = []; for await (const chunk of service.query('export via bash')) { chunks.push(chunk); } const blockedChunk = chunks.find((c) => c.type === 'blocked'); expect(blockedChunk).toBeUndefined(); }); it('should allow Bash command writing to context path', async () => { setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, createAssistantWithToolUse('Bash', { command: 'echo hi > /tmp/workspace/out.md' }, 'bash-context-write'), { type: 'result' }, ]); const chunks: any[] = []; for await (const chunk of service.query('write context bash', undefined, undefined, { externalContextPaths: ['/tmp/workspace'], })) { chunks.push(chunk); } const blockedChunk = chunks.find((c) => c.type === 'blocked'); expect(blockedChunk).toBeUndefined(); }); it('should allow Bash command writing to allowed export path via -o', async () => { mockPlugin.settings.allowedExportPaths = ['/tmp']; setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, createAssistantWithToolUse('Bash', { command: 'pandoc ./notes/file.md -o /tmp/out.docx' }, 'bash-export-o'), { type: 'result' }, ]); const chunks: any[] = []; for await (const chunk of service.query('export via pandoc')) { chunks.push(chunk); } const blockedChunk = chunks.find((c) => c.type === 'blocked'); expect(blockedChunk).toBeUndefined(); }); it('should block Bash command reading from allowed export path (write-only)', async () => { mockPlugin.settings.allowedExportPaths = ['/tmp']; setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, createAssistantWithToolUse('Bash', { command: 'cat /tmp/out.md' }, 'bash-export-read'), { type: 'result' }, ]); const chunks: any[] = []; for await (const chunk of service.query('read export')) { chunks.push(chunk); } const blockedChunk = chunks.find((c) => c.type === 'blocked'); expect(blockedChunk).toBeDefined(); expect(blockedChunk?.content).toContain('write-only'); }); it('should block Bash command copying from allowed export path into vault (write-only)', async () => { mockPlugin.settings.allowedExportPaths = ['/tmp']; setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, createAssistantWithToolUse('Bash', { command: 'cp /tmp/out.md ./notes/out.md' }, 'bash-export-cp'), { type: 'result' }, ]); const chunks: any[] = []; for await (const chunk of service.query('copy export')) { chunks.push(chunk); } const blockedChunk = chunks.find((c) => c.type === 'blocked'); expect(blockedChunk).toBeDefined(); expect(blockedChunk?.content).toContain('write-only'); }); it('should allow Bash commands with paths inside vault', async () => { setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, createAssistantWithToolUse('Bash', { command: 'cat /test/vault/path/notes/file.md' }, 'bash-inside'), createUserWithToolResult('File contents', 'bash-inside'), { type: 'result' }, ]); const chunks: any[] = []; for await (const chunk of service.query('cat file')) { chunks.push(chunk); } // Should not be blocked by vault restriction (may still be blocked by blocklist) const blockedChunk = chunks.find((c) => c.type === 'blocked' && c.content.includes('outside the vault')); expect(blockedChunk).toBeUndefined(); }); it('should block path traversal attempts', async () => { setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, createAssistantWithToolUse('Read', { file_path: '/test/vault/path/../../../etc/passwd' }, 'read-traversal'), { type: 'result' }, ]); const chunks: any[] = []; for await (const chunk of service.query('read file')) { chunks.push(chunk); } const blockedChunk = chunks.find((c) => c.type === 'blocked'); expect(blockedChunk).toBeDefined(); expect(blockedChunk?.content).toContain('outside the vault'); }); it('should block Glob tool searching outside vault', async () => { setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, createAssistantWithToolUse('Glob', { pattern: '*.md', path: '/etc' }, 'glob-outside'), { type: 'result' }, ]); const chunks: any[] = []; for await (const chunk of service.query('search files')) { chunks.push(chunk); } const blockedChunk = chunks.find((c) => c.type === 'blocked'); expect(blockedChunk).toBeDefined(); expect(blockedChunk?.content).toContain('outside the vault'); }); it('should block Glob tool with escaping pattern', async () => { setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, createAssistantWithToolUse('Glob', { pattern: '../**/*.md' }, 'glob-escape'), { type: 'result' }, ]); const chunks: any[] = []; for await (const chunk of service.query('search files')) { chunks.push(chunk); } const blockedChunk = chunks.find((c) => c.type === 'blocked'); expect(blockedChunk).toBeDefined(); expect(blockedChunk?.content).toContain('outside the vault'); }); it('should block Grep tool searching outside vault', async () => { setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, createAssistantWithToolUse('Grep', { pattern: 'passwd', path: '/etc' }, 'grep-outside'), { type: 'result' }, ]); const chunks: any[] = []; for await (const chunk of service.query('grep outside')) { chunks.push(chunk); } const blockedChunk = chunks.find((c) => c.type === 'blocked'); expect(blockedChunk).toBeDefined(); expect(blockedChunk?.content).toContain('outside the vault'); }); it('should not block Grep tool with absolute pattern', async () => { setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, createAssistantWithToolUse('Grep', { pattern: '/etc/passwd' }, 'grep-abs-pattern'), { type: 'result' }, ]); const chunks: any[] = []; for await (const chunk of service.query('grep pattern')) { chunks.push(chunk); } const blockedChunk = chunks.find((c) => c.type === 'blocked'); expect(blockedChunk).toBeUndefined(); }); it('should block tilde expansion paths outside vault', async () => { setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, createAssistantWithToolUse('Bash', { command: 'cat ~/.bashrc' }, 'bash-tilde'), { type: 'result' }, ]); const chunks: any[] = []; for await (const chunk of service.query('read bashrc')) { chunks.push(chunk); } const blockedChunk = chunks.find((c) => c.type === 'blocked'); expect(blockedChunk).toBeDefined(); expect(blockedChunk?.content).toContain('outside the vault'); }); it('should block NotebookEdit tool writing outside vault', async () => { setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, createAssistantWithToolUse('NotebookEdit', { notebook_path: '/etc/passwd', file_path: '/etc/passwd' }, 'notebook-outside'), { type: 'result' }, ]); const chunks: any[] = []; for await (const chunk of service.query('edit notebook')) { chunks.push(chunk); } const blockedChunk = chunks.find((c) => c.type === 'blocked'); expect(blockedChunk).toBeDefined(); expect(blockedChunk?.content).toContain('outside the vault'); }); it('should block LS tool paths outside vault', async () => { setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, createAssistantWithToolUse('LS', { path: '/etc' }, 'ls-outside'), { type: 'result' }, ]); const chunks: any[] = []; for await (const chunk of service.query('list files')) { chunks.push(chunk); } const blockedChunk = chunks.find((c) => c.type === 'blocked'); expect(blockedChunk).toBeDefined(); }); it('should block relative paths in Bash commands that escape vault', async () => { setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, createAssistantWithToolUse('Bash', { command: 'cat ../secrets.txt' }, 'bash-relative'), { type: 'result' }, ]); const chunks: any[] = []; for await (const chunk of service.query('read relative')) { chunks.push(chunk); } const blockedChunk = chunks.find((c) => c.type === 'blocked'); expect(blockedChunk).toBeDefined(); expect(blockedChunk?.content).toContain('outside the vault'); }); }); describe('extended thinking', () => { beforeEach(() => { (fs.existsSync as jest.Mock).mockReturnValue(true); }); it('should transform thinking blocks from assistant messages', async () => { setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, { type: 'assistant', message: { content: [ { type: 'thinking', thinking: 'Let me analyze this problem...' }, { type: 'text', text: 'Here is my answer.' }, ], }, }, { type: 'result' }, ]); const chunks: any[] = []; for await (const chunk of service.query('think about this')) { chunks.push(chunk); } const thinkingChunk = chunks.find((c) => c.type === 'thinking'); expect(thinkingChunk).toBeDefined(); expect(thinkingChunk?.content).toBe('Let me analyze this problem...'); const textChunk = chunks.find((c) => c.type === 'text'); expect(textChunk).toBeDefined(); expect(textChunk?.content).toBe('Here is my answer.'); }); it('should transform thinking deltas from stream events', async () => { setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, { type: 'stream_event', event: { type: 'content_block_start', content_block: { type: 'thinking', thinking: 'Starting thought...' }, }, }, { type: 'stream_event', event: { type: 'content_block_delta', delta: { type: 'thinking_delta', thinking: ' continuing thought...' }, }, }, { type: 'result' }, ]); const chunks: any[] = []; for await (const chunk of service.query('think')) { chunks.push(chunk); } const thinkingChunks = chunks.filter((c) => c.type === 'thinking'); expect(thinkingChunks.length).toBeGreaterThanOrEqual(1); expect(thinkingChunks.some((c) => c.content.includes('thought'))).toBe(true); }); }); describe('permission utility functions', () => { it('should generate correct action patterns for different tools', () => { expect(getActionPattern('Bash', { command: 'git status' })).toBe('git status'); expect(getActionPattern('Read', { file_path: '/test/file.md' })).toBe('/test/file.md'); expect(getActionPattern('Write', { file_path: '/test/output.md' })).toBe('/test/output.md'); expect(getActionPattern('Edit', { file_path: '/test/edit.md' })).toBe('/test/edit.md'); expect(getActionPattern('Glob', { pattern: '**/*.md' })).toBe('**/*.md'); expect(getActionPattern('Grep', { pattern: 'TODO' })).toBe('TODO'); }); it('should generate correct action descriptions', () => { expect(getActionDescription('Bash', { command: 'git status' })).toBe('Run command: git status'); expect(getActionDescription('Read', { file_path: '/test/file.md' })).toBe('Read file: /test/file.md'); expect(getActionDescription('Write', { file_path: '/test/output.md' })).toBe('Write to file: /test/output.md'); expect(getActionDescription('Edit', { file_path: '/test/edit.md' })).toBe('Edit file: /test/edit.md'); expect(getActionDescription('Glob', { pattern: '**/*.md' })).toBe('Search files matching: **/*.md'); expect(getActionDescription('Grep', { pattern: 'TODO' })).toBe('Search content matching: TODO'); }); }); // Note: safe mode approvals tests removed - createUnifiedToolCallback was part of plan mode describe('session expiration recovery', () => { beforeEach(() => { (fs.existsSync as jest.Mock).mockReturnValue(true); }); it('should detect session expired errors', () => { // Now test the standalone function directly expect(isSessionExpiredError(new Error('Session expired'))).toBe(true); expect(isSessionExpiredError(new Error('session not found'))).toBe(true); expect(isSessionExpiredError(new Error('invalid session'))).toBe(true); expect(isSessionExpiredError(new Error('Resume failed'))).toBe(true); }); it('should not detect non-session errors as session errors', () => { // Now test the standalone function directly expect(isSessionExpiredError(new Error('Network error'))).toBe(false); expect(isSessionExpiredError(new Error('Rate limited'))).toBe(false); expect(isSessionExpiredError(new Error('Invalid API key'))).toBe(false); }); it('should build context from conversation history', () => { const messages = [ { id: 'msg-1', role: 'user' as const, content: 'Hello', timestamp: Date.now() }, { id: 'msg-2', role: 'assistant' as const, content: 'Hi there!', timestamp: Date.now() }, { id: 'msg-3', role: 'user' as const, content: 'How are you?', timestamp: Date.now() }, ]; // Now test the standalone function directly const context = buildContextFromHistory(messages); expect(context).toContain('User: Hello'); expect(context).toContain('Assistant: Hi there!'); expect(context).toContain('User: How are you?'); }); it('should include tool call info with input (status only for success)', () => { const messages = [ { id: 'msg-1', role: 'user' as const, content: 'Read a file', timestamp: Date.now() }, { id: 'msg-2', role: 'assistant' as const, content: 'Reading file...', timestamp: Date.now(), toolCalls: [ { id: 'tool-1', name: 'Read', input: { file_path: '/test.md' }, status: 'completed' as const, result: 'File contents' }, ], }, ]; // Now test the standalone function directly const context = buildContextFromHistory(messages); // Successful tools show input but no result (Claude can re-read if needed) expect(context).toContain('[Tool Read input: file_path=/test.md status=completed]'); expect(context).not.toContain('File contents'); }); it('should include error messages for failed tool calls with input', () => { const messages = [ { id: 'msg-1', role: 'user' as const, content: 'Read a file', timestamp: Date.now() }, { id: 'msg-2', role: 'assistant' as const, content: 'Reading file...', timestamp: Date.now(), toolCalls: [ { id: 'tool-1', name: 'Read', input: { file_path: '/missing.md' }, status: 'error' as const, result: 'File not found' }, ], }, ]; const context = buildContextFromHistory(messages); // Failed tools include input AND error message so Claude knows what went wrong expect(context).toContain('[Tool Read input: file_path=/missing.md status=error] error: File not found'); }); it('should include current note in rebuilt history', () => { const messages = [ { id: 'msg-1', role: 'user' as const, content: 'Edit this file', timestamp: Date.now(), currentNote: 'notes/file.md' }, ]; // Now test the standalone function directly const context = buildContextFromHistory(messages); expect(context).toContain(''); expect(context).toContain('notes/file.md'); }); it('should truncate long tool results', () => { const longResult = 'x'.repeat(1000); // Now test the standalone function directly const truncated = truncateToolResult(longResult, 100); expect(truncated.length).toBeLessThan(longResult.length); expect(truncated).toContain('(truncated)'); }); it('should not truncate short tool results', () => { const shortResult = 'Short result'; // Now test the standalone function directly const result = truncateToolResult(shortResult, 100); expect(result).toBe(shortResult); }); }); describe('session expiration recovery flow', () => { beforeEach(() => { (fs.existsSync as jest.Mock).mockReturnValue(true); mockPlugin.getResolvedClaudeCliPath.mockReturnValue('/mock/claude'); }); it('should rebuild history and retry without resume on session expiration', async () => { service.setSessionId('stale-session'); const prompts: string[] = []; jest.spyOn(service as any, 'queryViaSDK').mockImplementation((async function* (prompt: string) { prompts.push(prompt); if (prompts.length === 1) { throw new Error('Session expired'); } yield { type: 'text', content: 'Recovered' }; }) as any); const history = [ { id: 'msg-1', role: 'user' as const, content: 'First question', timestamp: Date.now() }, { id: 'msg-2', role: 'assistant' as const, content: 'Answer', timestamp: Date.now(), toolCalls: [ { id: 'tool-1', name: 'Read', input: { file_path: '/test/vault/path/file.md' }, status: 'completed' as const, result: 'file content' }, ], }, { id: 'msg-3', role: 'user' as const, content: 'Follow up', timestamp: Date.now(), currentNote: 'note.md' }, ]; const chunks: any[] = []; for await (const chunk of service.query('Follow up', undefined, history, { forceColdStart: true })) { chunks.push(chunk); } expect(prompts[0]).toBe('Follow up'); expect(prompts[1]).toContain('User: First question'); expect(prompts[1]).toContain('Assistant: Answer'); expect(prompts[1]).toContain(''); expect(prompts[1]).toContain('note.md'); expect(chunks.some((c) => c.type === 'text' && c.content === 'Recovered')).toBe(true); expect(service.getSessionId()).toBeNull(); }); it('should rebuild history when persistent query throws session expired', async () => { service.setSessionId('stale-session'); const prompts: string[] = []; // eslint-disable-next-line require-yield jest.spyOn(service as any, 'queryViaPersistent').mockImplementation((async function* () { throw new Error('Session expired'); }) as any); jest.spyOn(service as any, 'queryViaSDK').mockImplementation((async function* (prompt: string) { prompts.push(prompt); yield { type: 'text', content: 'Recovered' }; }) as any); const history = [ { id: 'msg-1', role: 'user' as const, content: 'First question', timestamp: Date.now() }, { id: 'msg-2', role: 'assistant' as const, content: 'Answer', timestamp: Date.now() }, { id: 'msg-3', role: 'user' as const, content: 'Follow up', timestamp: Date.now() }, ]; const chunks: any[] = []; for await (const chunk of service.query('Follow up', undefined, history)) { chunks.push(chunk); } expect(prompts).toHaveLength(1); expect(prompts[0]).toContain('User: First question'); expect(prompts[0]).toContain('Assistant: Answer'); expect(prompts[0]).toContain('User: Follow up'); expect(chunks.some((c) => c.type === 'text' && c.content === 'Recovered')).toBe(true); expect(service.getSessionId()).toBeNull(); }); it('should preserve current message images when session expired during cold-start', async () => { service.setSessionId('stale-session'); let capturedImages: any[] | undefined; jest.spyOn(service as any, 'queryViaSDK').mockImplementation((async function* ( _prompt: string, _vaultPath: string, _cliPath: string, images: any[] | undefined ) { if (!capturedImages) { // First call throws session expired capturedImages = images; throw new Error('Session expired'); } // Second call (retry) should have the images capturedImages = images; yield { type: 'text', content: 'Recovered' }; }) as any); const history = [ { id: 'msg-1', role: 'user' as const, content: 'First question', timestamp: Date.now() }, { id: 'msg-2', role: 'assistant' as const, content: 'Answer', timestamp: Date.now() }, ]; const currentImages = [ { id: 'img-1', name: 'test.png', mediaType: 'image/png' as const, data: 'base64data', size: 100, source: 'file' as const }, ]; const chunks: any[] = []; for await (const chunk of service.query('Follow up with image', currentImages, history, { forceColdStart: true })) { chunks.push(chunk); } expect(capturedImages).toBeDefined(); expect(capturedImages).toHaveLength(1); expect(capturedImages![0].id).toBe('img-1'); expect(chunks.some((c) => c.type === 'text' && c.content === 'Recovered')).toBe(true); }); it('should preserve current message images when session expired during persistent query', async () => { service.setSessionId('stale-session'); let capturedImages: any[] | undefined; // eslint-disable-next-line require-yield jest.spyOn(service as any, 'queryViaPersistent').mockImplementation((async function* () { throw new Error('Session expired'); }) as any); jest.spyOn(service as any, 'queryViaSDK').mockImplementation((async function* ( _prompt: string, _vaultPath: string, _cliPath: string, images: any[] | undefined ) { capturedImages = images; yield { type: 'text', content: 'Recovered' }; }) as any); const history = [ { id: 'msg-1', role: 'user' as const, content: 'First question', timestamp: Date.now() }, { id: 'msg-2', role: 'assistant' as const, content: 'Answer', timestamp: Date.now() }, ]; const currentImages = [ { id: 'img-1', name: 'test.png', mediaType: 'image/png' as const, data: 'base64data', size: 100, source: 'file' as const }, { id: 'img-2', name: 'test2.jpg', mediaType: 'image/jpeg' as const, data: 'base64data2', size: 200, source: 'paste' as const }, ]; const chunks: any[] = []; for await (const chunk of service.query('Follow up with images', currentImages, history)) { chunks.push(chunk); } expect(capturedImages).toBeDefined(); expect(capturedImages).toHaveLength(2); expect(capturedImages![0].id).toBe('img-1'); expect(capturedImages![1].id).toBe('img-2'); expect(chunks.some((c) => c.type === 'text' && c.content === 'Recovered')).toBe(true); }); }); describe('image prompt and hydration', () => { beforeEach(() => { (fs.existsSync as jest.Mock).mockReturnValue(true); mockPlugin.getResolvedClaudeCliPath.mockReturnValue('/mock/claude'); }); it('should return plain prompt when no valid images', () => { const prompt = (service as any).buildPromptWithImages('hello', []); expect(prompt).toBe('hello'); }); it('should build async generator with image blocks', async () => { const images = [ { id: 'img-1', name: 'a.png', mediaType: 'image/png', data: 'AAA', size: 3, source: 'file' }, { id: 'img-2', name: 'b.png', mediaType: 'image/png', data: 'BBB', size: 3, source: 'file' }, ]; const gen = (service as any).buildPromptWithImages('hi', images) as AsyncGenerator; const messages: any[] = []; for await (const m of gen) messages.push(m); expect(messages).toHaveLength(1); expect(messages[0].type).toBe('user'); expect(messages[0].message.content[0].type).toBe('image'); expect(messages[0].message.content[2].type).toBe('text'); }); }); // QueryOptionsBuilder tests moved to tests/unit/core/agent/QueryOptionsBuilder.test.ts describe('transformSDKMessage additional branches', () => { it('should transform tool_result blocks inside user content', () => { const sdkMessage: any = { type: 'user', message: { content: [ { type: 'tool_result', tool_use_id: 'tool-1', content: 'out', is_error: true }, ], }, }; const chunks = Array.from(transformSDKMessage(sdkMessage)); expect(chunks[0]).toEqual(expect.objectContaining({ type: 'tool_result', id: 'tool-1', isError: true })); }); it('should transform stream_event tool_use and text blocks', () => { const toolUseMsg: any = { type: 'stream_event', event: { type: 'content_block_start', content_block: { type: 'tool_use', id: 't1', name: 'Read', input: {} } }, }; const textStartMsg: any = { type: 'stream_event', event: { type: 'content_block_start', content_block: { type: 'text', text: 'hello' } }, }; const textDeltaMsg: any = { type: 'stream_event', event: { type: 'content_block_delta', delta: { type: 'text_delta', text: ' world' } }, }; const toolChunks = Array.from(transformSDKMessage(toolUseMsg)); const textChunks = [ ...Array.from(transformSDKMessage(textStartMsg)), ...Array.from(transformSDKMessage(textDeltaMsg)), ]; expect(toolChunks[0]).toEqual(expect.objectContaining({ type: 'tool_use', id: 't1', name: 'Read' })); expect(textChunks.map((c: any) => c.content).join('')).toBe('hello world'); }); // Note: Tests for result message usage were removed because transformSDKMessage // now extracts usage from assistant messages (not result messages) to avoid // inaccurate spikes from aggregated subagent tokens it('should emit no chunks for result messages (usage now comes from assistant messages)', () => { const sdkMessage: any = { type: 'result', modelUsage: { 'model-a': { inputTokens: 10, cacheCreationInputTokens: 0, cacheReadInputTokens: 0, contextWindow: 0, }, }, }; const chunks = Array.from(transformSDKMessage(sdkMessage)); expect(chunks).toHaveLength(0); }); }); describe('remaining business branches', () => { beforeEach(() => { (fs.existsSync as jest.Mock).mockReturnValue(true); mockPlugin.getResolvedClaudeCliPath.mockReturnValue('/mock/claude'); }); it('yields error when session retry also fails', async () => { // eslint-disable-next-line require-yield jest.spyOn(service as any, 'queryViaSDK').mockImplementation(async function* () { throw new Error('Session expired'); }); const history = [ { id: 'u1', role: 'user' as const, content: 'Hi', timestamp: 0 }, ]; const chunks: any[] = []; for await (const c of service.query('Hi', undefined, history, { forceColdStart: true })) chunks.push(c); const errorChunk = chunks.find((c) => c.type === 'error'); expect(errorChunk).toBeDefined(); expect(errorChunk.content).toContain('Session expired'); }); it('yields error for non-session failures', async () => { // eslint-disable-next-line require-yield jest.spyOn(service as any, 'queryViaSDK').mockImplementation(async function* () { throw new Error('Network down'); }); const chunks: any[] = []; for await (const c of service.query('Hi', undefined, undefined, { forceColdStart: true })) chunks.push(c); expect(chunks.some((c) => c.type === 'error' && c.content.includes('Network down'))).toBe(true); }); it('skips non-user messages and empty assistants in rebuilt context', () => { const messages: any[] = [ { id: 'sys', role: 'system', content: 'ignore', timestamp: 0 }, { id: 'a1', role: 'assistant', content: '', timestamp: 0 }, { id: 'u1', role: 'user', content: 'Hello', timestamp: 0 }, ]; // Now test the standalone function directly const context = buildContextFromHistory(messages); expect(context).toContain('User: Hello'); expect(context).not.toContain('system'); }); it('returns undefined when no user message exists', () => { // Now test the standalone function directly const last = getLastUserMessage([ { id: 'a1', role: 'assistant' as const, content: 'Hi', timestamp: 0 }, ]); expect(last).toBeUndefined(); }); it('formats tool call without result', () => { // Now test the standalone function directly const line = formatToolCallForContext({ id: 't', name: 'Read', input: {}, status: 'completed' as const }); expect(line).toBe('[Tool Read status=completed]'); }); it('yields error when SDK query throws inside queryViaSDK', async () => { // eslint-disable-next-line @typescript-eslint/no-require-imports const sdk = require('@anthropic-ai/claude-agent-sdk'); const spy = jest.spyOn(sdk, 'query').mockImplementation(() => { throw new Error('boom'); }); const chunks: any[] = []; for await (const c of service.query('Hi', undefined, undefined, { forceColdStart: true })) chunks.push(c); expect(chunks.some((c) => c.type === 'error' && c.content.includes('boom'))).toBe(true); spy.mockRestore(); }); // Note: 'allows pre-approved actions' test removed - createUnifiedToolCallback was part of plan mode it('returns continue for non-file tools in vault hook and null for unknown paths', async () => { // Create vault restriction hook using the exported function const hook = createVaultRestrictionHook({ getPathAccessType: () => 'vault', }); const res = await hook.hooks[0]({ tool_name: 'WebSearch', tool_input: {} } as any, 't1', {} as any); expect((res as any).continue).toBe(true); expect(getPathFromToolInput('WebSearch', {})).toBeNull(); }); it('does not treat Grep pattern as a path', () => { expect(getPathFromToolInput('Grep', { pattern: '/etc/passwd' })).toBeNull(); expect(getPathFromToolInput('Grep', { pattern: 'TODO', path: 'notes' })).toBe('notes'); }); it('covers NotebookEdit and default patterns/descriptions', () => { // Now test the standalone functions directly expect(getActionPattern('NotebookEdit', { notebook_path: 'nb.ipynb' })).toBe('nb.ipynb'); expect(getActionPattern('Other', { foo: 'bar' })).toContain('foo'); expect(getActionDescription('Other', { foo: 'bar' })).toContain('foo'); }); }); describe('persistent query configuration detection', () => { it('detects system prompt changes requiring restart', async () => { // First query establishes baseline config const chunks1: any[] = []; for await (const c of service.query('first')) chunks1.push(c); // Change system prompt which affects systemPromptKey mockPlugin.settings.systemPrompt = 'new custom prompt'; // Second query should detect the change const chunks2: any[] = []; for await (const c of service.query('second')) chunks2.push(c); // If restart happened, the session would change // The service should have detected the configuration change expect(chunks2.some((c) => c.type === 'done')).toBe(true); }); it('detects export paths changes requiring restart', async () => { const chunks1: any[] = []; for await (const c of service.query('first')) chunks1.push(c); // Change export paths mockPlugin.settings.allowedExportPaths = ['/new/export/path']; const chunks2: any[] = []; for await (const c of service.query('second')) chunks2.push(c); expect(chunks2.some((c) => c.type === 'done')).toBe(true); }); }); describe('persistent query dynamic updates', () => { it('updates thinking tokens on the active persistent query when budget changes (custom model)', async () => { // Use a custom model so the legacy budget path is used mockPlugin.settings.model = 'custom-model'; mockPlugin.settings.thinkingBudget = 'off'; const chunks1: any[] = []; for await (const c of service.query('first')) chunks1.push(c); // Change thinking budget - this should trigger setMaxThinkingTokens on next query mockPlugin.settings.thinkingBudget = 'high'; const chunks2: any[] = []; for await (const c of service.query('second')) chunks2.push(c); const response = getLastResponse(); // setMaxThinkingTokens should be called with the new budget value (16000 for 'high') expect(response?.setMaxThinkingTokens).toHaveBeenCalledWith(16000); }); it('does not call setMaxThinkingTokens for adaptive models when budget changes', async () => { // Adaptive model (sonnet) should use effort levels, not token budgets mockPlugin.settings.model = 'sonnet'; mockPlugin.settings.thinkingBudget = 'off'; const chunks1: any[] = []; for await (const c of service.query('first')) chunks1.push(c); // Change thinking budget — should be ignored for adaptive models mockPlugin.settings.thinkingBudget = 'high'; const chunks2: any[] = []; for await (const c of service.query('second')) chunks2.push(c); const response = getLastResponse(); expect(response?.setMaxThinkingTokens).not.toHaveBeenCalled(); }); it('updates permission mode via setPermissionMode when going from YOLO to normal', async () => { // Start in YOLO mode mockPlugin.settings.permissionMode = 'yolo'; service = new ClaudianService(mockPlugin, createMockMcpManager()); const chunks1: any[] = []; for await (const c of service.query('first')) chunks1.push(c); // Switch to normal mode mockPlugin.settings.permissionMode = 'normal'; const chunks2: any[] = []; for await (const c of service.query('second')) chunks2.push(c); const response = getLastResponse(); // Should call setPermissionMode for YOLO -> normal transition expect(response?.setPermissionMode).toHaveBeenCalledWith('acceptEdits'); }); it('updates permission mode via setPermissionMode when going from normal to YOLO', async () => { // Start in normal mode mockPlugin.settings.permissionMode = 'normal'; service = new ClaudianService(mockPlugin, createMockMcpManager()); const chunks1: any[] = []; for await (const c of service.query('first')) chunks1.push(c); // Switch to YOLO mode mockPlugin.settings.permissionMode = 'yolo'; const chunks2: any[] = []; for await (const c of service.query('second')) chunks2.push(c); const response = getLastResponse(); // Should call setPermissionMode for normal -> YOLO transition (no restart needed) expect(response?.setPermissionMode).toHaveBeenCalledWith('bypassPermissions'); }); it('updates MCP servers on the active persistent query', async () => { const chunks1: any[] = []; for await (const c of service.query('first', undefined, undefined, { mcpMentions: new Set(['server1']), })) chunks1.push(c); const response1 = getLastResponse(); expect(response1?.setMcpServers).toHaveBeenCalled(); // Query with different MCP mentions const chunks2: any[] = []; for await (const c of service.query('second', undefined, undefined, { mcpMentions: new Set(['server2']), })) chunks2.push(c); const response2 = getLastResponse(); expect(response2?.setMcpServers).toHaveBeenCalled(); }); it('reapplies query overrides after restart triggered by config change', async () => { const chunks1: any[] = []; for await (const c of service.query('first')) chunks1.push(c); mockPlugin.settings.systemPrompt = 'restart-required'; const chunks2: any[] = []; for await (const c of service.query('second', undefined, undefined, { model: 'claude-opus-4-5', })) chunks2.push(c); const response = getLastResponse(); expect(response?.setModel).toHaveBeenCalledWith('claude-opus-4-5'); }); it('falls back to cold-start when restart fails during dynamic updates', async () => { const chunks1: any[] = []; for await (const c of service.query('first')) chunks1.push(c); expect((service as any).persistentQuery).not.toBeNull(); // Force a config change that requires restart mockPlugin.settings.systemPrompt = 'restart-required'; // Allow query + applyDynamicUpdates, then fail restart due to missing CLI path mockPlugin.getResolvedClaudeCliPath.mockReset(); mockPlugin.getResolvedClaudeCliPath .mockReturnValueOnce('/mock/claude') .mockReturnValueOnce('/mock/claude') .mockReturnValueOnce(null); const callCountBeforeSecond = getQueryCallCount(); const chunks2: any[] = []; for await (const c of service.query('second')) chunks2.push(c); expect(chunks2.some((c) => c.type === 'text')).toBe(true); expect(getQueryCallCount()).toBe(callCountBeforeSecond + 1); expect((service as any).persistentQuery).toBeNull(); expect((service as any).shuttingDown).toBe(false); }); }); describe('persistent query crash recovery', () => { it('prevents infinite crash recovery loops via crashRecoveryAttempted flag', async () => { // Access private state for testing const serviceAny = service as any; // Simulate first crash recovery serviceAny.crashRecoveryAttempted = false; serviceAny.lastSentMessage = createTextUserMessage('test'); // After first crash, flag should be set serviceAny.crashRecoveryAttempted = true; // Second crash should not attempt recovery const shouldResend = serviceAny.lastSentMessage && !serviceAny.crashRecoveryAttempted; expect(shouldResend).toBe(false); }); it('clears lastSentMessage on successful completion', async () => { const serviceAny = service as any; // Before query, lastSentMessage should be null expect(serviceAny.lastSentMessage).toBeNull(); // Run a query const chunks: any[] = []; for await (const c of service.query('test')) chunks.push(c); // After successful completion, lastSentMessage should be cleared expect(serviceAny.lastSentMessage).toBeNull(); }); }); // Note: 'persistent query deferred close', 'tool restriction with allowed tools list', and // 'persistent query permission mode transitions' tests removed - createUnifiedToolCallback/pendingCloseReason // were part of plan mode describe('persistent query crash recovery behavior', () => { it('restarts persistent query after consumer error to prepare for next query', async () => { const serviceAny = service as any; // Run a query to set up the persistent query const chunks: any[] = []; for await (const c of service.query('initial')) chunks.push(c); // The persistent query should exist expect(serviceAny.persistentQuery).not.toBeNull(); // Crash recovery should restart the persistent query loop serviceAny.crashRecoveryAttempted = false; await service.ensureReady({ force: true }); // After restart, persistent query should still be ready expect(serviceAny.persistentQuery).not.toBeNull(); }); it('re-enqueues pending message after crash recovery restart', async () => { // eslint-disable-next-line @typescript-eslint/no-require-imports const sdk = require('@anthropic-ai/claude-agent-sdk'); let callCount = 0; let firstPrompt: any = null; let secondPrompt: any = null; let resolveSecondPrompt: ((message: any) => void) | null = null; const secondPromptPromise = new Promise((resolve, reject) => { const timeout = setTimeout(() => { reject(new Error('Timed out waiting for crash recovery re-enqueue')); }, 2000); resolveSecondPrompt = (message: any) => { clearTimeout(timeout); resolve(message); }; }); const spy = jest.spyOn(sdk, 'query').mockImplementation(({ prompt }: { prompt: any }) => { callCount += 1; const callIndex = callCount; const generator = async function* () { if (prompt && typeof prompt[Symbol.asyncIterator] === 'function') { for await (const message of prompt) { if (callIndex === 1) { firstPrompt = message; throw new Error('boom'); } secondPrompt = message; if (resolveSecondPrompt) resolveSecondPrompt(message); yield { type: 'system', subtype: 'init', session_id: 'test-session-123' }; yield { type: 'assistant', message: { content: [{ type: 'text', text: 'Recovered' }] } }; yield { type: 'result', result: 'completed' }; } return; } if (callIndex === 1) { firstPrompt = prompt; throw new Error('boom'); } secondPrompt = prompt; if (resolveSecondPrompt) resolveSecondPrompt(prompt); yield { type: 'system', subtype: 'init', session_id: 'test-session-123' }; yield { type: 'assistant', message: { content: [{ type: 'text', text: 'Recovered' }] } }; yield { type: 'result', result: 'completed' }; }; const gen = generator() as AsyncGenerator & { interrupt: jest.Mock; setModel: jest.Mock; setMaxThinkingTokens: jest.Mock; setPermissionMode: jest.Mock; setMcpServers: jest.Mock; }; gen.interrupt = jest.fn().mockResolvedValue(undefined); gen.setModel = jest.fn().mockResolvedValue(undefined); gen.setMaxThinkingTokens = jest.fn().mockResolvedValue(undefined); gen.setPermissionMode = jest.fn().mockResolvedValue(undefined); gen.setMcpServers = jest.fn().mockResolvedValue({ added: [], removed: [], errors: {} }); return gen; }); try { // eslint-disable-next-line @typescript-eslint/no-unused-vars for await (const _chunk of service.query('initial')) { // drain } await secondPromptPromise; expect(callCount).toBeGreaterThanOrEqual(2); expect(firstPrompt).not.toBeNull(); expect(secondPrompt).not.toBeNull(); expect(secondPrompt.message?.content).toEqual(firstPrompt.message?.content); } finally { spy.mockRestore(); } }); it('only attempts crash recovery once via crashRecoveryAttempted flag', async () => { const serviceAny = service as any; // Run a query to set up the persistent query const chunks: any[] = []; for await (const c of service.query('initial')) chunks.push(c); // First crash - should attempt recovery expect(serviceAny.crashRecoveryAttempted).toBe(false); const shouldAttemptFirst = !serviceAny.crashRecoveryAttempted; expect(shouldAttemptFirst).toBe(true); // After first crash, flag is set serviceAny.crashRecoveryAttempted = true; // Second crash - should not attempt recovery const shouldAttemptSecond = !serviceAny.crashRecoveryAttempted; expect(shouldAttemptSecond).toBe(false); }); }); }); ================================================ FILE: tests/integration/core/mcp/mcp.test.ts ================================================ import { Client } from '@modelcontextprotocol/sdk/client'; import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp'; import { McpServerManager } from '@/core/mcp'; import { testMcpServer } from '@/core/mcp/McpTester'; import { MCP_CONFIG_PATH, McpStorage } from '@/core/storage/McpStorage'; import type { ClaudianMcpServer, McpHttpServerConfig, McpServerConfig, McpSSEServerConfig, McpStdioServerConfig, } from '@/core/types/mcp'; import { DEFAULT_MCP_SERVER, getMcpServerType, isValidMcpServerConfig, } from '@/core/types/mcp'; import { extractMcpMentions, parseCommand, splitCommandString, } from '@/utils/mcp'; jest.mock('@modelcontextprotocol/sdk/client', () => ({ Client: jest.fn(), })); jest.mock('@modelcontextprotocol/sdk/client/stdio', () => ({ StdioClientTransport: jest.fn(), })); jest.mock('@modelcontextprotocol/sdk/client/sse', () => ({ SSEClientTransport: jest.fn(), })); jest.mock('@modelcontextprotocol/sdk/client/streamableHttp', () => ({ StreamableHTTPClientTransport: jest.fn(), })); function createMemoryStorage(initialFile?: Record): { storage: McpStorage; files: Map; } { const files = new Map(); if (initialFile) { files.set(MCP_CONFIG_PATH, JSON.stringify(initialFile)); } const adapter = { exists: async (path: string) => files.has(path), read: async (path: string) => files.get(path) ?? '', write: async (path: string, content: string) => { files.set(path, content); }, }; return { storage: new McpStorage(adapter as any), files }; } // ============================================================================ // MCP Type Tests // ============================================================================ describe('MCP Types', () => { describe('getMcpServerType', () => { it('should return stdio for command-based config', () => { const config: McpStdioServerConfig = { command: 'npx' }; expect(getMcpServerType(config)).toBe('stdio'); }); it('should return stdio for config with explicit type', () => { const config: McpStdioServerConfig = { type: 'stdio', command: 'docker' }; expect(getMcpServerType(config)).toBe('stdio'); }); it('should return sse for SSE config', () => { const config: McpSSEServerConfig = { type: 'sse', url: 'http://localhost:3000/sse' }; expect(getMcpServerType(config)).toBe('sse'); }); it('should return http for HTTP config', () => { const config: McpHttpServerConfig = { type: 'http', url: 'http://localhost:3000/mcp' }; expect(getMcpServerType(config)).toBe('http'); }); it('should return http for URL without explicit type', () => { const config = { url: 'http://localhost:3000/mcp' } as McpServerConfig; expect(getMcpServerType(config)).toBe('http'); }); }); describe('isValidMcpServerConfig', () => { it('should return true for valid stdio config', () => { expect(isValidMcpServerConfig({ command: 'npx' })).toBe(true); expect(isValidMcpServerConfig({ command: 'docker', args: ['exec', '-i'] })).toBe(true); }); it('should return true for valid URL config', () => { expect(isValidMcpServerConfig({ url: 'http://localhost:3000' })).toBe(true); expect(isValidMcpServerConfig({ type: 'sse', url: 'http://localhost:3000/sse' })).toBe(true); expect(isValidMcpServerConfig({ type: 'http', url: 'http://localhost:3000/mcp' })).toBe(true); }); it('should return false for invalid configs', () => { expect(isValidMcpServerConfig(null)).toBe(false); expect(isValidMcpServerConfig(undefined)).toBe(false); expect(isValidMcpServerConfig({})).toBe(false); expect(isValidMcpServerConfig({ command: 123 })).toBe(false); expect(isValidMcpServerConfig({ url: 123 })).toBe(false); expect(isValidMcpServerConfig('string')).toBe(false); expect(isValidMcpServerConfig(123)).toBe(false); }); }); describe('DEFAULT_MCP_SERVER', () => { it('should have enabled true by default', () => { expect(DEFAULT_MCP_SERVER.enabled).toBe(true); }); it('should have contextSaving true by default', () => { expect(DEFAULT_MCP_SERVER.contextSaving).toBe(true); }); }); }); // ============================================================================ // McpStorage Clipboard Parsing Tests // ============================================================================ describe('McpStorage', () => { describe('parseClipboardConfig', () => { it('should parse full Claude Code format', () => { const json = JSON.stringify({ mcpServers: { 'my-server': { command: 'npx', args: ['server'] }, 'other-server': { type: 'sse', url: 'http://localhost:3000' }, }, }); const result = McpStorage.parseClipboardConfig(json); expect(result.needsName).toBe(false); expect(result.servers).toHaveLength(2); expect(result.servers[0].name).toBe('my-server'); expect(result.servers[0].config).toEqual({ command: 'npx', args: ['server'] }); expect(result.servers[1].name).toBe('other-server'); }); it('should parse single server with name', () => { const json = JSON.stringify({ 'my-server': { command: 'docker', args: ['exec', '-i', 'container'] }, }); const result = McpStorage.parseClipboardConfig(json); expect(result.needsName).toBe(false); expect(result.servers).toHaveLength(1); expect(result.servers[0].name).toBe('my-server'); }); it('should parse single config without name', () => { const json = JSON.stringify({ command: 'python', args: ['-m', 'server'], }); const result = McpStorage.parseClipboardConfig(json); expect(result.needsName).toBe(true); expect(result.servers).toHaveLength(1); expect(result.servers[0].name).toBe(''); expect(result.servers[0].config).toEqual({ command: 'python', args: ['-m', 'server'] }); }); it('should parse URL config without name', () => { const json = JSON.stringify({ type: 'sse', url: 'http://localhost:3000/sse', headers: { Authorization: 'Bearer token' }, }); const result = McpStorage.parseClipboardConfig(json); expect(result.needsName).toBe(true); expect(result.servers).toHaveLength(1); expect(result.servers[0].config).toEqual({ type: 'sse', url: 'http://localhost:3000/sse', headers: { Authorization: 'Bearer token' }, }); }); it('should parse multiple named servers without mcpServers wrapper', () => { const json = JSON.stringify({ server1: { command: 'npx' }, server2: { url: 'http://localhost:3000' }, }); const result = McpStorage.parseClipboardConfig(json); expect(result.needsName).toBe(false); expect(result.servers).toHaveLength(2); }); it('should throw for invalid JSON', () => { expect(() => McpStorage.parseClipboardConfig('not json')).toThrow('Invalid JSON'); }); it('should throw for non-object JSON', () => { expect(() => McpStorage.parseClipboardConfig('"string"')).toThrow('Invalid JSON object'); expect(() => McpStorage.parseClipboardConfig('123')).toThrow('Invalid JSON object'); expect(() => McpStorage.parseClipboardConfig('null')).toThrow('Invalid JSON object'); }); it('should throw for empty mcpServers', () => { const json = JSON.stringify({ mcpServers: {} }); expect(() => McpStorage.parseClipboardConfig(json)).toThrow('No valid server configs'); }); it('should throw for invalid config format', () => { const json = JSON.stringify({ invalidKey: 'invalidValue' }); expect(() => McpStorage.parseClipboardConfig(json)).toThrow('Invalid MCP configuration'); }); it('should skip invalid configs in mcpServers', () => { const json = JSON.stringify({ mcpServers: { valid: { command: 'npx' }, invalid: { notACommand: 'foo' }, }, }); const result = McpStorage.parseClipboardConfig(json); expect(result.servers).toHaveLength(1); expect(result.servers[0].name).toBe('valid'); }); }); describe('tryParseClipboardConfig', () => { it('should return parsed config for valid JSON', () => { const json = JSON.stringify({ command: 'npx' }); const result = McpStorage.tryParseClipboardConfig(json); expect(result).not.toBeNull(); expect(result!.needsName).toBe(true); }); it('should return null for non-JSON text', () => { expect(McpStorage.tryParseClipboardConfig('hello world')).toBeNull(); expect(McpStorage.tryParseClipboardConfig('not { json')).toBeNull(); }); it('should return null for text not starting with {', () => { expect(McpStorage.tryParseClipboardConfig('[]')).toBeNull(); expect(McpStorage.tryParseClipboardConfig(' []')).toBeNull(); }); it('should handle whitespace before JSON', () => { const json = ' { "command": "npx" }'; const result = McpStorage.tryParseClipboardConfig(json); expect(result).not.toBeNull(); }); it('should return null for invalid MCP config', () => { const json = JSON.stringify({ random: 'object' }); expect(McpStorage.tryParseClipboardConfig(json)).toBeNull(); }); }); describe('load/save', () => { it('should preserve unknown top-level keys and merge _claudian', async () => { const initial = { mcpServers: { legacy: { command: 'node' }, }, _claudian: { servers: { legacy: { enabled: false }, }, extra: { keep: true }, }, other: { keep: true }, }; const { storage, files } = createMemoryStorage(initial); const servers: ClaudianMcpServer[] = [ { name: 'new-server', config: { type: 'http', url: 'http://localhost:3000/mcp', headers: { Authorization: 'Bearer token' }, }, enabled: false, contextSaving: false, description: 'New server', }, ]; await storage.save(servers); const saved = JSON.parse(files.get(MCP_CONFIG_PATH) || '{}') as Record; expect(saved.other).toEqual({ keep: true }); expect(saved.mcpServers).toEqual({ 'new-server': { type: 'http', url: 'http://localhost:3000/mcp', headers: { Authorization: 'Bearer token' }, }, }); expect(saved._claudian).toEqual({ extra: { keep: true }, servers: { 'new-server': { enabled: false, contextSaving: false, description: 'New server', }, }, }); }); it('should keep existing _claudian fields when metadata is defaulted', async () => { const initial = { mcpServers: { legacy: { command: 'node' }, }, _claudian: { extra: { keep: true }, }, }; const { storage, files } = createMemoryStorage(initial); const servers: ClaudianMcpServer[] = [ { name: 'default-meta', config: { command: 'npx' }, enabled: DEFAULT_MCP_SERVER.enabled, contextSaving: DEFAULT_MCP_SERVER.contextSaving, }, ]; await storage.save(servers); const saved = JSON.parse(files.get(MCP_CONFIG_PATH) || '{}') as Record; expect(saved._claudian).toEqual({ extra: { keep: true } }); expect(saved.mcpServers).toEqual({ 'default-meta': { command: 'npx' } }); }); it('should load servers with metadata and defaults', async () => { const initial = { mcpServers: { stdio: { command: 'npx' }, remote: { type: 'sse', url: 'http://localhost:3000/sse' }, }, _claudian: { servers: { stdio: { enabled: false, contextSaving: false, description: 'Local tools' }, }, }, }; const { storage } = createMemoryStorage(initial); const servers = await storage.load(); expect(servers).toHaveLength(2); const stdio = servers.find((server) => server.name === 'stdio')!; const remote = servers.find((server) => server.name === 'remote')!; expect(stdio.enabled).toBe(false); expect(stdio.contextSaving).toBe(false); expect(stdio.description).toBe('Local tools'); expect(remote.enabled).toBe(true); expect(remote.contextSaving).toBe(true); }); it('should skip invalid server configs on load', async () => { const initial = { mcpServers: { valid: { command: 'npx' }, invalid: { foo: 'bar' }, }, _claudian: { servers: { invalid: { enabled: false }, }, }, }; const { storage } = createMemoryStorage(initial); const servers = await storage.load(); expect(servers).toHaveLength(1); expect(servers[0].name).toBe('valid'); expect(servers[0].enabled).toBe(true); expect(servers[0].contextSaving).toBe(true); }); it('should remove _claudian when only servers metadata exists', async () => { const initial = { mcpServers: { legacy: { command: 'node' }, }, _claudian: { servers: { legacy: { enabled: false }, }, }, }; const { storage, files } = createMemoryStorage(initial); const servers: ClaudianMcpServer[] = [ { name: 'legacy', config: { command: 'node' }, enabled: DEFAULT_MCP_SERVER.enabled, contextSaving: DEFAULT_MCP_SERVER.contextSaving, }, ]; await storage.save(servers); const saved = JSON.parse(files.get(MCP_CONFIG_PATH) || '{}') as Record; expect(saved._claudian).toBeUndefined(); }); }); }); // ============================================================================ // MCP Utils Tests // ============================================================================ describe('MCP Utils', () => { describe('extractMcpMentions', () => { it('should extract valid @mentions', () => { const validNames = new Set(['context7', 'code-exec', 'my_server']); const text = 'Use @context7 and @code-exec to help'; const result = extractMcpMentions(text, validNames); expect(result.size).toBe(2); expect(result.has('context7')).toBe(true); expect(result.has('code-exec')).toBe(true); }); it('should only extract valid names', () => { const validNames = new Set(['valid-server']); const text = 'Use @valid-server and @invalid-server'; const result = extractMcpMentions(text, validNames); expect(result.size).toBe(1); expect(result.has('valid-server')).toBe(true); expect(result.has('invalid-server')).toBe(false); }); it('should handle dots and underscores in names', () => { const validNames = new Set(['server.v2', 'my_server', 'test-server']); const text = '@server.v2 @my_server @test-server'; const result = extractMcpMentions(text, validNames); expect(result.size).toBe(3); }); it('should return empty set for no mentions', () => { const validNames = new Set(['server']); const text = 'No mentions here'; const result = extractMcpMentions(text, validNames); expect(result.size).toBe(0); }); it('should handle multiple same mentions', () => { const validNames = new Set(['server']); const text = '@server and @server again'; const result = extractMcpMentions(text, validNames); expect(result.size).toBe(1); }); it('should ignore @name/ filter mentions', () => { const validNames = new Set(['workspace']); const text = 'Use @workspace/ to filter files'; const result = extractMcpMentions(text, validNames); expect(result.size).toBe(0); }); it('should not match partial names from email addresses', () => { // The regex captures everything after @ until a non-valid char // So user@example.com captures 'example.com', not 'example' const validNames = new Set(['example']); const text = 'Contact user@example.com for help'; const result = extractMcpMentions(text, validNames); // 'example.com' is captured, but 'example' alone is not in the capture // So it won't match the validNames set expect(result.size).toBe(0); }); }); describe('splitCommandString', () => { it('should split simple command', () => { expect(splitCommandString('docker exec -i')).toEqual(['docker', 'exec', '-i']); }); it('should handle quoted arguments', () => { expect(splitCommandString('echo "hello world"')).toEqual(['echo', 'hello world']); expect(splitCommandString("echo 'hello world'")).toEqual(['echo', 'hello world']); }); it('should handle mixed quotes', () => { expect(splitCommandString('cmd "arg 1" \'arg 2\'')).toEqual(['cmd', 'arg 1', 'arg 2']); }); it('should handle empty string', () => { expect(splitCommandString('')).toEqual([]); }); it('should handle multiple spaces', () => { expect(splitCommandString('cmd arg1 arg2')).toEqual(['cmd', 'arg1', 'arg2']); }); it('should preserve quotes content with special chars', () => { expect(splitCommandString('echo "hello=world"')).toEqual(['echo', 'hello=world']); }); }); describe('parseCommand', () => { it('should parse command without args', () => { const result = parseCommand('docker'); expect(result.cmd).toBe('docker'); expect(result.args).toEqual([]); }); it('should parse command with inline args', () => { const result = parseCommand('docker exec -i container'); expect(result.cmd).toBe('docker'); expect(result.args).toEqual(['exec', '-i', 'container']); }); it('should use provided args if given', () => { const result = parseCommand('docker', ['run', '-it']); expect(result.cmd).toBe('docker'); expect(result.args).toEqual(['run', '-it']); }); it('should prefer provided args over inline', () => { const result = parseCommand('docker exec', ['run']); expect(result.cmd).toBe('docker exec'); expect(result.args).toEqual(['run']); }); it('should handle empty command', () => { const result = parseCommand(''); expect(result.cmd).toBe(''); expect(result.args).toEqual([]); }); }); }); // ============================================================================ // McpTester Tests // ============================================================================ describe('McpTester', () => { let mockClientInstance: { connect: jest.Mock; listTools: jest.Mock; close: jest.Mock; getServerVersion: jest.Mock; }; beforeEach(() => { jest.clearAllMocks(); mockClientInstance = { connect: jest.fn().mockResolvedValue(undefined), listTools: jest.fn().mockResolvedValue({ tools: [{ name: 'tool-a', description: 'Tool A', inputSchema: { type: 'object' } }], }), close: jest.fn().mockResolvedValue(undefined), getServerVersion: jest.fn().mockReturnValue({ name: 'test-srv', version: '1.0.0' }), }; (Client as jest.Mock).mockImplementation(() => mockClientInstance); }); it('should test stdio server and return tools', async () => { const server: ClaudianMcpServer = { name: 'local', config: { command: 'node', args: ['server'] }, enabled: true, contextSaving: false, }; const result = await testMcpServer(server); expect(result.success).toBe(true); expect(result.serverName).toBe('test-srv'); expect(result.serverVersion).toBe('1.0.0'); expect(result.tools).toEqual([{ name: 'tool-a', description: 'Tool A', inputSchema: { type: 'object' } }]); expect(StdioClientTransport).toHaveBeenCalledWith( expect.objectContaining({ command: 'node', args: ['server'] }), ); expect(mockClientInstance.connect).toHaveBeenCalledTimes(1); expect(mockClientInstance.listTools).toHaveBeenCalledTimes(1); }); it('should fail when stdio command is missing', async () => { const server: ClaudianMcpServer = { name: 'missing', config: { command: '' }, enabled: true, contextSaving: false, }; const result = await testMcpServer(server); expect(result.success).toBe(false); expect(result.error).toBe('Missing command'); expect(mockClientInstance.connect).not.toHaveBeenCalled(); }); it('should fail for invalid URL', async () => { const server: ClaudianMcpServer = { name: 'bad-url', config: { type: 'http', url: 'not-a-valid-url' }, enabled: true, contextSaving: false, }; const result = await testMcpServer(server); expect(result.success).toBe(false); expect(result.error).toBeDefined(); expect(mockClientInstance.connect).not.toHaveBeenCalled(); }); it('should test http server and return tools', async () => { const server: ClaudianMcpServer = { name: 'http', config: { type: 'http', url: 'http://localhost:3000/mcp', headers: { Authorization: 'token' } }, enabled: true, contextSaving: false, }; const result = await testMcpServer(server); expect(result.success).toBe(true); expect(result.serverName).toBe('test-srv'); expect(result.serverVersion).toBe('1.0.0'); expect(result.tools).toEqual([{ name: 'tool-a', description: 'Tool A', inputSchema: { type: 'object' } }]); expect(StreamableHTTPClientTransport).toHaveBeenCalledWith( expect.any(URL), expect.objectContaining({ fetch: expect.any(Function), requestInit: { headers: { Authorization: 'token' } }, }), ); }); it('should test sse server and return tools', async () => { const server: ClaudianMcpServer = { name: 'sse', config: { type: 'sse', url: 'http://localhost:3000/sse', headers: { Authorization: 'token' } }, enabled: true, contextSaving: false, }; const result = await testMcpServer(server); expect(result.success).toBe(true); expect(result.serverName).toBe('test-srv'); expect(result.serverVersion).toBe('1.0.0'); expect(result.tools).toEqual([{ name: 'tool-a', description: 'Tool A', inputSchema: { type: 'object' } }]); expect(SSEClientTransport).toHaveBeenCalledWith( expect.any(URL), expect.objectContaining({ fetch: expect.any(Function), requestInit: { headers: { Authorization: 'token' } }, }), ); }); it('should return failure when connect fails', async () => { mockClientInstance.connect.mockRejectedValue(new Error('Connection refused')); const server: ClaudianMcpServer = { name: 'fail', config: { type: 'http', url: 'http://localhost:3000/mcp' }, enabled: true, contextSaving: false, }; const result = await testMcpServer(server); expect(result.success).toBe(false); expect(result.error).toBe('Connection refused'); }); it('should return partial success when listTools fails', async () => { mockClientInstance.listTools.mockRejectedValue(new Error('tools/list not supported')); const server: ClaudianMcpServer = { name: 'partial', config: { type: 'http', url: 'http://localhost:3000/mcp' }, enabled: true, contextSaving: false, }; const result = await testMcpServer(server); expect(result.success).toBe(true); expect(result.serverName).toBe('test-srv'); expect(result.serverVersion).toBe('1.0.0'); expect(result.tools).toEqual([]); }); it('should return timeout error when connection times out', async () => { jest.useFakeTimers(); try { mockClientInstance.connect.mockImplementation( (_transport: unknown, options?: { signal?: AbortSignal }) => new Promise((_resolve, reject) => { options?.signal?.addEventListener('abort', () => reject(new Error('aborted'))); }), ); const server: ClaudianMcpServer = { name: 'timeout', config: { type: 'http', url: 'http://localhost:3000/mcp' }, enabled: true, contextSaving: false, }; const resultPromise = testMcpServer(server); jest.advanceTimersByTime(10000); const result = await resultPromise; expect(result.success).toBe(false); expect(result.error).toBe('Connection timeout (10s)'); } finally { jest.useRealTimers(); } }); }); // ============================================================================ // McpServerManager Tests (Unit tests without plugin dependency) // ============================================================================ describe('McpServerManager', () => { function createManager(servers: ClaudianMcpServer[]): McpServerManager { const manager = new McpServerManager({ load: jest.fn().mockResolvedValue(servers), }); // Directly set the manager's servers for testing (manager as any).servers = servers; return manager; } describe('getActiveServers', () => { const servers: ClaudianMcpServer[] = [ { name: 'always-on', config: { command: 'server1' }, enabled: true, contextSaving: false, }, { name: 'context-saving', config: { command: 'server2' }, enabled: true, contextSaving: true, }, { name: 'disabled', config: { command: 'server3' }, enabled: false, contextSaving: false, }, { name: 'disabled-context', config: { command: 'server4' }, enabled: false, contextSaving: true, }, ]; it('should include enabled servers without context-saving', () => { const manager = createManager(servers); const result = manager.getActiveServers(new Set()); expect(result['always-on']).toBeDefined(); expect(result['disabled']).toBeUndefined(); }); it('should exclude context-saving servers when not mentioned', () => { const manager = createManager(servers); const result = manager.getActiveServers(new Set()); expect(result['context-saving']).toBeUndefined(); }); it('should include context-saving servers when mentioned', () => { const manager = createManager(servers); const result = manager.getActiveServers(new Set(['context-saving'])); expect(result['context-saving']).toBeDefined(); expect(result['always-on']).toBeDefined(); }); it('should never include disabled servers even when mentioned', () => { const manager = createManager(servers); const result = manager.getActiveServers(new Set(['disabled', 'disabled-context'])); expect(result['disabled']).toBeUndefined(); expect(result['disabled-context']).toBeUndefined(); }); it('should return empty object for all disabled servers', () => { const disabledServers: ClaudianMcpServer[] = [ { name: 's1', config: { command: 'c1' }, enabled: false, contextSaving: false }, { name: 's2', config: { command: 'c2' }, enabled: false, contextSaving: true }, ]; const manager = createManager(disabledServers); const result = manager.getActiveServers(new Set(['s1', 's2'])); expect(Object.keys(result)).toHaveLength(0); }); }); describe('getContextSavingServers', () => { const servers: ClaudianMcpServer[] = [ { name: 's1', config: { command: 'c1' }, enabled: true, contextSaving: true }, { name: 's2', config: { command: 'c2' }, enabled: true, contextSaving: false }, { name: 's3', config: { command: 'c3' }, enabled: false, contextSaving: true }, { name: 's4', config: { command: 'c4' }, enabled: true, contextSaving: true }, ]; it('should return only enabled context-saving servers', () => { const manager = createManager(servers); const result = manager.getContextSavingServers(); expect(result).toHaveLength(2); expect(result.map((s) => s.name)).toEqual(['s1', 's4']); }); }); describe('extractMentions', () => { const servers: ClaudianMcpServer[] = [ { name: 'context7', config: { command: 'c1' }, enabled: true, contextSaving: true }, { name: 'always-on', config: { command: 'c2' }, enabled: true, contextSaving: false }, { name: 'disabled', config: { command: 'c3' }, enabled: false, contextSaving: true }, ]; it('should only extract enabled context-saving mentions', () => { const manager = createManager(servers); const result = manager.extractMentions('Use @context7 and @always-on and @disabled'); expect(result.size).toBe(1); expect(result.has('context7')).toBe(true); }); it('should return empty set when no valid mentions exist', () => { const manager = createManager(servers); const result = manager.extractMentions('No mentions here'); expect(result.size).toBe(0); }); }); describe('helper methods', () => { it('should report enabled counts and server presence', () => { const servers: ClaudianMcpServer[] = [ { name: 's1', config: { command: 'c1' }, enabled: true, contextSaving: true }, { name: 's2', config: { command: 'c2' }, enabled: true, contextSaving: false }, { name: 's3', config: { command: 'c3' }, enabled: false, contextSaving: true }, ]; const manager = createManager(servers); expect(manager.getEnabledCount()).toBe(2); expect(manager.hasServers()).toBe(true); }); it('should return false when no servers are configured', () => { const manager = createManager([]); expect(manager.getEnabledCount()).toBe(0); expect(manager.hasServers()).toBe(false); }); }); }); ================================================ FILE: tests/integration/features/chat/imagePersistence.test.ts ================================================ import type { ChatMessage, ImageAttachment } from '@/core/types'; import { ChatState } from '@/features/chat/state'; describe('ChatState persistence', () => { it('preserves image data when persisting messages', () => { const state = new ChatState(); const images: ImageAttachment[] = [ { id: 'img-1', name: 'test.png', mediaType: 'image/png', size: 10, data: 'YmFzZTY0', source: 'paste', }, ]; const messages: ChatMessage[] = [ { id: 'msg-1', role: 'user', content: 'hello', timestamp: Date.now(), images, }, ]; state.messages = messages; const persisted = state.getPersistedMessages(); // Image data is preserved (single source of truth) expect(persisted[0].images?.[0].data).toBe('YmFzZTY0'); expect(persisted[0].images?.[0].name).toBe('test.png'); expect(persisted[0].images?.[0].mediaType).toBe('image/png'); }); }); ================================================ FILE: tests/integration/main.test.ts ================================================ import * as os from 'os'; import { TOOL_SUBAGENT } from '@/core/tools'; import { DEFAULT_SETTINGS, VIEW_TYPE_CLAUDIAN } from '@/core/types'; import * as sdkSession from '@/utils/sdkSession'; // Mock fs for ClaudianService jest.mock('fs'); // Now import the plugin after mocking import ClaudianPlugin from '@/main'; describe('ClaudianPlugin', () => { let plugin: ClaudianPlugin; let mockApp: any; let mockManifest: any; beforeEach(() => { // Reset mocks jest.clearAllMocks(); mockApp = { vault: { adapter: { basePath: '/test/vault', exists: jest.fn().mockResolvedValue(false), read: jest.fn().mockResolvedValue(''), write: jest.fn().mockResolvedValue(undefined), remove: jest.fn().mockResolvedValue(undefined), mkdir: jest.fn().mockResolvedValue(undefined), list: jest.fn().mockResolvedValue({ files: [], folders: [] }), stat: jest.fn().mockResolvedValue(null), rename: jest.fn().mockResolvedValue(undefined), }, }, workspace: { getLeavesOfType: jest.fn().mockReturnValue([]), getRightLeaf: jest.fn().mockReturnValue({ setViewState: jest.fn().mockResolvedValue(undefined), }), getLeaf: jest.fn().mockReturnValue({ setViewState: jest.fn().mockResolvedValue(undefined), }), revealLeaf: jest.fn(), }, }; mockManifest = { id: 'claudian', name: 'Claudian', version: '0.1.0', }; // Create plugin instance with mocked app plugin = new ClaudianPlugin(mockApp, mockManifest); (plugin.loadData as jest.Mock).mockResolvedValue({}); }); describe('onload', () => { it('should initialize settings with defaults', async () => { await plugin.onload(); expect(plugin.settings).toBeDefined(); expect(plugin.settings.enableBlocklist).toBe(DEFAULT_SETTINGS.enableBlocklist); expect(plugin.settings.blockedCommands).toEqual(DEFAULT_SETTINGS.blockedCommands); }); // Note: With multi-tab, agentService is per-tab via TabManager, not on plugin it('should register the view', async () => { await plugin.onload(); expect((plugin.registerView as jest.Mock)).toHaveBeenCalledWith( VIEW_TYPE_CLAUDIAN, expect.any(Function) ); }); it('should add ribbon icon', async () => { await plugin.onload(); expect((plugin.addRibbonIcon as jest.Mock)).toHaveBeenCalledWith( 'bot', 'Open Claudian', expect.any(Function) ); }); it('should add command to open view', async () => { await plugin.onload(); expect((plugin.addCommand as jest.Mock)).toHaveBeenCalledWith({ id: 'open-view', name: 'Open chat view', callback: expect.any(Function), }); }); it('should migrate legacy cli path to hostname-based paths and clear old field', async () => { const legacyPath = '/legacy/claude'; mockApp.vault.adapter.exists.mockImplementation(async (path: string) => { // claudeCliPath is now in claudian-settings.json return path === '.claude/claudian-settings.json'; }); mockApp.vault.adapter.read.mockImplementation(async (path: string) => { if (path === '.claude/claudian-settings.json') { return JSON.stringify({ claudeCliPath: legacyPath }); } return ''; }); await plugin.onload(); const hostname = os.hostname(); // Should migrate to hostname-based path expect(plugin.settings.claudeCliPathsByHost[hostname]).toBe(legacyPath); // Should clear legacy field after migration expect(plugin.settings.claudeCliPath).toBe(''); // Should save settings with migrated path and cleared legacy field expect(mockApp.vault.adapter.write).toHaveBeenCalled(); const settingsWrite = (mockApp.vault.adapter.write as jest.Mock).mock.calls.find( ([path]) => path === '.claude/claudian-settings.json' ); expect(settingsWrite).toBeDefined(); const savedSettings = JSON.parse(settingsWrite[1]); expect(savedSettings.claudeCliPathsByHost[hostname]).toBe(legacyPath); expect(savedSettings.claudeCliPath).toBe(''); }); }); describe('onunload', () => { // Note: With multi-tab, cleanup is handled per-tab via ClaudianView.onClose() it('should complete without error', async () => { await plugin.onload(); expect(() => plugin.onunload()).not.toThrow(); }); }); describe('activateView', () => { it('should reveal existing leaf if view already exists', async () => { const mockLeaf = { id: 'existing-leaf' }; mockApp.workspace.getLeavesOfType.mockReturnValue([mockLeaf]); await plugin.onload(); await plugin.activateView(); expect(mockApp.workspace.revealLeaf).toHaveBeenCalledWith(mockLeaf); }); it('should create new leaf in right sidebar if view does not exist', async () => { const mockRightLeaf = { setViewState: jest.fn().mockResolvedValue(undefined), }; mockApp.workspace.getLeavesOfType.mockReturnValue([]); mockApp.workspace.getRightLeaf.mockReturnValue(mockRightLeaf); await plugin.onload(); await plugin.activateView(); expect(mockApp.workspace.getRightLeaf).toHaveBeenCalledWith(false); expect(mockRightLeaf.setViewState).toHaveBeenCalledWith({ type: VIEW_TYPE_CLAUDIAN, active: true, }); }); it('should handle null right leaf gracefully', async () => { mockApp.workspace.getLeavesOfType.mockReturnValue([]); mockApp.workspace.getRightLeaf.mockReturnValue(null); await plugin.onload(); // Should not throw await expect(plugin.activateView()).resolves.not.toThrow(); }); it('should create new leaf in main editor area when openInMainTab is enabled', async () => { const mockMainLeaf = { setViewState: jest.fn().mockResolvedValue(undefined), }; mockApp.workspace.getLeavesOfType.mockReturnValue([]); mockApp.workspace.getLeaf.mockReturnValue(mockMainLeaf); await plugin.onload(); plugin.settings.openInMainTab = true; await plugin.activateView(); expect(mockApp.workspace.getLeaf).toHaveBeenCalledWith('tab'); expect(mockApp.workspace.getRightLeaf).not.toHaveBeenCalled(); expect(mockMainLeaf.setViewState).toHaveBeenCalledWith({ type: VIEW_TYPE_CLAUDIAN, active: true, }); }); it('should handle null main leaf gracefully when openInMainTab is enabled', async () => { mockApp.workspace.getLeavesOfType.mockReturnValue([]); mockApp.workspace.getLeaf.mockReturnValue(null); await plugin.onload(); plugin.settings.openInMainTab = true; await expect(plugin.activateView()).resolves.not.toThrow(); }); }); describe('loadSettings', () => { it('should merge saved data with defaults', async () => { // Mock claudian-settings.json exists with custom values (Claudian-specific settings) mockApp.vault.adapter.exists.mockImplementation(async (path: string) => { return path === '.claude/claudian-settings.json'; }); mockApp.vault.adapter.read.mockImplementation(async (path: string) => { if (path === '.claude/claudian-settings.json') { return JSON.stringify({ enableBlocklist: false, }); } return ''; }); await plugin.loadSettings(); expect(plugin.settings.enableBlocklist).toBe(false); // Should still have defaults for blockedCommands expect(plugin.settings.blockedCommands).toEqual(DEFAULT_SETTINGS.blockedCommands); }); it('should normalize blockedCommands when stored value is partial', async () => { // Mock claudian-settings.json exists with partial blockedCommands mockApp.vault.adapter.exists.mockImplementation(async (path: string) => { return path === '.claude/claudian-settings.json'; }); mockApp.vault.adapter.read.mockImplementation(async (path: string) => { if (path === '.claude/claudian-settings.json') { return JSON.stringify({ blockedCommands: { unix: ['rm -rf', ' '] }, }); } return ''; }); await plugin.loadSettings(); expect(plugin.settings.blockedCommands.unix).toEqual(['rm -rf']); expect(plugin.settings.blockedCommands.windows).toEqual(DEFAULT_SETTINGS.blockedCommands.windows); }); it('should use defaults when no saved data', async () => { // No settings file exists mockApp.vault.adapter.exists.mockResolvedValue(false); (plugin.loadData as jest.Mock).mockResolvedValue(null); await plugin.loadSettings(); expect(plugin.settings).toEqual(DEFAULT_SETTINGS); }); it('should use defaults when loadData returns empty object', async () => { // No settings file exists mockApp.vault.adapter.exists.mockResolvedValue(false); (plugin.loadData as jest.Mock).mockResolvedValue({}); await plugin.loadSettings(); expect(plugin.settings).toEqual(DEFAULT_SETTINGS); }); it('should reconcile model from environment and persist when changed', async () => { // Mock claudian-settings.json with environment variables mockApp.vault.adapter.exists.mockImplementation(async (path: string) => { return path === '.claude/claudian-settings.json'; }); mockApp.vault.adapter.read.mockImplementation(async (path: string) => { if (path === '.claude/claudian-settings.json') { return JSON.stringify({ environmentVariables: 'ANTHROPIC_MODEL=custom-model', lastEnvHash: '', }); } return ''; }); const saveSpy = jest.spyOn(plugin, 'saveSettings'); await plugin.loadSettings(); expect(plugin.settings.model).toBe('custom-model'); expect(saveSpy).toHaveBeenCalled(); }); }); describe('saveSettings', () => { it('should save settings to file', async () => { await plugin.onload(); plugin.settings.enableBlocklist = false; await plugin.saveSettings(); // Claudian-specific settings should be written to .claude/claudian-settings.json expect(mockApp.vault.adapter.write).toHaveBeenCalledWith( '.claude/claudian-settings.json', expect.stringContaining('"enableBlocklist": false') ); // The written content should include state fields const writeCall = (mockApp.vault.adapter.write as jest.Mock).mock.calls.find( ([path]) => path === '.claude/claudian-settings.json' ); expect(writeCall).toBeDefined(); const content = JSON.parse(writeCall[1]); expect(content).not.toHaveProperty('activeConversationId'); expect(content).toHaveProperty('lastEnvHash'); expect(content).toHaveProperty('lastClaudeModel'); expect(content).toHaveProperty('lastCustomModel'); // Permissions are now in .claude/settings.json (CC format), not claudian-settings.json expect(content).not.toHaveProperty('permissions'); }); }); describe('applyEnvironmentVariables', () => { it('updates runtime env vars when changed', async () => { await plugin.onload(); (plugin as any).runtimeEnvironmentVariables = 'A=1'; await plugin.applyEnvironmentVariables('A=2'); expect((plugin as any).runtimeEnvironmentVariables).toBe('A=2'); await plugin.applyEnvironmentVariables('A=3'); expect((plugin as any).runtimeEnvironmentVariables).toBe('A=3'); // No change - should not update const currentEnv = (plugin as any).runtimeEnvironmentVariables; await plugin.applyEnvironmentVariables('A=3'); expect((plugin as any).runtimeEnvironmentVariables).toBe(currentEnv); }); it('invalidates sessions when env hash changes', async () => { await plugin.onload(); const conv = await plugin.createConversation('session-123'); const saveMetadataSpy = jest.spyOn(plugin.storage.sessions, 'saveMetadata'); saveMetadataSpy.mockClear(); await plugin.applyEnvironmentVariables('ANTHROPIC_MODEL=claude-sonnet-4-5'); const updated = await plugin.getConversationById(conv.id); expect(updated?.sessionId).toBeNull(); expect(saveMetadataSpy).toHaveBeenCalled(); }); it('broadcasts ensureReady with force when env changes without model change', async () => { await plugin.onload(); // Mock getView to return a view with tabManager const mockEnsureReady = jest.fn().mockResolvedValue(true); const mockBroadcast = jest.fn().mockImplementation(async (fn) => { await fn({ ensureReady: mockEnsureReady }); }); const mockTabManager = { broadcastToAllTabs: mockBroadcast, getAllTabs: jest.fn().mockReturnValue([]), }; const mockView = { getTabManager: jest.fn().mockReturnValue(mockTabManager), refreshModelSelector: jest.fn(), }; jest.spyOn(plugin, 'getView').mockReturnValue(mockView as any); // Change env but not in a way that affects model await plugin.applyEnvironmentVariables('SOME_VAR=value'); expect(mockBroadcast).toHaveBeenCalled(); expect(mockEnsureReady).toHaveBeenCalledWith({ force: true }); }); }); describe('ribbon icon callback', () => { it('reveals existing view when ribbon icon is clicked', async () => { await plugin.onload(); const mockLeaf = { id: 'existing' }; mockApp.workspace.getLeavesOfType.mockReturnValue([mockLeaf]); const ribbonCallback = (plugin.addRibbonIcon as jest.Mock).mock.calls[0][2]; await ribbonCallback(); expect(mockApp.workspace.revealLeaf).toHaveBeenCalledWith(mockLeaf); }); }); describe('command callback', () => { it('reveals existing view when command is executed', async () => { await plugin.onload(); const mockLeaf = { id: 'existing' }; mockApp.workspace.getLeavesOfType.mockReturnValue([mockLeaf]); const commandConfig = (plugin.addCommand as jest.Mock).mock.calls[0][0]; await commandConfig.callback(); expect(mockApp.workspace.revealLeaf).toHaveBeenCalledWith(mockLeaf); }); }); describe('createConversation', () => { it('should create a new conversation with unique ID', async () => { await plugin.onload(); const conv = await plugin.createConversation(); expect(conv.id).toMatch(/^conv-\d+-[a-z0-9]+$/); expect(conv.messages).toEqual([]); expect(conv.sessionId).toBeNull(); }); it('should allow retrieving created conversation by ID', async () => { await plugin.onload(); const conv = await plugin.createConversation(); const fetched = await plugin.getConversationById(conv.id); expect(fetched?.id).toBe(conv.id); }); it('should generate default title with timestamp', async () => { await plugin.onload(); const conv = await plugin.createConversation(); // Title should contain month and time expect(conv.title).toBeTruthy(); expect(conv.title.length).toBeGreaterThan(0); }); // Note: Session management is now per-tab via TabManager }); describe('switchConversation', () => { it('should switch to existing conversation', async () => { await plugin.onload(); const conv1 = await plugin.createConversation(); await plugin.createConversation(); // Create second conversation to switch from const result = await plugin.switchConversation(conv1.id); expect(result?.id).toBe(conv1.id); }); // Note: Session ID restoration is now handled per-tab via TabManager it('should return null for non-existent conversation', async () => { await plugin.onload(); const result = await plugin.switchConversation('non-existent-id'); expect(result).toBeNull(); }); }); describe('deleteConversation', () => { it('should delete conversation by ID', async () => { await plugin.onload(); const conv = await plugin.createConversation(); const convId = conv.id; // Create another so we have at least one left await plugin.createConversation(); await plugin.deleteConversation(convId); const list = plugin.getConversationList(); expect(list.find(c => c.id === convId)).toBeUndefined(); }); it('should allow deleting last conversation without recreating', async () => { await plugin.onload(); const conv = await plugin.createConversation(); await plugin.deleteConversation(conv.id); const list = plugin.getConversationList(); expect(list.find(c => c.id === conv.id)).toBeUndefined(); }); }); describe('renameConversation', () => { it('should rename conversation', async () => { await plugin.onload(); const conv = await plugin.createConversation(); await plugin.renameConversation(conv.id, 'New Title'); const updated = await plugin.getConversationById(conv.id); expect(updated?.title).toBe('New Title'); }); it('should use default title if empty string provided', async () => { await plugin.onload(); const conv = await plugin.createConversation(); await plugin.renameConversation(conv.id, ' '); const updated = await plugin.getConversationById(conv.id); expect(updated?.title).toBeTruthy(); }); }); describe('updateConversation', () => { it('should update conversation messages', async () => { await plugin.onload(); const conv = await plugin.createConversation(); const messages = [ { id: 'msg-1', role: 'user' as const, content: 'Hello', timestamp: Date.now() }, ]; await plugin.updateConversation(conv.id, { messages }); const updated = await plugin.getConversationById(conv.id); expect(updated?.messages).toEqual(messages); }); it('should update conversation sessionId', async () => { await plugin.onload(); const conv = await plugin.createConversation(); await plugin.updateConversation(conv.id, { sessionId: 'new-session-id' }); const updated = await plugin.getConversationById(conv.id); expect(updated?.sessionId).toBe('new-session-id'); }); it('should update updatedAt timestamp', async () => { await plugin.onload(); const conv = await plugin.createConversation(); const originalUpdatedAt = conv.updatedAt; // Small delay to ensure timestamp differs await new Promise(resolve => setTimeout(resolve, 10)); await plugin.updateConversation(conv.id, { title: 'Changed' }); const updated = await plugin.getConversationById(conv.id); expect(updated?.updatedAt).toBeGreaterThan(originalUpdatedAt); }); }); describe('getConversationList', () => { it('should return conversation metadata', async () => { await plugin.onload(); await plugin.createConversation(); const list = plugin.getConversationList(); expect(list.length).toBeGreaterThan(0); expect(list[0]).toHaveProperty('id'); expect(list[0]).toHaveProperty('title'); expect(list[0]).toHaveProperty('messageCount'); expect(list[0]).toHaveProperty('preview'); }); it('should return preview from first user message', async () => { await plugin.onload(); const conv = await plugin.createConversation(); await plugin.updateConversation(conv.id, { messages: [ { id: 'msg-1', role: 'user', content: 'Hello Claude', timestamp: Date.now() }, ], }); const list = plugin.getConversationList(); const meta = list.find(c => c.id === conv.id); expect(meta?.preview).toContain('Hello Claude'); }); }); describe('loadSettings with conversations', () => { it('should load saved conversations from JSONL files', async () => { const timestamp = Date.now(); const sessionJsonl = JSON.stringify({ type: 'meta', id: 'conv-saved-1', title: 'Saved Chat', createdAt: timestamp, updatedAt: timestamp, sessionId: 'saved-session', }); // Mock files exist mockApp.vault.adapter.exists.mockImplementation(async (path: string) => { // Session files if (path === '.claude/sessions' || path === '.claude/sessions/conv-saved-1.jsonl') { return true; } // claudian-settings.json exists if (path === '.claude/claudian-settings.json') { return true; } return false; }); mockApp.vault.adapter.list.mockImplementation(async (path: string) => { if (path === '.claude/sessions') { return { files: ['.claude/sessions/conv-saved-1.jsonl'], folders: [] }; } return { files: [], folders: [] }; }); mockApp.vault.adapter.read.mockImplementation(async (path: string) => { if (path === '.claude/sessions/conv-saved-1.jsonl') { return sessionJsonl; } if (path === '.claude/claudian-settings.json') { return JSON.stringify({}); } return ''; }); // data.json is minimal (no state - already migrated) (plugin.loadData as jest.Mock).mockResolvedValue({}); await plugin.loadSettings(); const loaded = await plugin.getConversationById('conv-saved-1'); expect(loaded?.id).toBe('conv-saved-1'); expect(loaded?.title).toBe('Saved Chat'); }); it('should clear session IDs when provider base URL changes', async () => { const timestamp = Date.now(); const sessionJsonl = JSON.stringify({ type: 'meta', id: 'conv-saved-1', title: 'Saved Chat', createdAt: timestamp, updatedAt: timestamp, sessionId: 'saved-session', }); mockApp.vault.adapter.exists.mockImplementation(async (path: string) => { return path === '.claude/claudian-settings.json' || path === '.claude/sessions' || path === '.claude/sessions/conv-saved-1.jsonl'; }); mockApp.vault.adapter.list.mockImplementation(async (path: string) => { if (path === '.claude/sessions') { return { files: ['.claude/sessions/conv-saved-1.jsonl'], folders: [] }; } return { files: [], folders: [] }; }); mockApp.vault.adapter.read.mockImplementation(async (path: string) => { if (path === '.claude/claudian-settings.json') { // All these fields are now in claudian-settings.json return JSON.stringify({ lastEnvHash: 'old-hash', environmentVariables: 'ANTHROPIC_BASE_URL=https://api.example.com', }); } if (path === '.claude/sessions/conv-saved-1.jsonl') { return sessionJsonl; } return ''; }); // data.json is minimal (already migrated) (plugin.loadData as jest.Mock).mockResolvedValue({}); await plugin.loadSettings(); const loaded = await plugin.getConversationById('conv-saved-1'); expect(loaded?.sessionId).toBeNull(); const sessionWrite = (mockApp.vault.adapter.write as jest.Mock).mock.calls.find( ([path]) => path === '.claude/sessions/conv-saved-1.jsonl' ); expect(sessionWrite).toBeDefined(); const metaLine = (sessionWrite?.[1] as string).split(/\r?\n/)[0]; const meta = JSON.parse(metaLine); expect(meta.sessionId).toBeNull(); }); it('should ignore legacy activeConversationId when no sessions exist', async () => { // No sessions exist mockApp.vault.adapter.exists.mockResolvedValue(false); mockApp.vault.adapter.list.mockResolvedValue({ files: [], folders: [] }); (plugin.loadData as jest.Mock).mockResolvedValue({ activeConversationId: 'non-existent', migrationVersion: 2, }); await plugin.loadSettings(); expect(plugin.getConversationList()).toHaveLength(0); }); }); describe('Multi-session message loading', () => { it('should load messages from previousSdkSessionIds when present', async () => { const timestamp = Date.now(); // Setup conversation with previousSdkSessionIds const sessionMeta = JSON.stringify({ type: 'meta', id: 'conv-multi-session', title: 'Multi Session Chat', createdAt: timestamp, updatedAt: timestamp, sdkSessionId: 'session-B', previousSdkSessionIds: ['session-A'], isNative: true, }); mockApp.vault.adapter.exists.mockImplementation(async (path: string) => { return path === '.claude/claudian-settings.json' || path === '.claude/sessions' || path === '.claude/sessions/conv-multi-session.meta.json'; }); mockApp.vault.adapter.list.mockImplementation(async (path: string) => { if (path === '.claude/sessions') { return { files: ['.claude/sessions/conv-multi-session.meta.json'], folders: [] }; } return { files: [], folders: [] }; }); mockApp.vault.adapter.read.mockImplementation(async (path: string) => { if (path === '.claude/sessions/conv-multi-session.meta.json') { return sessionMeta; } if (path === '.claude/claudian-settings.json') { return JSON.stringify({}); } return ''; }); (plugin.loadData as jest.Mock).mockResolvedValue({}); await plugin.loadSettings(); const loaded = await plugin.getConversationById('conv-multi-session'); expect(loaded?.previousSdkSessionIds).toEqual(['session-A']); expect(loaded?.sdkSessionId).toBe('session-B'); }); it('should preserve previousSdkSessionIds through conversation updates', async () => { await plugin.onload(); const conv = await plugin.createConversation(); await plugin.updateConversation(conv.id, { sdkSessionId: 'session-B', previousSdkSessionIds: ['session-A'], isNative: true, }); const updated = await plugin.getConversationById(conv.id); expect(updated?.previousSdkSessionIds).toEqual(['session-A']); expect(updated?.sdkSessionId).toBe('session-B'); // Further update should preserve previousSdkSessionIds await plugin.updateConversation(conv.id, { title: 'Updated Title', }); const afterTitleUpdate = await plugin.getConversationById(conv.id); expect(afterTitleUpdate?.previousSdkSessionIds).toEqual(['session-A']); }); it('should handle empty previousSdkSessionIds array', async () => { await plugin.onload(); const conv = await plugin.createConversation(); await plugin.updateConversation(conv.id, { sdkSessionId: 'session-A', previousSdkSessionIds: [], isNative: true, }); const updated = await plugin.getConversationById(conv.id); expect(updated?.previousSdkSessionIds).toEqual([]); }); }); describe('loadSdkMessagesForConversation - fork branch', () => { it('should load from forkSource.sessionId and truncate at forkSource.resumeAt for pending fork', async () => { await plugin.onload(); const conv = await plugin.createConversation(); await plugin.updateConversation(conv.id, { isNative: true, forkSource: { sessionId: 'source-session-abc', resumeAt: 'asst-uuid-cutoff' }, // No sessionId or sdkSessionId → isPendingFork returns true sessionId: null, sdkSessionId: undefined, // Reset sdkMessagesLoaded to simulate plugin restart sdkMessagesLoaded: false, }); const existsSpy = jest.spyOn(sdkSession, 'sdkSessionExists').mockReturnValue(true); const loadSpy = jest.spyOn(sdkSession, 'loadSDKSessionMessages').mockResolvedValue({ messages: [ { id: 'sdk-msg-1', role: 'user', content: 'Hello', timestamp: 1000 }, { id: 'sdk-msg-2', role: 'assistant', content: 'Hi', timestamp: 1001 }, ], skippedLines: 0, }); // Trigger loadSdkMessagesForConversation via public API const loaded = await plugin.getConversationById(conv.id); // Should check existence of source session, not the conversation's own session expect(existsSpy).toHaveBeenCalledWith( expect.any(String), 'source-session-abc' ); // Should load from forkSource.sessionId with forkSource.resumeAt as truncation point expect(loadSpy).toHaveBeenCalledWith( expect.any(String), 'source-session-abc', 'asst-uuid-cutoff' ); // Messages should be loaded expect(loaded?.sdkMessagesLoaded).toBe(true); existsSpy.mockRestore(); loadSpy.mockRestore(); }); it('should NOT use fork path when conversation has its own sdkSessionId', async () => { await plugin.onload(); const conv = await plugin.createConversation(); await plugin.updateConversation(conv.id, { isNative: true, forkSource: { sessionId: 'source-session', resumeAt: 'asst-uuid' }, sdkSessionId: 'own-session-id', sdkMessagesLoaded: false, }); const existsSpy = jest.spyOn(sdkSession, 'sdkSessionExists').mockReturnValue(true); const loadSpy = jest.spyOn(sdkSession, 'loadSDKSessionMessages').mockResolvedValue({ messages: [], skippedLines: 0, }); await plugin.getConversationById(conv.id); // Should load from own session, not forkSource session expect(existsSpy).toHaveBeenCalledWith( expect.any(String), 'own-session-id' ); existsSpy.mockRestore(); loadSpy.mockRestore(); }); }); describe('loadSdkMessagesForConversation - subagent recovery', () => { it('restores subagent data when Task tool exists but subagent content block is missing', async () => { await plugin.onload(); const conv = await plugin.createConversation(); await plugin.updateConversation(conv.id, { isNative: true, sdkSessionId: 'session-subagent-recovery', sdkMessagesLoaded: false, messages: [ { id: 'assistant-1', role: 'assistant', content: '', timestamp: 1000, toolCalls: [ { id: 'task-1', name: 'Task', input: { description: 'Do sub task' }, status: 'completed', result: 'Task completed', } as any, ], // Simulate partial persisted blocks that lost the task tool block. contentBlocks: [{ type: 'text', content: 'Done' }] as any, } as any, ], subagentData: { 'task-1': { id: 'task-1', description: 'Recovered subagent', status: 'completed', result: 'Recovered result', toolCalls: [ { id: 'sub-tool-1', name: 'Read', input: { file_path: 'README.md' }, status: 'completed', result: 'content', } as any, ], isExpanded: false, } as any, }, }); const existsSpy = jest.spyOn(sdkSession, 'sdkSessionExists').mockReturnValue(true); const loadSpy = jest.spyOn(sdkSession, 'loadSDKSessionMessages').mockResolvedValue({ messages: [], skippedLines: 0, }); const loaded = await plugin.getConversationById(conv.id); expect(loadSpy).toHaveBeenCalledWith( expect.any(String), 'session-subagent-recovery', undefined ); expect(loaded?.messages[0].toolCalls?.find(tc => tc.id === 'task-1')).toEqual( expect.objectContaining({ subagent: expect.objectContaining({ id: 'task-1', description: 'Recovered subagent', result: 'Recovered result', }), }) ); expect(loaded?.messages[0].contentBlocks).toEqual( expect.arrayContaining([ expect.objectContaining({ type: 'subagent', subagentId: 'task-1' }), ]) ); existsSpy.mockRestore(); loadSpy.mockRestore(); }); it('prefers richer SDK task result over stale cached subagent result', async () => { await plugin.onload(); const conv = await plugin.createConversation(); await plugin.updateConversation(conv.id, { isNative: true, sdkSessionId: 'session-subagent-merge', sdkMessagesLoaded: false, messages: [ { id: 'assistant-merge', role: 'assistant', content: '', timestamp: 1000, toolCalls: [ { id: 'task-merge-1', name: 'Task', input: { description: 'Do sub task', run_in_background: true }, status: 'completed', result: 'Full SDK result from queue-operation', } as any, ], contentBlocks: [{ type: 'subagent', subagentId: 'task-merge-1', mode: 'async' }] as any, } as any, ], subagentData: { 'task-merge-1': { id: 'task-merge-1', description: 'Recovered subagent', mode: 'async', asyncStatus: 'completed', status: 'completed', result: 'Short stale result', toolCalls: [], isExpanded: false, } as any, }, }); const existsSpy = jest.spyOn(sdkSession, 'sdkSessionExists').mockReturnValue(true); const loadSpy = jest.spyOn(sdkSession, 'loadSDKSessionMessages').mockResolvedValue({ messages: [], skippedLines: 0, }); const loaded = await plugin.getConversationById(conv.id); const taskTool = loaded?.messages[0].toolCalls?.find(tc => tc.id === 'task-merge-1'); expect(loadSpy).toHaveBeenCalledWith( expect.any(String), 'session-subagent-merge', undefined ); expect(taskTool?.result).toBe('Full SDK result from queue-operation'); expect(taskTool?.subagent?.result).toBe('Full SDK result from queue-operation'); existsSpy.mockRestore(); loadSpy.mockRestore(); }); it('keeps the richer cached async result when both SDK and cache are terminal', async () => { await plugin.onload(); const conv = await plugin.createConversation(); await plugin.updateConversation(conv.id, { isNative: true, sdkSessionId: 'session-subagent-cache-richer', sdkMessagesLoaded: false, messages: [], subagentData: { 'task-merge-2': { id: 'task-merge-2', description: 'Recovered subagent', mode: 'async', asyncStatus: 'completed', status: 'completed', result: 'Recovered final result with full details', toolCalls: [], isExpanded: false, agentId: 'agent-cache-richer', } as any, }, }); const existsSpy = jest.spyOn(sdkSession, 'sdkSessionExists').mockReturnValue(true); const loadSpy = jest.spyOn(sdkSession, 'loadSDKSessionMessages').mockResolvedValue({ messages: [ { id: 'assistant-cache-richer', role: 'assistant', content: '', timestamp: 1000, toolCalls: [ { id: 'task-merge-2', name: 'Task', input: { description: 'SDK async subagent', run_in_background: true }, status: 'completed', result: 'Short SDK result', subagent: { id: 'task-merge-2', description: 'SDK async subagent', mode: 'async', asyncStatus: 'completed', status: 'completed', result: 'Short SDK result', toolCalls: [], isExpanded: false, agentId: 'agent-cache-richer', }, } as any, ], contentBlocks: [{ type: 'subagent', subagentId: 'task-merge-2', mode: 'async' }] as any, } as any, ], skippedLines: 0, }); const loaded = await plugin.getConversationById(conv.id); const taskTool = loaded?.messages[0].toolCalls?.find(tc => tc.id === 'task-merge-2'); expect(taskTool?.status).toBe('completed'); expect(taskTool?.result).toBe('Recovered final result with full details'); expect(taskTool?.subagent?.result).toBe('Recovered final result with full details'); existsSpy.mockRestore(); loadSpy.mockRestore(); }); it('drops stale asyncStatus from cached sync subagents during recovery', async () => { await plugin.onload(); const conv = await plugin.createConversation(); await plugin.updateConversation(conv.id, { isNative: true, sdkSessionId: 'session-sync-subagent-cleanup', sdkMessagesLoaded: false, messages: [ { id: 'assistant-sync', role: 'assistant', content: '', timestamp: 1000, toolCalls: [ { id: 'task-sync-1', name: 'Task', input: { description: 'Do sync task' }, status: 'completed', result: 'Sync result', } as any, ], contentBlocks: [{ type: 'subagent', subagentId: 'task-sync-1', mode: 'sync' }] as any, } as any, ], subagentData: { 'task-sync-1': { id: 'task-sync-1', description: 'Recovered sync subagent', mode: 'sync', asyncStatus: 'completed', status: 'completed', result: 'Recovered sync result', toolCalls: [], isExpanded: false, } as any, }, }); const existsSpy = jest.spyOn(sdkSession, 'sdkSessionExists').mockReturnValue(true); const loadSpy = jest.spyOn(sdkSession, 'loadSDKSessionMessages').mockResolvedValue({ messages: [], skippedLines: 0, }); const loaded = await plugin.getConversationById(conv.id); const taskTool = loaded?.messages[0].toolCalls?.find(tc => tc.id === 'task-sync-1'); expect(taskTool?.subagent?.mode).toBe('sync'); expect(taskTool?.subagent?.asyncStatus).toBeUndefined(); existsSpy.mockRestore(); loadSpy.mockRestore(); }); it('prefers terminal SDK async status over stale cached running state', async () => { await plugin.onload(); const conv = await plugin.createConversation(); await plugin.updateConversation(conv.id, { isNative: true, sdkSessionId: 'session-async-sdk-terminal', sdkMessagesLoaded: false, messages: [], subagentData: { 'task-async-sdk-terminal': { id: 'task-async-sdk-terminal', description: 'Cached async subagent', mode: 'async', asyncStatus: 'running', status: 'running', result: 'Still running', toolCalls: [], isExpanded: false, } as any, }, }); const existsSpy = jest.spyOn(sdkSession, 'sdkSessionExists').mockReturnValue(true); const loadSpy = jest.spyOn(sdkSession, 'loadSDKSessionMessages').mockResolvedValue({ messages: [ { id: 'assistant-sdk-terminal', role: 'assistant', content: '', timestamp: 1000, toolCalls: [ { id: 'task-async-sdk-terminal', name: 'Task', input: { description: 'SDK async subagent', run_in_background: true }, status: 'completed', result: 'Full SDK final result', subagent: { id: 'task-async-sdk-terminal', description: 'SDK async subagent', mode: 'async', asyncStatus: 'completed', status: 'completed', result: 'Full SDK final result', toolCalls: [], isExpanded: false, agentId: 'agent-sdk-terminal', }, } as any, ], contentBlocks: [{ type: 'subagent', subagentId: 'task-async-sdk-terminal', mode: 'async' }] as any, } as any, ], skippedLines: 0, }); const loaded = await plugin.getConversationById(conv.id); const taskTool = loaded?.messages[0].toolCalls?.find(tc => tc.id === 'task-async-sdk-terminal'); expect(taskTool?.status).toBe('completed'); expect(taskTool?.result).toBe('Full SDK final result'); expect(taskTool?.subagent?.status).toBe('completed'); expect(taskTool?.subagent?.asyncStatus).toBe('completed'); expect(taskTool?.subagent?.result).toBe('Full SDK final result'); existsSpy.mockRestore(); loadSpy.mockRestore(); }); it('prefers cached terminal async status over SDK launch-only running state', async () => { await plugin.onload(); const conv = await plugin.createConversation(); await plugin.updateConversation(conv.id, { isNative: true, sdkSessionId: 'session-async-cache-terminal', sdkMessagesLoaded: false, messages: [], subagentData: { 'task-async-cache-terminal': { id: 'task-async-cache-terminal', description: 'Cached async subagent', mode: 'async', asyncStatus: 'completed', status: 'completed', result: 'Recovered final result', toolCalls: [], isExpanded: false, agentId: 'agent-cache-terminal', } as any, }, }); const existsSpy = jest.spyOn(sdkSession, 'sdkSessionExists').mockReturnValue(true); const loadSpy = jest.spyOn(sdkSession, 'loadSDKSessionMessages').mockResolvedValue({ messages: [ { id: 'assistant-sdk-running', role: 'assistant', content: '', timestamp: 1000, toolCalls: [ { id: 'task-async-cache-terminal', name: 'Task', input: { description: 'SDK async subagent', run_in_background: true }, status: 'running', result: 'Task launched in background.', subagent: { id: 'task-async-cache-terminal', description: 'SDK async subagent', mode: 'async', asyncStatus: 'running', status: 'running', result: 'Task launched in background.', toolCalls: [], isExpanded: false, agentId: 'agent-cache-terminal', }, } as any, ], contentBlocks: [{ type: 'subagent', subagentId: 'task-async-cache-terminal', mode: 'async' }] as any, } as any, ], skippedLines: 0, }); const loaded = await plugin.getConversationById(conv.id); const taskTool = loaded?.messages[0].toolCalls?.find(tc => tc.id === 'task-async-cache-terminal'); expect(taskTool?.status).toBe('completed'); expect(taskTool?.result).toBe('Recovered final result'); expect(taskTool?.subagent?.status).toBe('completed'); expect(taskTool?.subagent?.asyncStatus).toBe('completed'); expect(taskTool?.subagent?.result).toBe('Recovered final result'); existsSpy.mockRestore(); loadSpy.mockRestore(); }); it('restores async subagent data and mode when Task tool exists but async block is missing', async () => { await plugin.onload(); const conv = await plugin.createConversation(); await plugin.updateConversation(conv.id, { isNative: true, sdkSessionId: 'session-async-subagent-recovery', sdkMessagesLoaded: false, messages: [ { id: 'assistant-1', role: 'assistant', content: '', timestamp: 1000, toolCalls: [ { id: 'task-async-1', name: 'Task', input: { description: 'Do background task', run_in_background: true }, status: 'completed', result: 'Task started', } as any, ], contentBlocks: [{ type: 'text', content: 'Started' }] as any, } as any, ], subagentData: { 'task-async-1': { id: 'task-async-1', description: 'Recovered async subagent', mode: 'async', asyncStatus: 'completed', status: 'completed', result: 'Recovered async result', toolCalls: [], isExpanded: false, } as any, }, }); const existsSpy = jest.spyOn(sdkSession, 'sdkSessionExists').mockReturnValue(true); const loadSpy = jest.spyOn(sdkSession, 'loadSDKSessionMessages').mockResolvedValue({ messages: [], skippedLines: 0, }); const loaded = await plugin.getConversationById(conv.id); const block = loaded?.messages[0].contentBlocks?.find( (b: any) => b.type === 'subagent' && b.subagentId === 'task-async-1' ) as any; expect(loaded?.messages[0].toolCalls?.find(tc => tc.id === 'task-async-1')).toEqual( expect.objectContaining({ id: 'task-async-1', subagent: expect.objectContaining({ id: 'task-async-1', mode: 'async', asyncStatus: 'completed', }), }) ); expect(block).toEqual( expect.objectContaining({ type: 'subagent', subagentId: 'task-async-1', mode: 'async' }) ); existsSpy.mockRestore(); loadSpy.mockRestore(); }); it('hydrates async subagent tool calls from SDK subagent files on reload', async () => { await plugin.onload(); const conv = await plugin.createConversation(); await plugin.updateConversation(conv.id, { isNative: true, sdkSessionId: 'session-async-subagent-tools', sdkMessagesLoaded: false, messages: [ { id: 'assistant-1', role: 'assistant', content: '', timestamp: 1000, toolCalls: [ { id: 'task-async-tools', name: 'Task', input: { description: 'Do background task', run_in_background: true }, status: 'completed', result: 'Task started', } as any, ], contentBlocks: [{ type: 'subagent', subagentId: 'task-async-tools', mode: 'async' }] as any, } as any, ], subagentData: { 'task-async-tools': { id: 'task-async-tools', description: 'Recovered async subagent', mode: 'async', asyncStatus: 'completed', status: 'completed', result: 'Recovered async result', agentId: 'agent-a123', toolCalls: [], isExpanded: false, } as any, }, }); const existsSpy = jest.spyOn(sdkSession, 'sdkSessionExists').mockReturnValue(true); const loadSpy = jest.spyOn(sdkSession, 'loadSDKSessionMessages').mockResolvedValue({ messages: [], skippedLines: 0, }); const loadSubagentToolsSpy = jest.spyOn(sdkSession, 'loadSubagentToolCalls').mockResolvedValue([ { id: 'sub-tool-1', name: 'Bash', input: { command: 'ls' }, status: 'completed', result: 'ok', isExpanded: false, } as any, ]); const loaded = await plugin.getConversationById(conv.id); const taskTool = loaded?.messages[0].toolCalls?.find(tc => tc.id === 'task-async-tools'); expect(loadSubagentToolsSpy).toHaveBeenCalledWith( expect.any(String), 'session-async-subagent-tools', 'agent-a123' ); expect(taskTool?.subagent?.toolCalls).toEqual( expect.arrayContaining([ expect.objectContaining({ id: 'sub-tool-1', name: 'Bash', result: 'ok', }), ]) ); existsSpy.mockRestore(); loadSpy.mockRestore(); loadSubagentToolsSpy.mockRestore(); }); it('keeps async subagent renderer visible when task block and task tool call are both missing', async () => { await plugin.onload(); const conv = await plugin.createConversation(); await plugin.updateConversation(conv.id, { isNative: true, sdkSessionId: 'session-async-subagent-fallback', sdkMessagesLoaded: false, messages: [ { id: 'assistant-1', role: 'assistant', content: 'Background work started', timestamp: 1000, contentBlocks: [{ type: 'text', content: 'Background work started' }] as any, } as any, ], subagentData: { 'task-async-orphan': { id: 'task-async-orphan', description: 'Recovered async orphan subagent', mode: 'async', asyncStatus: 'running', status: 'running', result: 'Running in background', toolCalls: [], isExpanded: false, } as any, }, }); const existsSpy = jest.spyOn(sdkSession, 'sdkSessionExists').mockReturnValue(true); const loadSpy = jest.spyOn(sdkSession, 'loadSDKSessionMessages').mockResolvedValue({ messages: [], skippedLines: 0, }); const loaded = await plugin.getConversationById(conv.id); const assistant = loaded?.messages.find(m => m.id === 'assistant-1'); const block = assistant?.contentBlocks?.find( (b: any) => b.type === 'subagent' && b.subagentId === 'task-async-orphan' ) as any; expect(assistant?.toolCalls?.find((tc: any) => tc.id === 'task-async-orphan')).toEqual( expect.objectContaining({ id: 'task-async-orphan', name: TOOL_SUBAGENT, subagent: expect.objectContaining({ id: 'task-async-orphan', mode: 'async', asyncStatus: 'running', }), }) ); expect(block).toEqual( expect.objectContaining({ type: 'subagent', subagentId: 'task-async-orphan', mode: 'async', }) ); existsSpy.mockRestore(); loadSpy.mockRestore(); }); }); }); ================================================ FILE: tests/tsconfig.json ================================================ { "extends": "../tsconfig.jest.json", "compilerOptions": { "noEmit": true }, "include": ["**/*.ts"] } ================================================ FILE: tests/unit/core/agent/ClaudianService.test.ts ================================================ import * as sdkModule from '@anthropic-ai/claude-agent-sdk'; import { Notice } from 'obsidian'; import { ClaudianService } from '@/core/agent/ClaudianService'; import { MessageChannel } from '@/core/agent/MessageChannel'; import { createResponseHandler } from '@/core/agent/types'; import type { McpServerManager } from '@/core/mcp'; import type ClaudianPlugin from '@/main'; import * as envUtils from '@/utils/env'; import * as sessionUtils from '@/utils/session'; const sdkMock = sdkModule as unknown as { setMockMessages: (messages: any[], options?: { appendResult?: boolean }) => void; resetMockMessages: () => void; simulateCrash: (afterChunks?: number) => void; query: typeof sdkModule.query; }; type MockMcpServerManager = jest.Mocked; describe('ClaudianService', () => { let mockPlugin: Partial; let mockMcpManager: MockMcpServerManager; let service: ClaudianService; async function collectChunks(gen: AsyncGenerator): Promise { const chunks: any[] = []; for await (const chunk of gen) { chunks.push(chunk); } return chunks; } beforeEach(() => { jest.clearAllMocks(); const storageMock = { addDenyRule: jest.fn().mockResolvedValue(undefined), addAllowRule: jest.fn().mockResolvedValue(undefined), getPermissions: jest.fn().mockResolvedValue({ allow: [], deny: [], ask: [] }), }; mockPlugin = { app: { vault: { adapter: { basePath: '/mock/vault/path' } }, }, storage: storageMock, settings: { model: 'claude-3-5-sonnet', permissionMode: 'ask' as const, thinkingBudget: 0, blockedCommands: [], enableBlocklist: false, allowExternalAccess: false, mediaFolder: 'claudian-media', systemPrompt: '', allowedExportPaths: [], loadUserClaudeSettings: false, claudeCliPath: '/usr/local/bin/claude', claudeCliPaths: [], enableAutoTitleGeneration: true, titleGenerationModel: 'claude-3-5-haiku', }, getResolvedClaudeCliPath: jest.fn().mockReturnValue('/usr/local/bin/claude'), getActiveEnvironmentVariables: jest.fn().mockReturnValue(''), pluginManager: { getPluginsKey: jest.fn().mockReturnValue(''), }, } as unknown as ClaudianPlugin; mockMcpManager = { loadServers: jest.fn().mockResolvedValue(undefined), getAllDisallowedMcpTools: jest.fn().mockReturnValue([]), getActiveServers: jest.fn().mockReturnValue({}), getDisallowedMcpTools: jest.fn().mockReturnValue([]), } as unknown as MockMcpServerManager; service = new ClaudianService(mockPlugin as ClaudianPlugin, mockMcpManager); }); describe('Session Management', () => { it('should have null session ID initially', () => { expect(service.getSessionId()).toBeNull(); }); it('should set session ID', () => { service.setSessionId('test-session-123'); expect(service.getSessionId()).toBe('test-session-123'); }); it('should reset session', () => { service.setSessionId('test-session-123'); service.resetSession(); expect(service.getSessionId()).toBeNull(); }); it('should not close persistent query when setting same session ID', () => { service.setSessionId('test-session-123'); const activeStateBefore = service.isPersistentQueryActive(); service.setSessionId('test-session-123'); expect(service.getSessionId()).toBe('test-session-123'); expect(service.isPersistentQueryActive()).toBe(activeStateBefore); }); it('should update session ID when switching to different session', () => { service.setSessionId('test-session-123'); service.setSessionId('different-session-456'); expect(service.getSessionId()).toBe('different-session-456'); }); it('should handle setting null session ID', () => { service.setSessionId('test-session-123'); service.setSessionId(null); expect(service.getSessionId()).toBeNull(); }); it('should pass externalContextPaths to ensureReady when setting session ID', async () => { const ensureReadySpy = jest.spyOn(service, 'ensureReady').mockResolvedValue(true); service.setSessionId('test-session', ['/path/a', '/path/b']); // ensureReady is called asynchronously, give it a tick await Promise.resolve(); expect(ensureReadySpy).toHaveBeenCalledWith({ sessionId: 'test-session', externalContextPaths: ['/path/a', '/path/b'], }); }); it('should pass undefined externalContextPaths when not provided', async () => { const ensureReadySpy = jest.spyOn(service, 'ensureReady').mockResolvedValue(true); service.setSessionId('test-session'); await Promise.resolve(); expect(ensureReadySpy).toHaveBeenCalledWith({ sessionId: 'test-session', externalContextPaths: undefined, }); }); }); describe('MCP Server Management', () => { it('should reload MCP servers', async () => { await service.reloadMcpServers(); expect(mockMcpManager.loadServers).toHaveBeenCalled(); }); }); describe('Persistent Query Management', () => { it('should not be active initially', () => { expect(service.isPersistentQueryActive()).toBe(false); }); it('should close persistent query', () => { service.setSessionId('test-session'); service.closePersistentQuery('test reason'); expect(service.isPersistentQueryActive()).toBe(false); }); it('should restart persistent query via ensureReady with force', async () => { service.setSessionId('test-session'); const startPersistentQuerySpy = jest.spyOn(service as any, 'startPersistentQuery'); startPersistentQuerySpy.mockResolvedValue(undefined); const result = await service.ensureReady({ force: true }); expect(result).toBe(true); expect(startPersistentQuerySpy).toHaveBeenCalled(); }); it('should return false (no-op) when config unchanged and query running', async () => { const startPersistentQuerySpy = jest.spyOn(service as any, 'startPersistentQuery'); // Mock startPersistentQuery to simulate real side effects (subprocess boundary) startPersistentQuerySpy.mockImplementation(async (vaultPath: string, cliPath: string, _sessionId?: string, externalContextPaths?: string[]) => { (service as any).persistentQuery = { interrupt: jest.fn().mockResolvedValue(undefined) }; (service as any).currentConfig = (service as any).buildPersistentQueryConfig(vaultPath, cliPath, externalContextPaths); }); // First call starts the query const result1 = await service.ensureReady(); expect(result1).toBe(true); expect(startPersistentQuerySpy).toHaveBeenCalledTimes(1); // Second call with same config should no-op const result2 = await service.ensureReady(); expect(result2).toBe(false); expect(startPersistentQuerySpy).toHaveBeenCalledTimes(1); // Still 1, not called again }); it('should restart when config changed (external context paths)', async () => { const startPersistentQuerySpy = jest.spyOn(service as any, 'startPersistentQuery'); // Mock startPersistentQuery to simulate real side effects (subprocess boundary) startPersistentQuerySpy.mockImplementation(async (vaultPath: string, cliPath: string, _sessionId?: string, externalContextPaths?: string[]) => { (service as any).persistentQuery = { interrupt: jest.fn().mockResolvedValue(undefined) }; (service as any).currentConfig = (service as any).buildPersistentQueryConfig(vaultPath, cliPath, externalContextPaths); }); // First call starts with no external paths (Case 1: not running) await service.ensureReady({ externalContextPaths: [] }); expect(startPersistentQuerySpy).toHaveBeenCalledTimes(1); // Second call with different paths triggers restart via real needsRestart const result = await service.ensureReady({ externalContextPaths: ['/new/path'] }); expect(result).toBe(true); expect(startPersistentQuerySpy).toHaveBeenCalledTimes(2); }); it('should pass preserveHandlers: true to closePersistentQuery on force restart', async () => { const startPersistentQuerySpy = jest.spyOn(service as any, 'startPersistentQuery'); const closePersistentQuerySpy = jest.spyOn(service, 'closePersistentQuery'); startPersistentQuerySpy.mockImplementation(async () => { (service as any).persistentQuery = { interrupt: jest.fn().mockResolvedValue(undefined) }; }); // Start the query first await service.ensureReady(); expect(startPersistentQuerySpy).toHaveBeenCalledTimes(1); // Force restart with preserveHandlers: true (crash recovery scenario) await service.ensureReady({ force: true, preserveHandlers: true }); expect(closePersistentQuerySpy).toHaveBeenCalledWith('forced restart', { preserveHandlers: true }); expect(startPersistentQuerySpy).toHaveBeenCalledTimes(2); }); it('should pass preserveHandlers through config change restart', async () => { const startPersistentQuerySpy = jest.spyOn(service as any, 'startPersistentQuery'); const closePersistentQuerySpy = jest.spyOn(service, 'closePersistentQuery'); // Mock startPersistentQuery to simulate real side effects (subprocess boundary) startPersistentQuerySpy.mockImplementation(async (vaultPath: string, cliPath: string, _sessionId?: string, externalContextPaths?: string[]) => { (service as any).persistentQuery = { interrupt: jest.fn().mockResolvedValue(undefined) }; (service as any).currentConfig = (service as any).buildPersistentQueryConfig(vaultPath, cliPath, externalContextPaths); }); // Start the query first await service.ensureReady({ externalContextPaths: [] }); // Config change with preserveHandlers: true await service.ensureReady({ externalContextPaths: ['/new/path'], preserveHandlers: true }); expect(closePersistentQuerySpy).toHaveBeenCalledWith('config changed', { preserveHandlers: true }); }); it('should return false when CLI unavailable after force close', async () => { const startPersistentQuerySpy = jest.spyOn(service as any, 'startPersistentQuery'); const closePersistentQuerySpy = jest.spyOn(service, 'closePersistentQuery'); startPersistentQuerySpy.mockImplementation(async () => { (service as any).persistentQuery = { interrupt: jest.fn().mockResolvedValue(undefined) }; }); // Start the query first await service.ensureReady(); expect(startPersistentQuerySpy).toHaveBeenCalledTimes(1); // Now make CLI unavailable (mockPlugin.getResolvedClaudeCliPath as jest.Mock).mockReturnValue(null); // Force restart should close but fail to start new one const result = await service.ensureReady({ force: true }); expect(result).toBe(false); expect(closePersistentQuerySpy).toHaveBeenCalledWith('forced restart', { preserveHandlers: undefined }); expect(startPersistentQuerySpy).toHaveBeenCalledTimes(1); // Not called again }); it('should return false when CLI unavailable after config change close', async () => { const startPersistentQuerySpy = jest.spyOn(service as any, 'startPersistentQuery'); const closePersistentQuerySpy = jest.spyOn(service, 'closePersistentQuery'); // Mock startPersistentQuery to simulate real side effects (subprocess boundary) startPersistentQuerySpy.mockImplementation(async (vaultPath: string, cliPath: string, _sessionId?: string, externalContextPaths?: string[]) => { (service as any).persistentQuery = { interrupt: jest.fn().mockResolvedValue(undefined) }; (service as any).currentConfig = (service as any).buildPersistentQueryConfig(vaultPath, cliPath, externalContextPaths); }); // Start the query first (Case 1: not running) await service.ensureReady({ externalContextPaths: [] }); // Make CLI unavailable after the config change detection // In Case 3, CLI is checked once before needsRestart, then again after close let cliCallCount = 0; (mockPlugin.getResolvedClaudeCliPath as jest.Mock).mockImplementation(() => { cliCallCount++; // First call (for config check) returns valid path // Second call (after close, for restart) returns null return cliCallCount === 1 ? '/usr/local/bin/claude' : null; }); // Config change should close but fail to start new one (CLI unavailable) const result = await service.ensureReady({ externalContextPaths: ['/new/path'] }); expect(result).toBe(false); expect(closePersistentQuerySpy).toHaveBeenCalledWith('config changed', { preserveHandlers: undefined }); }); it('should cleanup resources', () => { const closePersistentQuerySpy = jest.spyOn(service, 'closePersistentQuery'); const cancelSpy = jest.spyOn(service, 'cancel'); service.cleanup(); expect(closePersistentQuerySpy).toHaveBeenCalledWith('plugin cleanup'); expect(cancelSpy).toHaveBeenCalled(); }); }); describe('Query Cancellation', () => { it('should cancel cold-start query', () => { const abortSpy = jest.fn(); (service as any).abortController = { abort: abortSpy, signal: { aborted: false } }; service.cancel(); expect(abortSpy).toHaveBeenCalled(); }); it('should mark session as interrupted on cancel', () => { const sessionManager = (service as any).sessionManager; (service as any).abortController = { abort: jest.fn(), signal: { aborted: false } }; service.cancel(); expect(sessionManager.wasInterrupted()).toBe(true); }); it('should call approval dismisser on cancel', () => { const dismisser = jest.fn(); service.setApprovalDismisser(dismisser); service.cancel(); expect(dismisser).toHaveBeenCalled(); }); it('should not throw when no approval dismisser is set', () => { expect(() => service.cancel()).not.toThrow(); }); }); describe('Approval Callback', () => { // approvalCallback is private with no observable side effect from setApprovalCallback alone. // Verifying the stored value requires direct access. it('should set approval callback', () => { const callback = jest.fn(); service.setApprovalCallback(callback); expect((service as any).approvalCallback).toBe(callback); }); it('should set null approval callback', () => { const callback = jest.fn(); service.setApprovalCallback(callback); service.setApprovalCallback(null); expect((service as any).approvalCallback).toBeNull(); }); describe('createApprovalCallback permission flow', () => { const canUseToolOptions = { signal: new AbortController().signal, toolUseID: 'test-tool-use-id', }; it('should deny when no approvalCallback is set', async () => { const canUseTool = (service as any).createApprovalCallback(); const result = await canUseTool('Bash', { command: 'ls' }, canUseToolOptions); expect(result.behavior).toBe('deny'); expect(result.message).toBe('No approval handler available.'); }); it('should return deny when user denies', async () => { const callback = jest.fn().mockResolvedValue('deny'); service.setApprovalCallback(callback); const canUseTool = (service as any).createApprovalCallback(); const result = await canUseTool('Bash', { command: 'ls' }, canUseToolOptions); expect(result.behavior).toBe('deny'); expect(result.message).toBe('User denied this action.'); expect(result).not.toHaveProperty('updatedPermissions'); }); it('should return deny without interrupt when approvalCallback throws', async () => { const callback = jest.fn().mockRejectedValue(new Error('Modal render failed')); service.setApprovalCallback(callback); const canUseTool = (service as any).createApprovalCallback(); const result = await canUseTool('Bash', { command: 'ls' }, canUseToolOptions); expect(result.behavior).toBe('deny'); expect(result.message).toContain('Modal render failed'); expect(result.interrupt).toBe(false); }); it('should return deny with interrupt for cancel decisions', async () => { const callback = jest.fn().mockResolvedValue('cancel'); service.setApprovalCallback(callback); const canUseTool = (service as any).createApprovalCallback(); const result = await canUseTool('Bash', { command: 'ls' }, canUseToolOptions); expect(result.behavior).toBe('deny'); expect(result.message).toBe('User interrupted.'); expect(result.interrupt).toBe(true); }); it('should prompt again after deny (no session cache)', async () => { const callback = jest.fn().mockResolvedValue('deny'); service.setApprovalCallback(callback); const canUseTool = (service as any).createApprovalCallback(); await canUseTool('Bash', { command: 'rm -rf /tmp' }, canUseToolOptions); await canUseTool('Bash', { command: 'rm -rf /tmp' }, canUseToolOptions); expect(callback).toHaveBeenCalledTimes(2); }); it('should forward decisionReason and blockedPath to approvalCallback', async () => { const callback = jest.fn().mockResolvedValue('allow'); service.setApprovalCallback(callback); const canUseTool = (service as any).createApprovalCallback(); await canUseTool('Read', { file_path: '/etc/passwd' }, { ...canUseToolOptions, decisionReason: 'Path is outside allowed directories', blockedPath: '/etc/passwd', }); expect(callback).toHaveBeenCalledWith( 'Read', { file_path: '/etc/passwd' }, 'Read file: /etc/passwd', { decisionReason: 'Path is outside allowed directories', blockedPath: '/etc/passwd', agentID: undefined, }, ); }); it('should forward agentID to approvalCallback', async () => { const callback = jest.fn().mockResolvedValue('allow'); service.setApprovalCallback(callback); const canUseTool = (service as any).createApprovalCallback(); await canUseTool('Bash', { command: 'ls' }, { ...canUseToolOptions, agentID: 'sub-agent-42', }); expect(callback).toHaveBeenCalledWith( 'Bash', { command: 'ls' }, expect.any(String), { decisionReason: undefined, blockedPath: undefined, agentID: 'sub-agent-42', }, ); }); it('should return updatedPermissions with session destination for allow decisions', async () => { const callback = jest.fn().mockResolvedValue('allow'); service.setApprovalCallback(callback); const canUseTool = (service as any).createApprovalCallback(); const result = await canUseTool('Bash', { command: 'git status' }, canUseToolOptions); expect(result.behavior).toBe('allow'); expect(result.updatedPermissions).toBeDefined(); expect(result.updatedPermissions[0]).toMatchObject({ type: 'addRules', behavior: 'allow', destination: 'session', }); }); it('should return updatedPermissions for allow-always decisions', async () => { const callback = jest.fn().mockResolvedValue('allow-always'); service.setApprovalCallback(callback); const canUseTool = (service as any).createApprovalCallback(); const result = await canUseTool('Bash', { command: 'git status' }, canUseToolOptions); expect(result.behavior).toBe('allow'); expect(result.updatedPermissions).toBeDefined(); expect(result.updatedPermissions[0]).toMatchObject({ type: 'addRules', behavior: 'allow', destination: 'projectSettings', }); }); }); }); describe('Session Restoration', () => { it('should restore session with custom model', () => { const customModel = 'claude-3-opus'; (mockPlugin as any).settings.model = customModel; service.setSessionId('test-session-123'); expect(service.getSessionId()).toBe('test-session-123'); }); it('should invalidate session on reset', () => { service.setSessionId('test-session-123'); service.resetSession(); expect(service.getSessionId()).toBeNull(); }); }); describe('Ready State Change Listeners', () => { it('should call listener immediately with current ready state on subscribe', () => { const listener = jest.fn(); service.onReadyStateChange(listener); expect(listener).toHaveBeenCalledWith(false); }); it('should call listener with true when service is ready', () => { (service as any).persistentQuery = {}; (service as any).shuttingDown = false; const listener = jest.fn(); service.onReadyStateChange(listener); expect(listener).toHaveBeenCalledWith(true); }); it('should return unsubscribe function that removes listener', () => { const listener = jest.fn(); const unsubscribe = service.onReadyStateChange(listener); unsubscribe(); expect((service as any).readyStateListeners.has(listener)).toBe(false); }); it('should notify multiple listeners when ready state changes', () => { const listener1 = jest.fn(); const listener2 = jest.fn(); service.onReadyStateChange(listener1); service.onReadyStateChange(listener2); listener1.mockClear(); listener2.mockClear(); (service as any).notifyReadyStateChange(); expect(listener1).toHaveBeenCalledWith(false); expect(listener2).toHaveBeenCalledWith(false); }); it('should not call unsubscribed listeners on notify', () => { const listener1 = jest.fn(); const listener2 = jest.fn(); service.onReadyStateChange(listener1); const unsubscribe2 = service.onReadyStateChange(listener2); listener1.mockClear(); listener2.mockClear(); unsubscribe2(); (service as any).notifyReadyStateChange(); expect(listener1).toHaveBeenCalled(); expect(listener2).not.toHaveBeenCalled(); }); it('should isolate listener errors and continue notifying other listeners', () => { const errorListener = jest.fn().mockImplementation(() => { throw new Error('Listener error'); }); const normalListener = jest.fn(); service.onReadyStateChange(errorListener); service.onReadyStateChange(normalListener); normalListener.mockClear(); expect(() => (service as any).notifyReadyStateChange()).not.toThrow(); expect(normalListener).toHaveBeenCalledWith(false); }); it('should isolate errors on immediate callback during subscribe', () => { const errorListener = jest.fn().mockImplementation(() => { throw new Error('Listener error'); }); expect(() => service.onReadyStateChange(errorListener)).not.toThrow(); expect(errorListener).toHaveBeenCalled(); }); it('should skip notification when no listeners registered', () => { expect(() => (service as any).notifyReadyStateChange()).not.toThrow(); }); }); describe('SDK Skills (Supported Commands)', () => { it('should report not ready when no persistent query exists', () => { expect(service.isReady()).toBe(false); }); it('should report ready when persistent query is active', () => { // Simulate active persistent query (service as any).persistentQuery = {}; (service as any).shuttingDown = false; expect(service.isReady()).toBe(true); }); it('should report not ready when shutting down', () => { (service as any).persistentQuery = {}; (service as any).shuttingDown = true; expect(service.isReady()).toBe(false); }); it('should return empty array when no persistent query', async () => { const commands = await service.getSupportedCommands(); expect(commands).toEqual([]); }); it('should convert SDK skills to SlashCommand format', async () => { const mockSdkCommands = [ { name: 'commit', description: 'Create a git commit', argumentHint: '' }, { name: 'pr', description: 'Create a pull request', argumentHint: '' }, ]; const mockQuery = { supportedCommands: jest.fn().mockResolvedValue(mockSdkCommands), }; (service as any).persistentQuery = mockQuery; const commands = await service.getSupportedCommands(); expect(mockQuery.supportedCommands).toHaveBeenCalled(); expect(commands).toHaveLength(2); expect(commands[0]).toEqual({ id: 'sdk:commit', name: 'commit', description: 'Create a git commit', argumentHint: '', content: '', source: 'sdk', }); expect(commands[1]).toEqual({ id: 'sdk:pr', name: 'pr', description: 'Create a pull request', argumentHint: '<title>', content: '', source: 'sdk', }); }); it('should return empty array on SDK error', async () => { const mockQuery = { supportedCommands: jest.fn().mockRejectedValue(new Error('SDK error')), }; (service as any).persistentQuery = mockQuery; const commands = await service.getSupportedCommands(); expect(commands).toEqual([]); }); }); describe('isPipeError', () => { it('should return true for EPIPE code', () => { const error = { code: 'EPIPE' }; expect((service as any).isPipeError(error)).toBe(true); }); it('should return true for EPIPE in message', () => { const error = { message: 'write EPIPE to stdin' }; expect((service as any).isPipeError(error)).toBe(true); }); it('should return false for other errors', () => { const error = { code: 'ENOENT', message: 'file not found' }; expect((service as any).isPipeError(error)).toBe(false); }); it('should return false for null', () => { expect((service as any).isPipeError(null)).toBe(false); }); it('should return false for non-object', () => { expect((service as any).isPipeError('string')).toBe(false); }); it('should return false for undefined', () => { expect((service as any).isPipeError(undefined)).toBe(false); }); }); describe('isStreamTextEvent', () => { it('should return false for non-stream_event messages', () => { expect((service as any).isStreamTextEvent({ type: 'assistant' })).toBe(false); expect((service as any).isStreamTextEvent({ type: 'result' })).toBe(false); expect((service as any).isStreamTextEvent({ type: 'user' })).toBe(false); }); it('should return false when event is missing', () => { expect((service as any).isStreamTextEvent({ type: 'stream_event' })).toBe(false); }); it('should return true for content_block_start with text type', () => { expect((service as any).isStreamTextEvent({ type: 'stream_event', event: { type: 'content_block_start', content_block: { type: 'text' } }, })).toBe(true); }); it('should return false for content_block_start with non-text type', () => { expect((service as any).isStreamTextEvent({ type: 'stream_event', event: { type: 'content_block_start', content_block: { type: 'tool_use' } }, })).toBe(false); }); it('should return true for content_block_delta with text_delta type', () => { expect((service as any).isStreamTextEvent({ type: 'stream_event', event: { type: 'content_block_delta', delta: { type: 'text_delta' } }, })).toBe(true); }); it('should return false for content_block_delta with non-text_delta type', () => { expect((service as any).isStreamTextEvent({ type: 'stream_event', event: { type: 'content_block_delta', delta: { type: 'input_json_delta' } }, })).toBe(false); }); it('should return false for other stream event types', () => { expect((service as any).isStreamTextEvent({ type: 'stream_event', event: { type: 'message_start' }, })).toBe(false); }); }); describe('buildSDKUserMessage', () => { it('should build text-only message', () => { const message = (service as any).buildSDKUserMessage('Hello Claude'); expect(message).toEqual({ type: 'user', message: { role: 'user', content: 'Hello Claude' }, parent_tool_use_id: null, session_id: '', uuid: expect.any(String), }); }); it('should include session ID when available', () => { service.setSessionId('session-abc'); const message = (service as any).buildSDKUserMessage('Test'); expect(message.session_id).toBe('session-abc'); }); it('should build message with images', () => { const images = [{ id: 'img1', name: 'test.png', mediaType: 'image/png', data: 'base64data', size: 100, source: 'file', }]; const message = (service as any).buildSDKUserMessage('Look at this', images); expect(message.message.content).toEqual([ { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'base64data' } }, { type: 'text', text: 'Look at this' }, ]); }); it('should omit text block when prompt is empty with images', () => { const images = [{ id: 'img1', name: 'test.png', mediaType: 'image/png', data: 'base64data', size: 100, source: 'file', }]; const message = (service as any).buildSDKUserMessage(' ', images); expect(message.message.content).toEqual([ { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'base64data' } }, ]); }); it('should handle empty images array as text-only', () => { const message = (service as any).buildSDKUserMessage('Hello', []); expect(message.message.content).toBe('Hello'); }); }); describe('buildPromptWithImages', () => { it('should return plain string when no images', () => { const result = (service as any).buildPromptWithImages('Hello'); expect(result).toBe('Hello'); }); it('should return plain string when images is undefined', () => { const result = (service as any).buildPromptWithImages('Hello', undefined); expect(result).toBe('Hello'); }); it('should return plain string when images is empty', () => { const result = (service as any).buildPromptWithImages('Hello', []); expect(result).toBe('Hello'); }); it('should return async generator when images are provided', async () => { const images = [{ id: 'img1', name: 'test.png', mediaType: 'image/png', data: 'base64data', size: 100, source: 'file', }]; const result = (service as any).buildPromptWithImages('Describe', images); // Should be an async generator expect(typeof result[Symbol.asyncIterator]).toBe('function'); const messages: any[] = []; for await (const msg of result) { messages.push(msg); } expect(messages).toHaveLength(1); expect(messages[0].type).toBe('user'); expect(messages[0].message.role).toBe('user'); expect(messages[0].message.content).toEqual([ { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'base64data' } }, { type: 'text', text: 'Describe' }, ]); }); it('should omit text when prompt is whitespace with images', async () => { const images = [{ id: 'img1', name: 'test.png', mediaType: 'image/png', data: 'base64data', size: 100, source: 'file', }]; const result = (service as any).buildPromptWithImages(' ', images); const messages: any[] = []; for await (const msg of result) { messages.push(msg); } expect(messages[0].message.content).toEqual([ { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'base64data' } }, ]); }); }); describe('consumeSessionInvalidation', () => { it('should return false when no invalidation', () => { expect(service.consumeSessionInvalidation()).toBe(false); }); it('should delegate to sessionManager', () => { const sessionManager = (service as any).sessionManager; sessionManager.invalidateSession(); expect(service.consumeSessionInvalidation()).toBe(true); // Should be consumed expect(service.consumeSessionInvalidation()).toBe(false); }); }); describe('Response Handler Management', () => { it('should register and unregister handlers', () => { const handler = createResponseHandler({ id: 'test-handler', onChunk: jest.fn(), onDone: jest.fn(), onError: jest.fn(), }); (service as any).registerResponseHandler(handler); expect((service as any).responseHandlers).toHaveLength(1); (service as any).unregisterResponseHandler('test-handler'); expect((service as any).responseHandlers).toHaveLength(0); }); it('should not fail when unregistering non-existent handler', () => { (service as any).unregisterResponseHandler('nonexistent'); expect((service as any).responseHandlers).toHaveLength(0); }); it('should register multiple handlers', () => { const handler1 = createResponseHandler({ id: 'h1', onChunk: jest.fn(), onDone: jest.fn(), onError: jest.fn(), }); const handler2 = createResponseHandler({ id: 'h2', onChunk: jest.fn(), onDone: jest.fn(), onError: jest.fn(), }); (service as any).registerResponseHandler(handler1); (service as any).registerResponseHandler(handler2); expect((service as any).responseHandlers).toHaveLength(2); (service as any).unregisterResponseHandler('h1'); expect((service as any).responseHandlers).toHaveLength(1); expect((service as any).responseHandlers[0].id).toBe('h2'); }); }); describe('closePersistentQuery handler notification', () => { it('should call onDone on all handlers when not preserving', () => { const onDone1 = jest.fn(); const onDone2 = jest.fn(); const handler1 = createResponseHandler({ id: 'h1', onChunk: jest.fn(), onDone: onDone1, onError: jest.fn() }); const handler2 = createResponseHandler({ id: 'h2', onChunk: jest.fn(), onDone: onDone2, onError: jest.fn() }); // Set up persistent query state (service as any).persistentQuery = { interrupt: jest.fn().mockResolvedValue(undefined) }; (service as any).messageChannel = { close: jest.fn() }; (service as any).queryAbortController = { abort: jest.fn() }; (service as any).responseHandlers = [handler1, handler2]; service.closePersistentQuery('test'); expect(onDone1).toHaveBeenCalled(); expect(onDone2).toHaveBeenCalled(); }); it('should NOT call onDone when preserving handlers', () => { const onDone = jest.fn(); const handler = createResponseHandler({ id: 'h1', onChunk: jest.fn(), onDone, onError: jest.fn() }); (service as any).persistentQuery = { interrupt: jest.fn().mockResolvedValue(undefined) }; (service as any).messageChannel = { close: jest.fn() }; (service as any).queryAbortController = { abort: jest.fn() }; (service as any).responseHandlers = [handler]; service.closePersistentQuery('test', { preserveHandlers: true }); expect(onDone).not.toHaveBeenCalled(); }); }); describe('Cancel with persistent query', () => { it('should interrupt persistent query on cancel', () => { const interruptMock = jest.fn().mockResolvedValue(undefined); (service as any).persistentQuery = { interrupt: interruptMock }; (service as any).shuttingDown = false; service.cancel(); expect(interruptMock).toHaveBeenCalled(); }); it('should not interrupt persistent query when shutting down', () => { const interruptMock = jest.fn().mockResolvedValue(undefined); (service as any).persistentQuery = { interrupt: interruptMock }; (service as any).shuttingDown = true; service.cancel(); expect(interruptMock).not.toHaveBeenCalled(); }); }); describe('createApprovalCallback - allowed tools restriction', () => { const canUseToolOptions = { signal: new AbortController().signal, toolUseID: 'test-tool-use-id', }; it('should deny tools not in allowedTools list', async () => { (service as any).currentAllowedTools = ['Read', 'Glob']; const callback = jest.fn().mockResolvedValue('allow'); service.setApprovalCallback(callback); const canUseTool = (service as any).createApprovalCallback(); const result = await canUseTool('Bash', { command: 'ls' }, canUseToolOptions); expect(result.behavior).toBe('deny'); expect(result.message).toContain('not allowed'); expect(result.message).toContain('Allowed tools: Read, Glob'); expect(callback).not.toHaveBeenCalled(); }); it('should deny when allowedTools is empty', async () => { (service as any).currentAllowedTools = []; const callback = jest.fn().mockResolvedValue('allow'); service.setApprovalCallback(callback); const canUseTool = (service as any).createApprovalCallback(); const result = await canUseTool('Read', { file_path: 'test.md' }, canUseToolOptions); expect(result.behavior).toBe('deny'); expect(result.message).toContain('No tools are allowed'); }); it('should allow Skill tool even when not in allowedTools', async () => { (service as any).currentAllowedTools = ['Read']; const callback = jest.fn().mockResolvedValue('allow'); service.setApprovalCallback(callback); const canUseTool = (service as any).createApprovalCallback(); const result = await canUseTool('Skill', { name: 'commit' }, canUseToolOptions); expect(result.behavior).toBe('allow'); expect(callback).toHaveBeenCalled(); }); it('should allow tools in the allowedTools list', async () => { (service as any).currentAllowedTools = ['Read', 'Glob']; const callback = jest.fn().mockResolvedValue('allow'); service.setApprovalCallback(callback); const canUseTool = (service as any).createApprovalCallback(); const result = await canUseTool('Read', { file_path: 'test.md' }, canUseToolOptions); expect(result.behavior).toBe('allow'); expect(callback).toHaveBeenCalled(); }); it('should not restrict when currentAllowedTools is null', async () => { (service as any).currentAllowedTools = null; const callback = jest.fn().mockResolvedValue('allow'); service.setApprovalCallback(callback); const canUseTool = (service as any).createApprovalCallback(); const result = await canUseTool('Bash', { command: 'rm -rf /' }, canUseToolOptions); expect(result.behavior).toBe('allow'); expect(callback).toHaveBeenCalled(); }); }); describe('routeMessage', () => { let handler: ReturnType<typeof createResponseHandler>; let onChunk: jest.Mock; let onDone: jest.Mock; beforeEach(() => { onChunk = jest.fn(); onDone = jest.fn(); handler = createResponseHandler({ id: 'route-test', onChunk, onDone, onError: jest.fn(), }); (service as any).responseHandlers = [handler]; (service as any).messageChannel = { onTurnComplete: jest.fn(), setSessionId: jest.fn(), }; }); it('should route session_init event and capture session', async () => { const message = { type: 'system', subtype: 'init', session_id: 'new-session-42' }; await (service as any).routeMessage(message); expect(service.getSessionId()).toBe('new-session-42'); }); it('should route stream chunks to handler', async () => { const message = { type: 'assistant', message: { content: [{ type: 'text', text: 'Hello' }] }, }; await (service as any).routeMessage(message); expect(onChunk).toHaveBeenCalled(); }); it('should signal turn complete on result message', async () => { const message = { type: 'result', subtype: 'success', result: 'completed' }; await (service as any).routeMessage(message); expect((service as any).messageChannel.onTurnComplete).toHaveBeenCalled(); expect(onDone).toHaveBeenCalled(); }); it('should yield error event from assistant message with error field', async () => { const message = { type: 'assistant', error: 'rate_limit', message: { content: [] } }; await (service as any).routeMessage(message); expect(onChunk).toHaveBeenCalledWith( expect.objectContaining({ type: 'error', content: 'rate_limit' }), ); }); it('should add sessionId to usage chunks', async () => { service.setSessionId('usage-session'); const message = { type: 'assistant', message: { content: [{ type: 'text', text: 'Response' }], usage: { input_tokens: 100, output_tokens: 50, cache_creation_input_tokens: 10, cache_read_input_tokens: 20, }, }, }; await (service as any).routeMessage(message); const usageChunks = onChunk.mock.calls.filter( ([chunk]: any) => chunk.type === 'usage' ); expect(usageChunks.length).toBeGreaterThan(0); expect(usageChunks[0][0].sessionId).toBe('usage-session'); }); it('should mark stream text seen on text stream events', async () => { const message = { type: 'stream_event', event: { type: 'content_block_start', content_block: { type: 'text' } }, }; await (service as any).routeMessage(message); expect(handler.sawStreamText).toBe(true); }); it('should skip duplicate text from assistant messages after stream text', async () => { // First, mark stream text as seen handler.markStreamTextSeen(); // Now send an assistant message with text content const message = { type: 'assistant', message: { content: [{ type: 'text', text: 'Streamed text' }] }, }; await (service as any).routeMessage(message); // Text chunks should be skipped const textChunks = onChunk.mock.calls.filter( ([chunk]: any) => chunk.type === 'text' ); expect(textChunks).toHaveLength(0); }); it('should reset auto-turn stream-text dedup after an empty buffered turn completes', async () => { (service as any).responseHandlers = []; const autoTurnCallback = jest.fn(); service.setAutoTurnCallback(autoTurnCallback); await (service as any).routeMessage({ type: 'stream_event', event: { type: 'content_block_start', content_block: { type: 'text' } }, }); await (service as any).routeMessage({ type: 'assistant', message: { content: [{ type: 'text', text: 'Deduped away' }] }, }); await (service as any).routeMessage({ type: 'result', subtype: 'success', result: 'first turn complete', }); await (service as any).routeMessage({ type: 'assistant', message: { content: [{ type: 'text', text: 'Fresh auto-turn text' }] }, }); await (service as any).routeMessage({ type: 'result', subtype: 'success', result: 'second turn complete', }); expect(autoTurnCallback).toHaveBeenCalledTimes(1); expect(autoTurnCallback).toHaveBeenCalledWith([ expect.objectContaining({ type: 'text', content: 'Fresh auto-turn text' }), ]); }); it('should notify when auto-turn callback rendering fails', async () => { (service as any).responseHandlers = []; const callbackError = new Error('renderer exploded'); service.setAutoTurnCallback(() => { throw callbackError; }); await (service as any).routeMessage({ type: 'assistant', message: { content: [{ type: 'text', text: 'Background result' }] }, }); await (service as any).routeMessage({ type: 'result', subtype: 'success', result: 'turn complete', }); expect(Notice).toHaveBeenCalledWith( expect.stringContaining('Background task completed') ); }); it('should suppress history rebuild and clear pendingForkSession on fork session init', async () => { // Set an existing session so captureSession detects a mismatch service.setSessionId('old-session'); // Apply fork state to mark as pending fork service.applyForkState({ sessionId: null, forkSource: { sessionId: 'old-session', resumeAt: 'asst-uuid-1' }, }); expect((service as any).pendingForkSession).toBe(true); // Simulate session_init from SDK with a NEW session ID (fork creates a new session) const message = { type: 'system', subtype: 'init', session_id: 'forked-session-new' }; await (service as any).routeMessage(message); // Session should be captured expect(service.getSessionId()).toBe('forked-session-new'); // Fork path should suppress the history rebuild that captureSession would normally trigger expect((service as any).sessionManager.needsHistoryRebuild()).toBe(false); // pendingForkSession should be consumed (one-shot) expect((service as any).pendingForkSession).toBe(false); }); it('should NOT suppress history rebuild for non-fork session mismatch', async () => { // Set an existing session so captureSession detects a mismatch service.setSessionId('old-session'); // No fork state applied — this is a normal session mismatch const message = { type: 'system', subtype: 'init', session_id: 'different-session' }; await (service as any).routeMessage(message); expect(service.getSessionId()).toBe('different-session'); // Normal mismatch should trigger history rebuild expect((service as any).sessionManager.needsHistoryRebuild()).toBe(true); expect((service as any).pendingForkSession).toBe(false); }); }); describe('applyDynamicUpdates', () => { let mockPersistentQuery: any; beforeEach(async () => { sdkMock.resetMockMessages(); // Start a persistent query via ensureReady const startSpy = jest.spyOn(service as any, 'startPersistentQuery'); startSpy.mockImplementation(async (vaultPath: string, cliPath: string, _sessionId?: string, externalContextPaths?: string[]) => { mockPersistentQuery = { interrupt: jest.fn().mockResolvedValue(undefined), setModel: jest.fn().mockResolvedValue(undefined), setMaxThinkingTokens: jest.fn().mockResolvedValue(undefined), setPermissionMode: jest.fn().mockResolvedValue(undefined), setMcpServers: jest.fn().mockResolvedValue({ added: [], removed: [], errors: {} }), }; (service as any).persistentQuery = mockPersistentQuery; (service as any).vaultPath = vaultPath; (service as any).currentConfig = (service as any).buildPersistentQueryConfig(vaultPath, cliPath, externalContextPaths); }); await service.ensureReady({ externalContextPaths: [] }); }); it('should update model when changed', async () => { (mockPlugin as any).settings.model = 'claude-3-opus'; await (service as any).applyDynamicUpdates({ model: 'claude-3-opus' }); expect(mockPersistentQuery.setModel).toHaveBeenCalled(); }); it('should not update model when unchanged', async () => { await (service as any).applyDynamicUpdates({ model: 'claude-3-5-sonnet' }); expect(mockPersistentQuery.setModel).not.toHaveBeenCalled(); }); it('should update thinking tokens when changed', async () => { // Initial budget is 0 (not a valid ThinkingBudget value) → tokens = null // Change to 'high' → tokens = 16000 (different from null → triggers update) (mockPlugin as any).settings.thinkingBudget = 'high'; await (service as any).applyDynamicUpdates({}); expect(mockPersistentQuery.setMaxThinkingTokens).toHaveBeenCalledWith(16000); }); it('should update permission mode when changed', async () => { (mockPlugin as any).settings.permissionMode = 'yolo'; await (service as any).applyDynamicUpdates({}); expect(mockPersistentQuery.setPermissionMode).toHaveBeenCalledWith('bypassPermissions'); }); it('should update MCP servers when changed', async () => { mockMcpManager.getActiveServers.mockReturnValue({ 'test-server': { command: 'test', args: [] }, }); await (service as any).applyDynamicUpdates({ mcpMentions: new Set(['test-server']), }); expect(mockPersistentQuery.setMcpServers).toHaveBeenCalled(); }); it('should not restart when allowRestart is false', async () => { // Change something that would trigger restart (mockPlugin.getResolvedClaudeCliPath as jest.Mock).mockReturnValue('/new/path/to/claude'); const ensureReadySpy = jest.spyOn(service, 'ensureReady'); await (service as any).applyDynamicUpdates({}, undefined, false); // ensureReady should NOT be called for restart when allowRestart is false expect(ensureReadySpy).not.toHaveBeenCalled(); }); it('should return early when no persistent query', async () => { (service as any).persistentQuery = null; // Should not throw await expect((service as any).applyDynamicUpdates({})).resolves.toBeUndefined(); }); it('should return early when no vault path', async () => { (service as any).vaultPath = null; await (service as any).applyDynamicUpdates({}); expect(mockPersistentQuery.setModel).not.toHaveBeenCalled(); }); it('should silently handle model update error', async () => { (mockPlugin as any).settings.model = 'claude-3-opus'; mockPersistentQuery.setModel.mockRejectedValueOnce(new Error('Model error')); await expect((service as any).applyDynamicUpdates({ model: 'claude-3-opus' })).resolves.toBeUndefined(); }); it('should silently handle thinking tokens update error', async () => { (mockPlugin as any).settings.thinkingBudget = 5000; mockPersistentQuery.setMaxThinkingTokens.mockRejectedValueOnce(new Error('Thinking error')); await expect((service as any).applyDynamicUpdates({})).resolves.toBeUndefined(); }); it('should silently handle permission mode update error', async () => { (mockPlugin as any).settings.permissionMode = 'yolo'; mockPersistentQuery.setPermissionMode.mockRejectedValueOnce(new Error('Permission error')); await expect((service as any).applyDynamicUpdates({})).resolves.toBeUndefined(); }); it('should silently handle MCP servers update error', async () => { mockMcpManager.getActiveServers.mockReturnValue({ 'server-1': { command: 'cmd' } }); mockPersistentQuery.setMcpServers.mockRejectedValueOnce(new Error('MCP error')); await expect((service as any).applyDynamicUpdates({ mcpMentions: new Set(['server-1']) })).resolves.toBeUndefined(); }); }); describe('query() method', () => { beforeEach(() => { sdkMock.resetMockMessages(); }); afterEach(() => { sdkMock.resetMockMessages(); }); it('should yield error when vault path is not available', async () => { (mockPlugin as any).app.vault.adapter.basePath = undefined; const chunks = await collectChunks(service.query('hello')); expect(chunks).toEqual([{ type: 'error', content: 'Could not determine vault path' }]); }); it('should yield error when CLI path is not available', async () => { (mockPlugin.getResolvedClaudeCliPath as jest.Mock).mockReturnValue(null); const chunks = await collectChunks(service.query('hello')); expect(chunks).toEqual([{ type: 'error', content: expect.stringContaining('Claude CLI not found') }]); }); it('should yield chunks from cold-start query', async () => { sdkMock.setMockMessages([ { type: 'system', subtype: 'init', session_id: 'cold-session' }, { type: 'assistant', message: { content: [{ type: 'text', text: 'Hi!' }] } }, ]); const chunks = await collectChunks(service.query('hello')); expect(chunks.length).toBeGreaterThan(0); const doneChunks = chunks.filter(c => c.type === 'done'); expect(doneChunks).toHaveLength(1); }); it('should capture session ID from cold-start response', async () => { sdkMock.setMockMessages([ { type: 'system', subtype: 'init', session_id: 'captured-session' }, { type: 'assistant', message: { content: [{ type: 'text', text: 'Hello' }] } }, ]); await collectChunks(service.query('hello')); expect(service.getSessionId()).toBe('captured-session'); }); it('should use persistent query when available', async () => { sdkMock.setMockMessages([ { type: 'system', subtype: 'init', session_id: 'persistent-session' }, { type: 'assistant', message: { content: [{ type: 'text', text: 'Hi' }] } }, ]); // Start a real persistent query via ensureReady mocking const startSpy = jest.spyOn(service as any, 'startPersistentQuery'); startSpy.mockImplementation(async (vaultPath: string, cliPath: string) => { const messageChannel = new MessageChannel(); (service as any).messageChannel = messageChannel; (service as any).persistentQuery = sdkMock.query({ prompt: messageChannel, options: { cwd: vaultPath, pathToClaudeCodeExecutable: cliPath } as any }); (service as any).currentConfig = (service as any).buildPersistentQueryConfig(vaultPath, cliPath, []); (service as any).startResponseConsumer(); }); await service.ensureReady(); const chunks = await collectChunks(service.query('hello')); const doneChunks = chunks.filter(c => c.type === 'done'); expect(doneChunks).toHaveLength(1); }); it('should rebuild history context when no session but has history', async () => { sdkMock.setMockMessages([ { type: 'system', subtype: 'init', session_id: 'new-session' }, { type: 'assistant', message: { content: [{ type: 'text', text: 'OK' }] } }, ]); const history: any[] = [ { id: '1', role: 'user', content: 'First question', timestamp: 1000 }, { id: '2', role: 'assistant', content: 'First answer', timestamp: 1001 }, ]; // No session set, but has history → should force cold start const chunks = await collectChunks(service.query('follow up', undefined, history)); const doneChunks = chunks.filter(c => c.type === 'done'); expect(doneChunks).toHaveLength(1); }); it('should handle errors in cold-start query', async () => { // Provide at least one message so the iterator runs and crash triggers sdkMock.setMockMessages([ { type: 'assistant', message: { content: [{ type: 'text', text: 'Hi' }] } }, ]); // Crash on first iteration (before emitting any message) sdkMock.simulateCrash(0); // Force cold-start to test the cold-start error handling path const chunks = await collectChunks( service.query('hello', undefined, undefined, { forceColdStart: true }) ); const errorChunks = chunks.filter(c => c.type === 'error'); expect(errorChunks).toHaveLength(1); expect(errorChunks[0].content).toContain('Simulated consumer crash'); }); }); describe('buildHistoryRebuildRequest', () => { it('should build request with history context', () => { const history: any[] = [ { id: '1', role: 'user', content: 'Tell me about X', timestamp: 1000 }, { id: '2', role: 'assistant', content: 'X is great', timestamp: 1001 }, ]; const result = (service as any).buildHistoryRebuildRequest('New question', history); expect(result.prompt).toContain('Tell me about X'); expect(result.prompt).toContain('X is great'); }); it('should include images from last user message', () => { const images = [{ id: 'img1', mediaType: 'image/png', data: 'abc', name: 'test.png', size: 100, source: 'file' }]; const history: any[] = [ { id: '1', role: 'user', content: 'Look', timestamp: 1000, images }, ]; const result = (service as any).buildHistoryRebuildRequest('Follow up', history); expect(result.images).toEqual(images); }); it('should return undefined images when last user message has no images', () => { const history: any[] = [ { id: '1', role: 'user', content: 'No images', timestamp: 1000 }, ]; const result = (service as any).buildHistoryRebuildRequest('Follow up', history); expect(result.images).toBeUndefined(); }); }); describe('startPersistentQuery guard', () => { it('should not start if already running', async () => { (service as any).persistentQuery = { interrupt: jest.fn() }; const buildOptsSpy = jest.spyOn(service as any, 'buildPersistentQueryOptions'); await (service as any).startPersistentQuery('/vault', '/cli', 'session'); expect(buildOptsSpy).not.toHaveBeenCalled(); }); }); describe('attachPersistentQueryStdinErrorHandler', () => { it('should attach error handler to stdin', () => { const onMock = jest.fn(); const onceMock = jest.fn(); const mockQuery = { transport: { processStdin: { on: onMock, once: onceMock, }, }, }; (service as any).attachPersistentQueryStdinErrorHandler(mockQuery); expect(onMock).toHaveBeenCalledWith('error', expect.any(Function)); expect(onceMock).toHaveBeenCalledWith('close', expect.any(Function)); }); it('should handle query without transport', () => { const mockQuery = {}; // Should not throw expect(() => (service as any).attachPersistentQueryStdinErrorHandler(mockQuery)).not.toThrow(); }); it('should handle query with transport but no processStdin', () => { const mockQuery = { transport: {} }; expect(() => (service as any).attachPersistentQueryStdinErrorHandler(mockQuery)).not.toThrow(); }); it('should close persistent query on non-pipe error when not shutting down', () => { const closeSpy = jest.spyOn(service, 'closePersistentQuery'); (service as any).persistentQuery = { interrupt: jest.fn().mockResolvedValue(undefined) }; (service as any).messageChannel = { close: jest.fn() }; (service as any).queryAbortController = { abort: jest.fn() }; (service as any).shuttingDown = false; let errorHandler: (error: any) => void; const mockQuery = { transport: { processStdin: { on: jest.fn((event: string, handler: any) => { if (event === 'error') errorHandler = handler; }), once: jest.fn(), removeListener: jest.fn(), }, }, }; (service as any).attachPersistentQueryStdinErrorHandler(mockQuery); // Trigger non-pipe error errorHandler!({ code: 'ECONNRESET', message: 'Connection reset' }); expect(closeSpy).toHaveBeenCalledWith('stdin error'); }); it('should NOT close persistent query on EPIPE error', () => { const closeSpy = jest.spyOn(service, 'closePersistentQuery'); (service as any).shuttingDown = false; let errorHandler: (error: any) => void; const mockQuery = { transport: { processStdin: { on: jest.fn((event: string, handler: any) => { if (event === 'error') errorHandler = handler; }), once: jest.fn(), removeListener: jest.fn(), }, }, }; (service as any).attachPersistentQueryStdinErrorHandler(mockQuery); // Trigger EPIPE error errorHandler!({ code: 'EPIPE' }); expect(closeSpy).not.toHaveBeenCalled(); }); it('should NOT close persistent query when shutting down', () => { const closeSpy = jest.spyOn(service, 'closePersistentQuery'); (service as any).shuttingDown = true; let errorHandler: (error: any) => void; const mockQuery = { transport: { processStdin: { on: jest.fn((event: string, handler: any) => { if (event === 'error') errorHandler = handler; }), once: jest.fn(), removeListener: jest.fn(), }, }, }; (service as any).attachPersistentQueryStdinErrorHandler(mockQuery); errorHandler!({ code: 'ECONNRESET' }); expect(closeSpy).not.toHaveBeenCalled(); }); it('should remove error handler on close', () => { const removeListenerMock = jest.fn(); let closeHandler: () => void; const mockQuery = { transport: { processStdin: { on: jest.fn(), once: jest.fn((_event: string, handler: any) => { closeHandler = handler; }), removeListener: removeListenerMock, }, }, }; (service as any).attachPersistentQueryStdinErrorHandler(mockQuery); // Trigger close closeHandler!(); expect(removeListenerMock).toHaveBeenCalledWith('error', expect.any(Function)); }); }); describe('query() - missing node error', () => { beforeEach(() => { sdkMock.resetMockMessages(); }); afterEach(() => { sdkMock.resetMockMessages(); jest.restoreAllMocks(); }); it('should yield error when Node.js is missing', async () => { jest.spyOn(envUtils, 'getMissingNodeError').mockReturnValueOnce( 'Claude Code CLI requires Node.js, but Node was not found' ); const chunks = await collectChunks(service.query('hello')); const errorChunks = chunks.filter(c => c.type === 'error'); expect(errorChunks).toHaveLength(1); expect(errorChunks[0].content).toContain('Node.js'); }); }); describe('query() - interrupted flag and history rebuild', () => { beforeEach(() => { sdkMock.resetMockMessages(); }); afterEach(() => { sdkMock.resetMockMessages(); }); it('should clear interrupted flag before query', async () => { sdkMock.setMockMessages([ { type: 'system', subtype: 'init', session_id: 'session-1' }, { type: 'assistant', message: { content: [{ type: 'text', text: 'OK' }] } }, ]); // Set interrupted state (service as any).sessionManager.markInterrupted(); expect((service as any).sessionManager.wasInterrupted()).toBe(true); await collectChunks(service.query('hello')); expect((service as any).sessionManager.wasInterrupted()).toBe(false); }); it('should rebuild history on session mismatch', async () => { // Use same session_id as the one we set to avoid captureSession re-setting the flag sdkMock.setMockMessages([ { type: 'system', subtype: 'init', session_id: 'old-session' }, { type: 'assistant', message: { content: [{ type: 'text', text: 'OK' }] } }, ]); // Set up session mismatch state: capture a session, then directly set the flag service.setSessionId('old-session'); (service as any).sessionManager.state.needsHistoryRebuild = true; const history: any[] = [ { id: '1', role: 'user', content: 'Previous question', timestamp: 1000 }, { id: '2', role: 'assistant', content: 'Previous answer', timestamp: 1001 }, ]; // Spy on buildPromptWithHistoryContext to verify it's called const buildSpy = jest.spyOn(sessionUtils, 'buildPromptWithHistoryContext'); const chunks = await collectChunks(service.query('follow up', undefined, history)); // Should complete successfully const doneChunks = chunks.filter(c => c.type === 'done'); expect(doneChunks).toHaveLength(1); // History rebuild function should have been called expect(buildSpy).toHaveBeenCalled(); }); }); describe('query() - session expired retry (cold-start path)', () => { beforeEach(() => { sdkMock.resetMockMessages(); }); afterEach(() => { sdkMock.resetMockMessages(); jest.restoreAllMocks(); }); it('should retry with history on session expired error in cold-start', async () => { // First call throws session expired, second succeeds let callCount = 0; const originalQuery = sdkMock.query; jest.spyOn(sdkModule, 'query' as any).mockImplementation((...args: any[]) => { callCount++; if (callCount === 1) { // First call: throw session expired error // eslint-disable-next-line require-yield const gen = (async function* () { throw new Error('session expired'); })() as any; gen.interrupt = jest.fn(); gen.setModel = jest.fn(); gen.setMaxThinkingTokens = jest.fn(); gen.setPermissionMode = jest.fn(); gen.setMcpServers = jest.fn(); return gen; } // Second call: succeed with retry return originalQuery.call(null, ...args); }); service.setSessionId('old-session'); const history: any[] = [ { id: '1', role: 'user', content: 'Previous', timestamp: 1000 }, { id: '2', role: 'assistant', content: 'Answer', timestamp: 1001 }, ]; sdkMock.setMockMessages([ { type: 'system', subtype: 'init', session_id: 'retry-session' }, { type: 'assistant', message: { content: [{ type: 'text', text: 'Retried OK' }] } }, ]); const chunks = await collectChunks( service.query('follow up', undefined, history, { forceColdStart: true }) ); // Should have retried and yielded chunks const doneChunks = chunks.filter(c => c.type === 'done'); expect(doneChunks).toHaveLength(1); expect(callCount).toBeGreaterThanOrEqual(2); }); it('should yield error when session expired retry also fails', async () => { jest.spyOn(sdkModule, 'query' as any).mockImplementation(() => { // eslint-disable-next-line require-yield const gen = (async function* () { throw new Error('session expired'); })() as any; gen.interrupt = jest.fn(); gen.setModel = jest.fn(); gen.setMaxThinkingTokens = jest.fn(); gen.setPermissionMode = jest.fn(); gen.setMcpServers = jest.fn(); return gen; }); service.setSessionId('old-session'); const history: any[] = [ { id: '1', role: 'user', content: 'Previous', timestamp: 1000 }, ]; const chunks = await collectChunks( service.query('follow up', undefined, history, { forceColdStart: true }) ); const errorChunks = chunks.filter(c => c.type === 'error'); expect(errorChunks).toHaveLength(1); expect(errorChunks[0].content).toContain('session expired'); }); }); describe('applyDynamicUpdates - cliPath null', () => { it('should return early when cliPath is null', async () => { (service as any).persistentQuery = { setModel: jest.fn() }; (service as any).vaultPath = '/vault'; (mockPlugin.getResolvedClaudeCliPath as jest.Mock).mockReturnValue(null); const setModelSpy = (service as any).persistentQuery.setModel; await (service as any).applyDynamicUpdates({}); expect(setModelSpy).not.toHaveBeenCalled(); }); }); describe('applyDynamicUpdates - restart needed', () => { it('should restart and re-apply when config changes require restart', async () => { sdkMock.resetMockMessages(); sdkMock.setMockMessages([ { type: 'system', subtype: 'init', session_id: 'restart-session' }, ]); // Set up mock persistent query const mockPQ = { interrupt: jest.fn().mockResolvedValue(undefined), setModel: jest.fn().mockResolvedValue(undefined), setMaxThinkingTokens: jest.fn().mockResolvedValue(undefined), setPermissionMode: jest.fn().mockResolvedValue(undefined), setMcpServers: jest.fn().mockResolvedValue({ added: [], removed: [], errors: {} }), }; (service as any).persistentQuery = mockPQ; (service as any).vaultPath = '/mock/vault/path'; (service as any).messageChannel = { close: jest.fn() }; (service as any).queryAbortController = { abort: jest.fn() }; (service as any).currentConfig = { model: 'claude-3-5-sonnet', thinkingTokens: null, permissionMode: 'ask', systemPromptKey: '', disallowedToolsKey: '', mcpServersKey: '{}', pluginsKey: '', externalContextPaths: [], allowedExportPaths: [], settingSources: '', claudeCliPath: '/usr/local/bin/claude', enableChrome: false, }; // Change CLI path to trigger restart (mockPlugin.getResolvedClaudeCliPath as jest.Mock).mockReturnValue('/new/path/to/claude'); // Mock ensureReady to return true (restarted) const ensureReadySpy = jest.spyOn(service, 'ensureReady').mockResolvedValue(true); await (service as any).applyDynamicUpdates({}); expect(ensureReadySpy).toHaveBeenCalledWith( expect.objectContaining({ force: true }) ); }); }); describe('routeMessage - agents event', () => { it('should set builtin agent names from init event', async () => { const mockAgentManager = { setBuiltinAgentNames: jest.fn() }; (mockPlugin as any).agentManager = mockAgentManager; const onChunk = jest.fn(); const handler = createResponseHandler({ id: 'agents-test', onChunk, onDone: jest.fn(), onError: jest.fn(), }); (service as any).responseHandlers = [handler]; (service as any).messageChannel = { onTurnComplete: jest.fn(), setSessionId: jest.fn(), }; // Send a system init message with agents const message = { type: 'system', subtype: 'init', session_id: 'test-session', agents: ['agent1', 'agent2'], }; await (service as any).routeMessage(message); expect(mockAgentManager.setBuiltinAgentNames).toHaveBeenCalledWith(['agent1', 'agent2']); }); it('should not throw when agentManager.setBuiltinAgentNames fails', async () => { const mockAgentManager = { setBuiltinAgentNames: jest.fn().mockImplementation(() => { throw new Error('agent error'); }), }; (mockPlugin as any).agentManager = mockAgentManager; const handler = createResponseHandler({ id: 'agents-error-test', onChunk: jest.fn(), onDone: jest.fn(), onError: jest.fn(), }); (service as any).responseHandlers = [handler]; (service as any).messageChannel = { onTurnComplete: jest.fn(), setSessionId: jest.fn(), }; const message = { type: 'system', subtype: 'init', session_id: 'test-session', agents: ['agent1'], }; // Should not throw await expect((service as any).routeMessage(message)).resolves.toBeUndefined(); }); }); describe('routeMessage - usage chunk with sessionId', () => { it('should attach sessionId to usage chunks from assistant messages', async () => { service.setSessionId('usage-session-id'); const onChunk = jest.fn(); const handler = createResponseHandler({ id: 'usage-test', onChunk, onDone: jest.fn(), onError: jest.fn(), }); (service as any).responseHandlers = [handler]; (service as any).messageChannel = { onTurnComplete: jest.fn(), setSessionId: jest.fn(), }; // Usage is extracted from assistant messages (not result messages) const message = { type: 'assistant', message: { content: [{ type: 'text', text: 'Response' }], usage: { input_tokens: 100, output_tokens: 50, cache_creation_input_tokens: 10, cache_read_input_tokens: 20, }, }, }; await (service as any).routeMessage(message); const usageChunks = onChunk.mock.calls .map(([chunk]: any) => chunk) .filter((c: any) => c.type === 'usage'); expect(usageChunks.length).toBeGreaterThan(0); expect(usageChunks[0].sessionId).toBe('usage-session-id'); }); }); describe('queryViaPersistent - edge cases', () => { it('should fall back to cold-start when persistent query is null', async () => { sdkMock.resetMockMessages(); sdkMock.setMockMessages([ { type: 'system', subtype: 'init', session_id: 'fallback-session' }, { type: 'assistant', message: { content: [{ type: 'text', text: 'Fallback' }] } }, ]); // No persistent query set (service as any).persistentQuery = null; (service as any).messageChannel = null; const chunks: any[] = []; for await (const chunk of (service as any).queryViaPersistent( 'test', undefined, '/mock/vault/path', '/usr/local/bin/claude' )) { chunks.push(chunk); } const doneChunks = chunks.filter(c => c.type === 'done'); expect(doneChunks).toHaveLength(1); }); it('should set allowedTools from query options', async () => { sdkMock.resetMockMessages(); sdkMock.setMockMessages([ { type: 'system', subtype: 'init', session_id: 'allowed-tools-session' }, { type: 'assistant', message: { content: [{ type: 'text', text: 'OK' }] } }, ]); // Set up persistent query const mockPQ = { interrupt: jest.fn().mockResolvedValue(undefined), setModel: jest.fn().mockResolvedValue(undefined), setMaxThinkingTokens: jest.fn().mockResolvedValue(undefined), setPermissionMode: jest.fn().mockResolvedValue(undefined), setMcpServers: jest.fn().mockResolvedValue({ added: [], removed: [], errors: {} }), }; (service as any).persistentQuery = mockPQ; const mockChannel = new MessageChannel(); (service as any).messageChannel = mockChannel; (service as any).responseConsumerRunning = true; (service as any).vaultPath = '/mock/vault/path'; (service as any).currentConfig = { model: 'claude-3-5-sonnet', thinkingTokens: null, permissionMode: 'ask', systemPromptKey: '', disallowedToolsKey: '', mcpServersKey: '{}', pluginsKey: '', externalContextPaths: [], allowedExportPaths: [], settingSources: '', claudeCliPath: '/usr/local/bin/claude', enableChrome: false, }; // Set up handler to resolve immediately const gen = (service as any).queryViaPersistent( 'test', undefined, '/mock/vault/path', '/usr/local/bin/claude', { allowedTools: ['Read', 'Glob'] } ); // The generator will hang waiting for handler.onDone, so we need to // trigger it via the response handler const iterPromise = gen.next(); // Wait a tick for the handler to be registered await new Promise(resolve => setTimeout(resolve, 10)); // Find and trigger the handler const handlers = (service as any).responseHandlers; if (handlers.length > 0) { handlers[0].onChunk({ type: 'text', content: 'Hi' }); handlers[0].onDone(); } await iterPromise; // allowedTools should include the specified tools + Skill expect((service as any).currentAllowedTools).toEqual(['Read', 'Glob', 'Skill']); // Drain the generator let next = await gen.next(); while (!next.done) { next = await gen.next(); } }); it('should fall back to cold-start when consumer is not running', async () => { sdkMock.resetMockMessages(); sdkMock.setMockMessages([ { type: 'system', subtype: 'init', session_id: 'consumer-fallback' }, { type: 'assistant', message: { content: [{ type: 'text', text: 'Fallback' }] } }, ]); // Persistent query exists but consumer is not running (service as any).persistentQuery = { interrupt: jest.fn().mockResolvedValue(undefined), setModel: jest.fn().mockResolvedValue(undefined), setMaxThinkingTokens: jest.fn().mockResolvedValue(undefined), setPermissionMode: jest.fn().mockResolvedValue(undefined), setMcpServers: jest.fn().mockResolvedValue({ added: [], removed: [], errors: {} }), }; (service as any).messageChannel = new MessageChannel(); (service as any).responseConsumerRunning = false; (service as any).vaultPath = '/mock/vault/path'; (service as any).currentConfig = { model: 'claude-3-5-sonnet', thinkingTokens: null, permissionMode: 'ask', systemPromptKey: '', disallowedToolsKey: '', mcpServersKey: '{}', pluginsKey: '', externalContextPaths: [], allowedExportPaths: [], settingSources: '', claudeCliPath: '/usr/local/bin/claude', enableChrome: false, }; const chunks: any[] = []; for await (const chunk of (service as any).queryViaPersistent( 'test', undefined, '/mock/vault/path', '/usr/local/bin/claude' )) { chunks.push(chunk); } const doneChunks = chunks.filter(c => c.type === 'done'); expect(doneChunks).toHaveLength(1); }); it('should fall back when persistent query lost after applyDynamicUpdates', async () => { sdkMock.resetMockMessages(); sdkMock.setMockMessages([ { type: 'system', subtype: 'init', session_id: 'lost-pq' }, { type: 'assistant', message: { content: [{ type: 'text', text: 'OK' }] } }, ]); // Set up persistent query that will be cleared by applyDynamicUpdates mock (service as any).persistentQuery = { interrupt: jest.fn().mockResolvedValue(undefined), setModel: jest.fn().mockResolvedValue(undefined), setMaxThinkingTokens: jest.fn().mockResolvedValue(undefined), setPermissionMode: jest.fn().mockResolvedValue(undefined), setMcpServers: jest.fn().mockResolvedValue({ added: [], removed: [], errors: {} }), }; (service as any).messageChannel = new MessageChannel(); (service as any).responseConsumerRunning = true; (service as any).vaultPath = '/mock/vault/path'; (service as any).currentConfig = { model: 'claude-3-5-sonnet', thinkingTokens: null, permissionMode: 'ask', systemPromptKey: '', disallowedToolsKey: '', mcpServersKey: '{}', pluginsKey: '', externalContextPaths: [], allowedExportPaths: [], settingSources: '', claudeCliPath: '/usr/local/bin/claude', enableChrome: false, }; // Mock applyDynamicUpdates to clear persistent query (simulating restart failure) jest.spyOn(service as any, 'applyDynamicUpdates').mockImplementation(async () => { (service as any).persistentQuery = null; (service as any).messageChannel = null; }); const chunks: any[] = []; for await (const chunk of (service as any).queryViaPersistent( 'test', undefined, '/mock/vault/path', '/usr/local/bin/claude' )) { chunks.push(chunk); } const doneChunks = chunks.filter(c => c.type === 'done'); expect(doneChunks).toHaveLength(1); }); it('should fall back when channel is closed during enqueue', async () => { sdkMock.resetMockMessages(); sdkMock.setMockMessages([ { type: 'system', subtype: 'init', session_id: 'closed-channel' }, { type: 'assistant', message: { content: [{ type: 'text', text: 'OK' }] } }, ]); const closedChannel = new MessageChannel(); closedChannel.close(); (service as any).persistentQuery = { interrupt: jest.fn().mockResolvedValue(undefined), setModel: jest.fn().mockResolvedValue(undefined), setMaxThinkingTokens: jest.fn().mockResolvedValue(undefined), setPermissionMode: jest.fn().mockResolvedValue(undefined), setMcpServers: jest.fn().mockResolvedValue({ added: [], removed: [], errors: {} }), }; (service as any).messageChannel = closedChannel; (service as any).responseConsumerRunning = true; (service as any).vaultPath = '/mock/vault/path'; (service as any).currentConfig = { model: 'claude-3-5-sonnet', thinkingTokens: null, permissionMode: 'ask', systemPromptKey: '', disallowedToolsKey: '', mcpServersKey: '{}', pluginsKey: '', externalContextPaths: [], allowedExportPaths: [], settingSources: '', claudeCliPath: '/usr/local/bin/claude', enableChrome: false, }; const chunks: any[] = []; for await (const chunk of (service as any).queryViaPersistent( 'test', undefined, '/mock/vault/path', '/usr/local/bin/claude' )) { chunks.push(chunk); } // Should fall back to cold-start and complete const doneChunks = chunks.filter(c => c.type === 'done'); expect(doneChunks).toHaveLength(1); }); it('should handle onError in handler and re-throw session expired', async () => { const mockPQ = { interrupt: jest.fn().mockResolvedValue(undefined), setModel: jest.fn().mockResolvedValue(undefined), setMaxThinkingTokens: jest.fn().mockResolvedValue(undefined), setPermissionMode: jest.fn().mockResolvedValue(undefined), setMcpServers: jest.fn().mockResolvedValue({ added: [], removed: [], errors: {} }), }; (service as any).persistentQuery = mockPQ; const mockChannel = new MessageChannel(); (service as any).messageChannel = mockChannel; (service as any).responseConsumerRunning = true; (service as any).vaultPath = '/mock/vault/path'; (service as any).currentConfig = { model: 'claude-3-5-sonnet', thinkingTokens: null, permissionMode: 'ask', systemPromptKey: '', disallowedToolsKey: '', mcpServersKey: '{}', pluginsKey: '', externalContextPaths: [], allowedExportPaths: [], settingSources: '', claudeCliPath: '/usr/local/bin/claude', enableChrome: false, }; // Mock applyDynamicUpdates to avoid side effects jest.spyOn(service as any, 'applyDynamicUpdates').mockResolvedValue(undefined); const gen = (service as any).queryViaPersistent( 'test', undefined, '/mock/vault/path', '/usr/local/bin/claude' ); // Consume the sdk_user_uuid chunk yielded before handler registration const firstChunk = await gen.next(); expect(firstChunk.value.type).toBe('sdk_user_uuid'); // Consume the sdk_user_sent chunk yielded after enqueue succeeds const sentChunk = await gen.next(); expect(sentChunk.value.type).toBe('sdk_user_sent'); const iterPromise = gen.next(); await new Promise(resolve => setTimeout(resolve, 10)); // Trigger onError with session expired const handlers = (service as any).responseHandlers; expect(handlers.length).toBeGreaterThan(0); handlers[0].onError(new Error('session expired')); // Session expired should be re-thrown by the generator // gen.next() will resolve with the error propagating through the generator await expect(iterPromise).rejects.toThrow('session expired'); }); it('should handle onError with non-session error', async () => { const mockPQ = { interrupt: jest.fn().mockResolvedValue(undefined), setModel: jest.fn().mockResolvedValue(undefined), setMaxThinkingTokens: jest.fn().mockResolvedValue(undefined), setPermissionMode: jest.fn().mockResolvedValue(undefined), setMcpServers: jest.fn().mockResolvedValue({ added: [], removed: [], errors: {} }), }; (service as any).persistentQuery = mockPQ; const mockChannel = new MessageChannel(); (service as any).messageChannel = mockChannel; (service as any).responseConsumerRunning = true; (service as any).vaultPath = '/mock/vault/path'; (service as any).currentConfig = { model: 'claude-3-5-sonnet', thinkingTokens: null, permissionMode: 'ask', systemPromptKey: '', disallowedToolsKey: '', mcpServersKey: '{}', pluginsKey: '', externalContextPaths: [], allowedExportPaths: [], settingSources: '', claudeCliPath: '/usr/local/bin/claude', enableChrome: false, }; // Mock applyDynamicUpdates to avoid side effects jest.spyOn(service as any, 'applyDynamicUpdates').mockResolvedValue(undefined); const gen = (service as any).queryViaPersistent( 'test', undefined, '/mock/vault/path', '/usr/local/bin/claude' ); // Consume the sdk_user_uuid chunk yielded before handler registration const uuidChunk = await gen.next(); expect(uuidChunk.value.type).toBe('sdk_user_uuid'); const chunks: any[] = []; // Consume the sdk_user_sent chunk yielded after enqueue succeeds const sentChunk = await gen.next(); expect(sentChunk.value.type).toBe('sdk_user_sent'); const iterPromise = gen.next(); await new Promise(resolve => setTimeout(resolve, 10)); // Trigger onError with regular error const handlers = (service as any).responseHandlers; expect(handlers.length).toBeGreaterThan(0); handlers[0].onError(new Error('Some internal error')); const first = await iterPromise; if (!first.done) { chunks.push(first.value); let next = await gen.next(); while (!next.done) { chunks.push(next.value); next = await gen.next(); } } const errorChunks = chunks.filter(c => c.type === 'error'); expect(errorChunks).toHaveLength(1); expect(errorChunks[0].content).toContain('Some internal error'); }); it('should yield buffered chunks from state.chunks', async () => { const mockPQ = { interrupt: jest.fn().mockResolvedValue(undefined), setModel: jest.fn().mockResolvedValue(undefined), setMaxThinkingTokens: jest.fn().mockResolvedValue(undefined), setPermissionMode: jest.fn().mockResolvedValue(undefined), setMcpServers: jest.fn().mockResolvedValue({ added: [], removed: [], errors: {} }), }; (service as any).persistentQuery = mockPQ; const mockChannel = new MessageChannel(); (service as any).messageChannel = mockChannel; (service as any).responseConsumerRunning = true; (service as any).vaultPath = '/mock/vault/path'; (service as any).currentConfig = { model: 'claude-3-5-sonnet', thinkingTokens: null, permissionMode: 'ask', systemPromptKey: '', disallowedToolsKey: '', mcpServersKey: '{}', pluginsKey: '', externalContextPaths: [], allowedExportPaths: [], settingSources: '', claudeCliPath: '/usr/local/bin/claude', enableChrome: false, }; // Mock applyDynamicUpdates to avoid side effects jest.spyOn(service as any, 'applyDynamicUpdates').mockResolvedValue(undefined); const gen = (service as any).queryViaPersistent( 'test', undefined, '/mock/vault/path', '/usr/local/bin/claude' ); // Consume the sdk_user_uuid chunk yielded before handler registration const uuidChunk = await gen.next(); expect(uuidChunk.value.type).toBe('sdk_user_uuid'); // Consume the sdk_user_sent chunk yielded after enqueue succeeds const sentChunk = await gen.next(); expect(sentChunk.value.type).toBe('sdk_user_sent'); const iterPromise = gen.next(); await new Promise(resolve => setTimeout(resolve, 10)); // Rapidly send multiple chunks then done const handlers = (service as any).responseHandlers; expect(handlers.length).toBeGreaterThan(0); handlers[0].onChunk({ type: 'text', content: 'First' }); handlers[0].onChunk({ type: 'text', content: 'Second' }); handlers[0].onDone(); const chunks: any[] = []; const first = await iterPromise; if (!first.done) { chunks.push(first.value); let next = await gen.next(); while (!next.done) { chunks.push(next.value); next = await gen.next(); } } const textChunks = chunks.filter(c => c.type === 'text'); expect(textChunks.length).toBe(2); expect(textChunks[0].content).toBe('First'); expect(textChunks[1].content).toBe('Second'); }); }); describe('query() - session expired retry from persistent path', () => { beforeEach(() => { sdkMock.resetMockMessages(); }); afterEach(() => { sdkMock.resetMockMessages(); jest.restoreAllMocks(); }); it('should retry via cold-start when persistent query yields session expired error', async () => { // Set up a session and history so retry can happen service.setSessionId('old-persistent-session'); const history: any[] = [ { id: '1', role: 'user', content: 'Previous question', timestamp: 1000 }, { id: '2', role: 'assistant', content: 'Previous answer', timestamp: 1001 }, ]; // Mock queryViaPersistent to throw session expired jest.spyOn(service as any, 'queryViaPersistent').mockImplementation( // eslint-disable-next-line require-yield async function* () { throw new Error('session expired'); } ); // Mock queryViaSDK to succeed on retry const queryViaSDKSpy = jest.spyOn(service as any, 'queryViaSDK').mockImplementation( async function* () { yield { type: 'text', content: 'Retried OK' }; yield { type: 'done' }; } ); // Need a persistent query to be "active" for shouldUsePersistent (service as any).persistentQuery = { interrupt: jest.fn().mockResolvedValue(undefined) }; (service as any).shuttingDown = false; const chunks = await collectChunks(service.query('follow up', undefined, history)); // Should have retried via SDK expect(queryViaSDKSpy).toHaveBeenCalled(); const textChunks = chunks.filter(c => c.type === 'text'); expect(textChunks[0].content).toBe('Retried OK'); }); it('should yield error when persistent session expired retry also fails', async () => { service.setSessionId('old-persistent-session'); const history: any[] = [ { id: '1', role: 'user', content: 'Previous question', timestamp: 1000 }, ]; jest.spyOn(service as any, 'queryViaPersistent').mockImplementation( // eslint-disable-next-line require-yield async function* () { throw new Error('session expired'); } ); jest.spyOn(service as any, 'queryViaSDK').mockImplementation( // eslint-disable-next-line require-yield async function* () { throw new Error('retry also failed'); } ); (service as any).persistentQuery = { interrupt: jest.fn().mockResolvedValue(undefined) }; (service as any).shuttingDown = false; const chunks = await collectChunks(service.query('follow up', undefined, history)); const errorChunks = chunks.filter(c => c.type === 'error'); expect(errorChunks).toHaveLength(1); expect(errorChunks[0].content).toContain('retry also failed'); }); it('should re-throw non-session-expired errors from persistent path', async () => { jest.spyOn(service as any, 'queryViaPersistent').mockImplementation( // eslint-disable-next-line require-yield async function* () { throw new Error('unexpected failure'); } ); (service as any).persistentQuery = { interrupt: jest.fn().mockResolvedValue(undefined) }; (service as any).shuttingDown = false; // query() should propagate the error (not catch it) await expect(async () => { await collectChunks(service.query('hello')); }).rejects.toThrow('unexpected failure'); }); it('should not retry session expired without conversation history', async () => { jest.spyOn(service as any, 'queryViaPersistent').mockImplementation( // eslint-disable-next-line require-yield async function* () { throw new Error('session expired'); } ); (service as any).persistentQuery = { interrupt: jest.fn().mockResolvedValue(undefined) }; (service as any).shuttingDown = false; // No history → should re-throw, not retry await expect(async () => { await collectChunks(service.query('hello')); }).rejects.toThrow('session expired'); }); }); describe('query() - non-session-expired cold-start error', () => { beforeEach(() => { sdkMock.resetMockMessages(); }); afterEach(() => { sdkMock.resetMockMessages(); jest.restoreAllMocks(); }); it('should yield error chunk for non-session-expired errors in cold-start path', async () => { jest.spyOn(sdkModule, 'query' as any).mockImplementation(() => { // eslint-disable-next-line require-yield const gen = (async function* () { throw new Error('connection timeout'); })() as any; gen.interrupt = jest.fn(); gen.setModel = jest.fn(); gen.setMaxThinkingTokens = jest.fn(); gen.setPermissionMode = jest.fn(); gen.setMcpServers = jest.fn(); return gen; }); const chunks = await collectChunks( service.query('hello', undefined, undefined, { forceColdStart: true }) ); const errorChunks = chunks.filter(c => c.type === 'error'); expect(errorChunks).toHaveLength(1); expect(errorChunks[0].content).toBe('connection timeout'); }); it('should handle non-Error thrown values in cold-start path', async () => { jest.spyOn(sdkModule, 'query' as any).mockImplementation(() => { // eslint-disable-next-line require-yield const gen = (async function* () { throw 'string error'; // eslint-disable-line no-throw-literal })() as any; gen.interrupt = jest.fn(); gen.setModel = jest.fn(); gen.setMaxThinkingTokens = jest.fn(); gen.setPermissionMode = jest.fn(); gen.setMcpServers = jest.fn(); return gen; }); const chunks = await collectChunks( service.query('hello', undefined, undefined, { forceColdStart: true }) ); const errorChunks = chunks.filter(c => c.type === 'error'); expect(errorChunks).toHaveLength(1); expect(errorChunks[0].content).toBe('Unknown error'); }); }); describe('queryViaSDK - abort signal handling', () => { beforeEach(() => { sdkMock.resetMockMessages(); }); afterEach(() => { sdkMock.resetMockMessages(); jest.restoreAllMocks(); }); it('should interrupt response when abort signal is triggered during iteration', async () => { const abortController = new AbortController(); (service as any).abortController = abortController; let interruptCalled = false; // Set up messages that allow us to abort mid-stream jest.spyOn(sdkModule, 'query' as any).mockImplementation(() => { const messages = [ { type: 'system', subtype: 'init', session_id: 'abort-session' }, { type: 'assistant', message: { content: [{ type: 'text', text: 'Hello' }] } }, // Third message won't be yielded because we abort after the second { type: 'assistant', message: { content: [{ type: 'text', text: 'World' }] } }, ]; let index = 0; const gen = { [Symbol.asyncIterator]() { return this; }, async next() { if (index >= messages.length) return { done: true, value: undefined }; const msg = messages[index++]; // Abort after yielding the second message if (index === 2) { abortController.abort(); } return { done: false, value: msg }; }, async return() { return { done: true, value: undefined }; }, interrupt: jest.fn().mockImplementation(async () => { interruptCalled = true; }), setModel: jest.fn(), setMaxThinkingTokens: jest.fn(), setPermissionMode: jest.fn(), setMcpServers: jest.fn(), }; return gen; }); const chunks: any[] = []; for await (const chunk of (service as any).queryViaSDK( 'hello', '/mock/vault/path', '/usr/local/bin/claude', undefined, { forceColdStart: true } )) { chunks.push(chunk); } // interrupt should have been called expect(interruptCalled).toBe(true); }); }); describe('startResponseConsumer - crash recovery', () => { it('should attempt crash recovery when error occurs before any chunks', async () => { // Set up persistent query that will throw on iteration const crashError = new Error('process crashed'); let iterationCount = 0; const mockPQ = { [Symbol.asyncIterator]() { return this; }, async next() { iterationCount++; if (iterationCount === 1) { throw crashError; } return { done: true, value: undefined }; }, async return() { return { done: true, value: undefined }; }, interrupt: jest.fn().mockResolvedValue(undefined), setModel: jest.fn().mockResolvedValue(undefined), setMaxThinkingTokens: jest.fn().mockResolvedValue(undefined), setPermissionMode: jest.fn().mockResolvedValue(undefined), setMcpServers: jest.fn().mockResolvedValue({ added: [], removed: [], errors: {} }), }; (service as any).persistentQuery = mockPQ; (service as any).messageChannel = { close: jest.fn(), enqueue: jest.fn(), onTurnComplete: jest.fn() }; (service as any).queryAbortController = { abort: jest.fn() }; (service as any).shuttingDown = false; (service as any).coldStartInProgress = false; (service as any).crashRecoveryAttempted = false; (service as any).responseConsumerRunning = false; // Set up a handler that hasn't seen any chunks (sawAnyChunk = false) const onError = jest.fn(); const handler = createResponseHandler({ id: 'crash-test', onChunk: jest.fn(), onDone: jest.fn(), onError, }); (service as any).responseHandlers = [handler]; // Set lastSentMessage for replay (service as any).lastSentMessage = { type: 'user', message: { role: 'user', content: 'test' }, parent_tool_use_id: null, session_id: 'test-session', }; // Mock ensureReady to succeed const ensureReadySpy = jest.spyOn(service, 'ensureReady').mockResolvedValue(true); // After ensureReady, messageChannel needs to exist jest.spyOn(service as any, 'applyDynamicUpdates').mockResolvedValue(undefined); (service as any).startResponseConsumer(); // Wait for async consumer to process await new Promise(resolve => setTimeout(resolve, 50)); expect(ensureReadySpy).toHaveBeenCalledWith( expect.objectContaining({ force: true, preserveHandlers: true }) ); }); it('should notify handler and restart when crash recovery already attempted', async () => { const crashError = new Error('process crashed again'); let iterationCount = 0; const mockPQ = { [Symbol.asyncIterator]() { return this; }, async next() { iterationCount++; if (iterationCount === 1) throw crashError; return { done: true, value: undefined }; }, async return() { return { done: true, value: undefined }; }, interrupt: jest.fn().mockResolvedValue(undefined), }; (service as any).persistentQuery = mockPQ; (service as any).messageChannel = { close: jest.fn() }; (service as any).queryAbortController = { abort: jest.fn() }; (service as any).shuttingDown = false; (service as any).coldStartInProgress = false; (service as any).crashRecoveryAttempted = true; // Already attempted (service as any).responseConsumerRunning = false; const onError = jest.fn(); const handler = createResponseHandler({ id: 'crash-test-2', onChunk: jest.fn(), onDone: jest.fn(), onError, }); // handler hasn't seen chunks (service as any).responseHandlers = [handler]; (service as any).lastSentMessage = { type: 'user', message: { role: 'user', content: 'test' }, parent_tool_use_id: null, session_id: 'test-session', }; // ensureReady should NOT be called for recovery (already attempted), // but should be called for restart-for-next-message jest.spyOn(service, 'ensureReady').mockResolvedValue(false); (service as any).startResponseConsumer(); await new Promise(resolve => setTimeout(resolve, 50)); // Handler should have been notified of error expect(onError).toHaveBeenCalledWith(crashError); }); it('should invalidate session when crash recovery restart fails with session expired', async () => { const crashError = new Error('process crashed'); let iterationCount = 0; const mockPQ = { [Symbol.asyncIterator]() { return this; }, async next() { iterationCount++; if (iterationCount === 1) throw crashError; return { done: true, value: undefined }; }, async return() { return { done: true, value: undefined }; }, interrupt: jest.fn().mockResolvedValue(undefined), }; (service as any).persistentQuery = mockPQ; (service as any).messageChannel = { close: jest.fn() }; (service as any).queryAbortController = { abort: jest.fn() }; (service as any).shuttingDown = false; (service as any).coldStartInProgress = false; (service as any).crashRecoveryAttempted = false; (service as any).responseConsumerRunning = false; const onError = jest.fn(); const handler = createResponseHandler({ id: 'session-expire-test', onChunk: jest.fn(), onDone: jest.fn(), onError, }); (service as any).responseHandlers = [handler]; (service as any).lastSentMessage = { type: 'user', message: { role: 'user', content: 'test' }, parent_tool_use_id: null, session_id: 'test-session', }; // Set session directly to avoid ensureReady side effects (service as any).sessionManager.setSessionId('my-session', 'claude-3-5-sonnet'); // ensureReady fails with session expired during crash recovery jest.spyOn(service, 'ensureReady').mockRejectedValue(new Error('session expired')); (service as any).startResponseConsumer(); await new Promise(resolve => setTimeout(resolve, 50)); // Session should be invalidated expect(service.consumeSessionInvalidation()).toBe(true); // Handler should be notified of the original error expect(onError).toHaveBeenCalledWith(crashError); }); it('should skip error handling when consumer is orphaned (replaced)', async () => { const crashError = new Error('old consumer error'); let resolveDelay: () => void; const delayPromise = new Promise<void>(resolve => { resolveDelay = resolve; }); const oldMockPQ = { [Symbol.asyncIterator]() { return this; }, async next() { // Wait for the swap to happen before throwing await delayPromise; throw crashError; }, async return() { return { done: true, value: undefined }; }, interrupt: jest.fn().mockResolvedValue(undefined), }; // This PQ is the "old" one that the consumer will iterate (service as any).persistentQuery = oldMockPQ; (service as any).messageChannel = { close: jest.fn() }; (service as any).queryAbortController = { abort: jest.fn() }; (service as any).shuttingDown = false; (service as any).coldStartInProgress = false; (service as any).responseConsumerRunning = false; const onError = jest.fn(); const handler = createResponseHandler({ id: 'orphan-test', onChunk: jest.fn(), onDone: jest.fn(), onError, }); (service as any).responseHandlers = [handler]; (service as any).startResponseConsumer(); // Wait for consumer to start its iteration (awaiting the delay) await new Promise(resolve => setTimeout(resolve, 10)); // Swap to a new PQ before the error fires (service as any).persistentQuery = { interrupt: jest.fn() }; // Now let the old PQ throw resolveDelay!(); await new Promise(resolve => setTimeout(resolve, 50)); // The orphaned consumer should NOT call onError expect(onError).not.toHaveBeenCalled(); }); }); describe('buildHooks - null vaultPath', () => { it('should allow file access when vaultPath is null', async () => { (service as any).vaultPath = null; const hooks = (service as any).buildHooks(); const vaultRestrictionHook = hooks.PreToolUse[1]; // When vaultPath is null, getPathAccessType returns 'vault' for all paths, // so the hook should allow access const result = await vaultRestrictionHook.hooks[0]({ tool_name: 'Read', tool_input: { file_path: '/some/external/path' }, }); expect(result.continue).toBe(true); }); }); describe('buildHooks - external access', () => { it('should omit vault restriction hook when external access is enabled', () => { (mockPlugin.settings as any).allowExternalAccess = true; const hooks = (service as any).buildHooks(); expect(hooks.PreToolUse).toHaveLength(1); expect(hooks.PreToolUse[0].matcher).toBe('Bash'); }); }); describe('queryViaSDK - stream text dedup and allowedTools', () => { beforeEach(() => { sdkMock.resetMockMessages(); }); afterEach(() => { sdkMock.resetMockMessages(); jest.restoreAllMocks(); }); it('should set allowedTools in cold-start query', async () => { sdkMock.setMockMessages([ { type: 'system', subtype: 'init', session_id: 'allowed-cs' }, { type: 'assistant', message: { content: [{ type: 'text', text: 'Hi' }] } }, ]); const chunks = await collectChunks( service.query('hello', undefined, undefined, { forceColdStart: true, allowedTools: ['Read', 'Write'], }) ); expect(chunks.some(c => c.type === 'done')).toBe(true); }); it('should handle stream text events and skip duplicate assistant text', async () => { sdkMock.setMockMessages([ { type: 'system', subtype: 'init', session_id: 'stream-dedup' }, { type: 'stream_event', event: { type: 'content_block_start', content_block: { type: 'text' } } }, { type: 'stream_event', event: { type: 'content_block_delta', delta: { type: 'text_delta', text: 'Hello' } } }, { type: 'assistant', message: { content: [{ type: 'text', text: 'Hello' }] } }, ], { appendResult: true }); const chunks = await collectChunks( service.query('hello', undefined, undefined, { forceColdStart: true }) ); // Stream text was seen, so duplicate text from assistant message should be skipped // Verify query completed successfully expect(chunks.some(c => c.type === 'done')).toBe(true); }); it('should yield usage chunks with sessionId', async () => { service.setSessionId('usage-cold-session'); sdkMock.setMockMessages([ { type: 'system', subtype: 'init', session_id: 'usage-cold-session' }, { type: 'assistant', message: { content: [{ type: 'text', text: 'Hi' }], usage: { input_tokens: 100, output_tokens: 50, cache_creation_input_tokens: 10, cache_read_input_tokens: 20, }, }, }, ], { appendResult: true }); const chunks = await collectChunks( service.query('hello', undefined, undefined, { forceColdStart: true }) ); const usageChunks = chunks.filter(c => c.type === 'usage'); expect(usageChunks.length).toBeGreaterThan(0); expect(usageChunks[0].sessionId).toBe('usage-cold-session'); expect(chunks.some(c => c.type === 'done')).toBe(true); }); }); describe('rewindFiles', () => { it('throws when no persistentQuery', async () => { (service as any).persistentQuery = null; await expect(service.rewindFiles('uuid')).rejects.toThrow('No active query'); }); it('throws when shuttingDown', async () => { (service as any).persistentQuery = { rewindFiles: jest.fn() }; (service as any).shuttingDown = true; await expect(service.rewindFiles('uuid')).rejects.toThrow('Service is shutting down'); (service as any).shuttingDown = false; }); it('calls persistentQuery.rewindFiles with correct args', async () => { const mockRewindFiles = jest.fn().mockResolvedValue({ canRewind: true, filesChanged: ['a.txt'] }); (service as any).persistentQuery = { rewindFiles: mockRewindFiles }; (service as any).shuttingDown = false; const result = await service.rewindFiles('test-uuid', true); expect(mockRewindFiles).toHaveBeenCalledWith('test-uuid', { dryRun: true }); expect(result).toEqual({ canRewind: true, filesChanged: ['a.txt'] }); }); }); describe('rewind', () => { it('dry-runs first to capture filesChanged, then performs actual rewind', async () => { // SDK only returns filesChanged on dry run, not on actual rewind const mockRewindFiles = jest.fn() .mockResolvedValueOnce({ canRewind: true, filesChanged: ['a.txt'], insertions: 5, deletions: 3 }) .mockResolvedValueOnce({ canRewind: true }); const mockInterrupt = jest.fn().mockResolvedValue(undefined); (service as any).persistentQuery = { rewindFiles: mockRewindFiles, interrupt: mockInterrupt }; (service as any).messageChannel = { close: jest.fn() }; (service as any).queryAbortController = { abort: jest.fn() }; (service as any).shuttingDown = false; const result = await service.rewind('user-uuid', 'assistant-uuid'); expect(mockRewindFiles).toHaveBeenCalledTimes(2); expect(mockRewindFiles).toHaveBeenNthCalledWith(1, 'user-uuid', { dryRun: true }); expect(mockRewindFiles).toHaveBeenNthCalledWith(2, 'user-uuid', { dryRun: undefined }); expect(result.canRewind).toBe(true); expect(result.filesChanged).toEqual(['a.txt']); expect(result.insertions).toBe(5); expect(result.deletions).toBe(3); expect((service as any).pendingResumeAt).toBe('assistant-uuid'); expect((service as any).persistentQuery).toBeNull(); }); it('returns error without closing query when dry-run canRewind is false', async () => { const mockRewindFiles = jest.fn().mockResolvedValue({ canRewind: false, error: 'No checkpoint' }); (service as any).persistentQuery = { rewindFiles: mockRewindFiles }; (service as any).shuttingDown = false; const result = await service.rewind('user-uuid', 'assistant-uuid'); expect(result.canRewind).toBe(false); expect(result.error).toBe('No checkpoint'); // Only dry run should have been called expect(mockRewindFiles).toHaveBeenCalledTimes(1); // Query should NOT be closed expect((service as any).persistentQuery).not.toBeNull(); }); it('closes the query when actual rewind canRewind is false', async () => { const mockRewindFiles = jest.fn() .mockResolvedValueOnce({ canRewind: true, filesChanged: ['a.txt'] }) .mockResolvedValueOnce({ canRewind: false, error: 'Unexpected error' }); const mockInterrupt = jest.fn().mockResolvedValue(undefined); (service as any).persistentQuery = { rewindFiles: mockRewindFiles, interrupt: mockInterrupt }; (service as any).messageChannel = { close: jest.fn() }; (service as any).queryAbortController = { abort: jest.fn() }; (service as any).shuttingDown = false; const result = await service.rewind('user-uuid', 'assistant-uuid'); expect(result.canRewind).toBe(false); expect(result.error).toBe('Unexpected error'); expect((service as any).pendingResumeAt).toBeUndefined(); expect((service as any).persistentQuery).toBeNull(); }); }); describe('buildSDKUserMessage uuid', () => { it('assigns a uuid to text-only messages', () => { const message = (service as any).buildSDKUserMessage('Hello'); expect(message.uuid).toBeDefined(); expect(typeof message.uuid).toBe('string'); expect(message.uuid.length).toBeGreaterThan(0); }); it('assigns a uuid to image messages', () => { const images = [{ id: 'img1', name: 'test.png', mediaType: 'image/png', data: 'b64', size: 10, source: 'file' }]; const message = (service as any).buildSDKUserMessage('Look', images); expect(message.uuid).toBeDefined(); expect(typeof message.uuid).toBe('string'); }); it('assigns unique uuids to different messages', () => { const msg1 = (service as any).buildSDKUserMessage('Hello'); const msg2 = (service as any).buildSDKUserMessage('World'); expect(msg1.uuid).not.toBe(msg2.uuid); }); }); describe('applyForkState', () => { it('sets pendingForkSession and pendingResumeAt when conversation has forkSource but no sessionId', () => { const conv = { sessionId: null as string | null, forkSource: { sessionId: 'source-session', resumeAt: 'asst-uuid-123' }, }; const result = service.applyForkState(conv); expect(result).toBe('source-session'); expect((service as any).pendingForkSession).toBe(true); expect((service as any).pendingResumeAt).toBe('asst-uuid-123'); }); it('does not set pendingForkSession when conversation has its own sessionId', () => { const conv = { sessionId: 'own-session', forkSource: { sessionId: 'source-session', resumeAt: 'asst-uuid-123' }, }; const result = service.applyForkState(conv); expect(result).toBe('own-session'); expect((service as any).pendingForkSession).toBe(false); expect((service as any).pendingResumeAt).toBeUndefined(); }); it('returns null when no sessionId and no forkSource', () => { const conv = { sessionId: null as string | null }; const result = service.applyForkState(conv); expect(result).toBeNull(); expect((service as any).pendingForkSession).toBe(false); }); it('returns sessionId when only sessionId is present (no forkSource)', () => { const conv = { sessionId: 'existing-session' }; const result = service.applyForkState(conv); expect(result).toBe('existing-session'); expect((service as any).pendingForkSession).toBe(false); }); it('clears pendingForkSession and pendingResumeAt from previous call', () => { // First call: set fork state service.applyForkState({ sessionId: null, forkSource: { sessionId: 'source-1', resumeAt: 'asst-1' }, }); expect((service as any).pendingForkSession).toBe(true); expect((service as any).pendingResumeAt).toBe('asst-1'); // Second call: conversation has own sessionId, should clear fork state service.applyForkState({ sessionId: 'own-session', forkSource: { sessionId: 'source-1', resumeAt: 'asst-1' }, }); expect((service as any).pendingForkSession).toBe(false); expect((service as any).pendingResumeAt).toBeUndefined(); }); it('clears pendingResumeAt when switching to non-fork conversation', () => { // Set fork state service.applyForkState({ sessionId: null, forkSource: { sessionId: 'source-1', resumeAt: 'asst-1' }, }); expect((service as any).pendingResumeAt).toBe('asst-1'); // Switch to a normal conversation (no forkSource) service.applyForkState({ sessionId: 'normal-session' }); expect((service as any).pendingResumeAt).toBeUndefined(); }); it('treats conversation as not pending when sdkSessionId is set', () => { const conv = { sessionId: null as string | null, sdkSessionId: 'sdk-session-xyz', forkSource: { sessionId: 'source-session', resumeAt: 'asst-uuid-123' }, }; const result = service.applyForkState(conv); // Returns forkSource.sessionId via the ?? chain, but does NOT set pending fork state expect(result).toBe('source-session'); expect((service as any).pendingForkSession).toBe(false); expect((service as any).pendingResumeAt).toBeUndefined(); }); }); }); ================================================ FILE: tests/unit/core/agent/MessageChannel.test.ts ================================================ import type { SDKUserMessage } from '@anthropic-ai/claude-agent-sdk'; import { MessageChannel } from '@/core/agent/MessageChannel'; // Helper to create SDK-format text user message function createTextUserMessage(content: string): SDKUserMessage { return { type: 'user', message: { role: 'user', content, }, parent_tool_use_id: null, session_id: '', }; } // Helper to create SDK-format image user message function createImageUserMessage(data = 'image-data'): SDKUserMessage { return { type: 'user', message: { role: 'user', content: [ { type: 'image', source: { type: 'base64', media_type: 'image/png', data, }, }, ], }, parent_tool_use_id: null, session_id: '', }; } describe('MessageChannel', () => { let channel: MessageChannel; let warnings: string[]; beforeEach(() => { warnings = []; channel = new MessageChannel((message) => warnings.push(message)); }); afterEach(() => { channel.close(); }); describe('basic operations', () => { it('should initially not be closed', () => { expect(channel.isClosed()).toBe(false); }); it('should initially have no active turn', () => { expect(channel.isTurnActive()).toBe(false); }); it('should initially have empty queue', () => { expect(channel.getQueueLength()).toBe(0); }); }); describe('enqueue and iteration', () => { it('merges queued text messages and stamps the session ID', async () => { const iterator = channel[Symbol.asyncIterator](); const firstPromise = iterator.next(); channel.enqueue(createTextUserMessage('first')); const first = await firstPromise; expect(first.value.message.content).toBe('first'); channel.enqueue(createTextUserMessage('second')); channel.enqueue(createTextUserMessage('third')); channel.setSessionId('session-abc'); channel.onTurnComplete(); const merged = await iterator.next(); expect(merged.value.message.content).toBe('second\n\nthird'); expect(merged.value.session_id).toBe('session-abc'); expect(warnings).toHaveLength(0); }); it('defers attachment messages and keeps the latest one', async () => { const iterator = channel[Symbol.asyncIterator](); const firstPromise = iterator.next(); channel.enqueue(createTextUserMessage('first')); await firstPromise; const attachmentOne = createImageUserMessage('image-one'); const attachmentTwo = createImageUserMessage('image-two'); channel.enqueue(attachmentOne); channel.enqueue(attachmentTwo); channel.onTurnComplete(); const queued = await iterator.next(); expect(queued.value.message.content).toEqual(attachmentTwo.message.content); expect(warnings.some((msg) => msg.includes('Attachment message replaced'))).toBe(true); }); it('drops merged text when it exceeds the max length', async () => { const iterator = channel[Symbol.asyncIterator](); const firstPromise = iterator.next(); channel.enqueue(createTextUserMessage('first')); await firstPromise; const longText = 'x'.repeat(12000); channel.enqueue(createTextUserMessage('short')); channel.enqueue(createTextUserMessage(longText)); channel.onTurnComplete(); const merged = await iterator.next(); expect(merged.value.message.content).toBe('short'); expect(warnings.some((msg) => msg.includes('Merged content exceeds'))).toBe(true); }); it('delivers message when enqueue is called before next (no deadlock)', async () => { // Enqueue BEFORE calling next() - this used to cause a deadlock channel.enqueue(createTextUserMessage('early message')); // Now call next() - it should pick up the queued message const iterator = channel[Symbol.asyncIterator](); const result = await iterator.next(); expect(result.done).toBe(false); expect(result.value.message.content).toBe('early message'); }); it('handles multiple enqueues before first next (queued separately)', async () => { // Enqueue multiple messages before any next() call // When turnActive=false, messages queue separately (no merging) channel.enqueue(createTextUserMessage('first')); channel.enqueue(createTextUserMessage('second')); const iterator = channel[Symbol.asyncIterator](); // First next() gets first message, turns on turnActive const first = await iterator.next(); expect(first.done).toBe(false); expect(first.value.message.content).toBe('first'); // Complete turn so second message can be delivered channel.onTurnComplete(); // Second next() gets second message const second = await iterator.next(); expect(second.done).toBe(false); expect(second.value.message.content).toBe('second'); }); }); describe('error handling', () => { it('throws error when enqueueing to closed channel', () => { channel.close(); expect(() => channel.enqueue(createTextUserMessage('test'))).toThrow('MessageChannel is closed'); }); }); describe('queue overflow', () => { it('drops newest messages when queue is full before consumer starts', () => { // Queue many messages before starting iteration (turnActive=false) for (let i = 0; i < 10; i++) { channel.enqueue(createTextUserMessage(`msg-${i}`)); } // Queue full warning should be triggered expect(warnings.filter((msg) => msg.includes('Queue full'))).not.toHaveLength(0); // Verify the queue length is capped at MAX_QUEUED_MESSAGES (8) expect(channel.getQueueLength()).toBe(8); }); }); describe('close resolves pending consumer', () => { it('resolves pending next() with done:true when closed', async () => { const iterator = channel[Symbol.asyncIterator](); // Start waiting for a message (no message enqueued yet) const pendingPromise = iterator.next(); // Close the channel while consumer is waiting channel.close(); const result = await pendingPromise; expect(result.done).toBe(true); }); }); describe('queue overflow during active turn', () => { it('drops text when queue is full during active turn', async () => { const iterator = channel[Symbol.asyncIterator](); // Start a turn const firstPromise = iterator.next(); channel.enqueue(createTextUserMessage('first')); await firstPromise; // Fill queue during active turn - first text merges, then subsequent // ones also merge. But since merge limit is 10000 chars, we need to // fill the queue with non-text (attachment) + text to trigger overflow channel.enqueue(createTextUserMessage('queued-text')); // Enqueue attachments to fill remaining queue slots for (let i = 0; i < 8; i++) { channel.enqueue(createImageUserMessage(`img-${i}`)); } // The 8th attachment should trigger overflow (text=1 + attachment=1 = 2 slots, // but attachments replace each other, so text=1 + attachment=1 = 2 used. // Additional image messages just replace the existing attachment slot) // The queue should have text + attachment = 2 items expect(channel.getQueueLength()).toBe(2); }); }); describe('enqueue attachment before consumer starts (no active turn)', () => { it('queues attachment message when no turn is active and no consumer', () => { channel.enqueue(createImageUserMessage('early-img')); expect(channel.getQueueLength()).toBe(1); }); }); describe('onTurnComplete with queued messages and waiting consumer', () => { it('delivers queued message to waiting consumer on turn complete', async () => { const iterator = channel[Symbol.asyncIterator](); // Deliver first message to start a turn const firstPromise = iterator.next(); channel.enqueue(createTextUserMessage('turn-1')); await firstPromise; // Queue a message during active turn channel.enqueue(createTextUserMessage('turn-2')); // Start waiting for next message (consumer blocks) const secondPromise = iterator.next(); // Complete the turn - should deliver queued message to waiting consumer channel.onTurnComplete(); const result = await secondPromise; expect(result.done).toBe(false); expect(result.value.message.content).toBe('turn-2'); expect(channel.isTurnActive()).toBe(true); }); }); describe('text extraction from content blocks', () => { it('extracts text from mixed content blocks', async () => { const iterator = channel[Symbol.asyncIterator](); const mixedMessage: SDKUserMessage = { type: 'user', message: { role: 'user', content: [ { type: 'text', text: 'hello' }, { type: 'text', text: 'world' }, ], }, parent_tool_use_id: null, session_id: '', }; const firstPromise = iterator.next(); channel.enqueue(mixedMessage); const result = await firstPromise; // Text blocks should be joined with \n\n when no turn is active // (delivered directly to consumer) expect(result.value.message.content).toEqual(mixedMessage.message.content); }); it('handles empty content gracefully', async () => { const iterator = channel[Symbol.asyncIterator](); // Start a turn so messages get queued const firstPromise = iterator.next(); channel.enqueue(createTextUserMessage('first')); await firstPromise; // Enqueue a message with no content during active turn const emptyMessage: SDKUserMessage = { type: 'user', message: { role: 'user', content: '', }, parent_tool_use_id: null, session_id: '', }; channel.enqueue(emptyMessage); channel.onTurnComplete(); const result = await iterator.next(); expect(result.value.message.content).toBe(''); }); }); describe('close and reset', () => { it('should mark channel as closed', () => { channel.close(); expect(channel.isClosed()).toBe(true); }); it('should clear queue on close', () => { channel.enqueue(createTextUserMessage('test')); channel.close(); expect(channel.getQueueLength()).toBe(0); }); it('should reset channel state', () => { channel.enqueue(createTextUserMessage('test')); channel.reset(); expect(channel.getQueueLength()).toBe(0); expect(channel.isClosed()).toBe(false); expect(channel.isTurnActive()).toBe(false); }); it('should return done when iterating closed channel', async () => { channel.close(); const iterator = channel[Symbol.asyncIterator](); const result = await iterator.next(); expect(result.done).toBe(true); }); }); describe('extractTextContent with array content blocks', () => { it('should extract and merge text from array-format content during active turn', async () => { const ch = new MessageChannel(); const iterator = ch[Symbol.asyncIterator](); // Start a turn with a normal message ch.enqueue(createTextUserMessage('initial')); await iterator.next(); // consume → turn active // Enqueue a message with array content (text-only, no images) // This goes through extractTextContent → filter/map/join path const arrayContentMessage: SDKUserMessage = { type: 'user', message: { role: 'user', content: [ { type: 'text', text: 'Hello' }, { type: 'text', text: 'World' }, ], }, parent_tool_use_id: null, session_id: '', }; ch.enqueue(arrayContentMessage); // Complete turn so merged message is delivered ch.onTurnComplete(); const result = await iterator.next(); // Text blocks should be extracted and joined with \n\n expect(result.value.message.content).toBe('Hello\n\nWorld'); }); it('should filter out non-text blocks from array content', async () => { const ch = new MessageChannel(); const iterator = ch[Symbol.asyncIterator](); // Start a turn ch.enqueue(createTextUserMessage('initial')); await iterator.next(); // consume → turn active // Enqueue array content with mixed blocks but NO images (so treated as text) // Note: only blocks with type='text' should be extracted const mixedContentMessage: SDKUserMessage = { type: 'user', message: { role: 'user', content: [ { type: 'text', text: 'Visible' }, { type: 'tool_result', tool_use_id: 'x', content: 'hidden' } as any, { type: 'text', text: 'Also Visible' }, ], }, parent_tool_use_id: null, session_id: '', }; ch.enqueue(mixedContentMessage); ch.onTurnComplete(); const result = await iterator.next(); expect(result.value.message.content).toBe('Visible\n\nAlso Visible'); }); }); describe('turn management', () => { it('should track turn state correctly', async () => { expect(channel.isTurnActive()).toBe(false); const iterator = channel[Symbol.asyncIterator](); channel.enqueue(createTextUserMessage('test')); // Wait for message to be delivered const firstPromise = iterator.next(); const result = await firstPromise; expect(result.done).toBe(false); expect(channel.isTurnActive()).toBe(true); channel.onTurnComplete(); expect(channel.isTurnActive()).toBe(false); }); }); }); ================================================ FILE: tests/unit/core/agent/QueryOptionsBuilder.test.ts ================================================ import type { QueryOptionsContext } from '@/core/agent/QueryOptionsBuilder'; import { QueryOptionsBuilder } from '@/core/agent/QueryOptionsBuilder'; import type { PersistentQueryConfig } from '@/core/agent/types'; import type { ClaudianSettings } from '@/core/types'; // Create a mock MCP server manager function createMockMcpManager() { return { loadServers: jest.fn().mockResolvedValue(undefined), getServers: jest.fn().mockReturnValue([]), getEnabledCount: jest.fn().mockReturnValue(0), getActiveServers: jest.fn().mockReturnValue({}), getDisallowedMcpTools: jest.fn().mockReturnValue([]), getAllDisallowedMcpTools: jest.fn().mockReturnValue([]), hasServers: jest.fn().mockReturnValue(false), } as any; } // Create a mock plugin manager function createMockPluginManager() { return { setEnabledPluginIds: jest.fn(), loadPlugins: jest.fn().mockResolvedValue(undefined), getPlugins: jest.fn().mockReturnValue([]), getUnavailableEnabledPlugins: jest.fn().mockReturnValue([]), hasEnabledPlugins: jest.fn().mockReturnValue(false), getEnabledCount: jest.fn().mockReturnValue(0), getPluginsKey: jest.fn().mockReturnValue(''), togglePlugin: jest.fn().mockReturnValue([]), enablePlugin: jest.fn().mockReturnValue([]), disablePlugin: jest.fn().mockReturnValue([]), hasPlugins: jest.fn().mockReturnValue(false), } as any; } // Create a mock settings object function createMockSettings(overrides: Partial<ClaudianSettings> = {}): ClaudianSettings { return { enableBlocklist: true, blockedCommands: { unix: ['rm -rf'], windows: ['Remove-Item -Recurse -Force'], }, permissions: [], permissionMode: 'yolo', allowedExportPaths: [], loadUserClaudeSettings: false, mediaFolder: '', systemPrompt: '', model: 'claude-sonnet-4-5', thinkingBudget: 'off', titleGenerationModel: '', excludedTags: [], environmentVariables: '', envSnippets: [], slashCommands: [], keyboardNavigation: { scrollUpKey: 'k', scrollDownKey: 'j', focusInputKey: 'i', }, claudeCliPath: '', enableChrome: false, ...overrides, } as ClaudianSettings; } function createMockPersistentQueryConfig( overrides: Partial<PersistentQueryConfig> = {} ): PersistentQueryConfig { return { model: 'sonnet', thinkingTokens: null, effortLevel: null, permissionMode: 'yolo', systemPromptKey: 'key1', disallowedToolsKey: '', mcpServersKey: '', pluginsKey: '', externalContextPaths: [], allowedExportPaths: [], settingSources: 'project', claudeCliPath: '/mock/claude', enableChrome: false, ...overrides, }; } // Create a base context for tests function createMockContext(overrides: Partial<QueryOptionsContext> = {}): QueryOptionsContext { return { vaultPath: '/test/vault', cliPath: '/mock/claude', settings: createMockSettings(), customEnv: {}, enhancedPath: '/usr/bin:/mock/bin', mcpManager: createMockMcpManager(), pluginManager: createMockPluginManager(), ...overrides, }; } describe('QueryOptionsBuilder', () => { describe('needsRestart', () => { it('returns true when currentConfig is null', () => { const newConfig = createMockPersistentQueryConfig(); expect(QueryOptionsBuilder.needsRestart(null, newConfig)).toBe(true); }); it('returns false when configs are identical', () => { const config = createMockPersistentQueryConfig(); expect(QueryOptionsBuilder.needsRestart(config, { ...config })).toBe(false); }); it('returns true when systemPromptKey changes', () => { const currentConfig = createMockPersistentQueryConfig(); const newConfig = { ...currentConfig, systemPromptKey: 'key2' }; expect(QueryOptionsBuilder.needsRestart(currentConfig, newConfig)).toBe(true); }); it('returns true when disallowedToolsKey changes', () => { const currentConfig = createMockPersistentQueryConfig(); const newConfig = { ...currentConfig, disallowedToolsKey: 'tool1|tool2' }; expect(QueryOptionsBuilder.needsRestart(currentConfig, newConfig)).toBe(true); }); it('returns true when claudeCliPath changes', () => { const currentConfig = createMockPersistentQueryConfig(); const newConfig = { ...currentConfig, claudeCliPath: '/new/claude' }; expect(QueryOptionsBuilder.needsRestart(currentConfig, newConfig)).toBe(true); }); it('returns true when allowedExportPaths changes', () => { const currentConfig = createMockPersistentQueryConfig({ allowedExportPaths: ['/path/a'] }); const newConfig = { ...currentConfig, allowedExportPaths: ['/path/a', '/path/b'] }; expect(QueryOptionsBuilder.needsRestart(currentConfig, newConfig)).toBe(true); }); it('returns true when settingSources changes', () => { const currentConfig = createMockPersistentQueryConfig(); const newConfig = { ...currentConfig, settingSources: 'user,project' }; expect(QueryOptionsBuilder.needsRestart(currentConfig, newConfig)).toBe(true); }); it('returns true when pluginsKey changes', () => { const currentConfig = createMockPersistentQueryConfig(); const newConfig = { ...currentConfig, pluginsKey: 'plugin-a:/path/a|plugin-b:/path/b' }; expect(QueryOptionsBuilder.needsRestart(currentConfig, newConfig)).toBe(true); }); it('returns true when effortLevel changes', () => { const currentConfig = createMockPersistentQueryConfig({ effortLevel: 'high' }); const newConfig = { ...currentConfig, effortLevel: 'low' as const }; expect(QueryOptionsBuilder.needsRestart(currentConfig, newConfig)).toBe(true); }); it('returns false when only model changes (dynamic update)', () => { const currentConfig = createMockPersistentQueryConfig(); const newConfig = { ...currentConfig, model: 'claude-opus-4-5' }; expect(QueryOptionsBuilder.needsRestart(currentConfig, newConfig)).toBe(false); }); it('returns true when enableChrome changes from false to true', () => { const currentConfig = createMockPersistentQueryConfig(); const newConfig = { ...currentConfig, enableChrome: true }; expect(QueryOptionsBuilder.needsRestart(currentConfig, newConfig)).toBe(true); }); it('returns true when enableChrome changes from true to false', () => { const currentConfig = createMockPersistentQueryConfig({ enableChrome: true }); const newConfig = { ...currentConfig, enableChrome: false }; expect(QueryOptionsBuilder.needsRestart(currentConfig, newConfig)).toBe(true); }); it('returns true when externalContextPaths changes', () => { const currentConfig = createMockPersistentQueryConfig(); const newConfig = { ...currentConfig, externalContextPaths: ['/external/path'] }; expect(QueryOptionsBuilder.needsRestart(currentConfig, newConfig)).toBe(true); }); it('returns true when externalContextPaths is added', () => { const currentConfig = createMockPersistentQueryConfig({ externalContextPaths: ['/path/a'] }); const newConfig = { ...currentConfig, externalContextPaths: ['/path/a', '/path/b'] }; expect(QueryOptionsBuilder.needsRestart(currentConfig, newConfig)).toBe(true); }); it('returns true when externalContextPaths is removed', () => { const currentConfig = createMockPersistentQueryConfig({ externalContextPaths: ['/path/a', '/path/b'] }); const newConfig = { ...currentConfig, externalContextPaths: ['/path/a'] }; expect(QueryOptionsBuilder.needsRestart(currentConfig, newConfig)).toBe(true); }); it('returns false when externalContextPaths order changes (same content)', () => { const currentConfig = createMockPersistentQueryConfig({ externalContextPaths: ['/path/a', '/path/b'] }); // Same paths, different order - should NOT require restart since sorted comparison const newConfig = { ...currentConfig, externalContextPaths: ['/path/b', '/path/a'] }; expect(QueryOptionsBuilder.needsRestart(currentConfig, newConfig)).toBe(false); }); }); describe('buildPersistentQueryConfig', () => { it('builds config with default settings', () => { const ctx = createMockContext(); const config = QueryOptionsBuilder.buildPersistentQueryConfig(ctx); expect(config.model).toBe('claude-sonnet-4-5'); expect(config.thinkingTokens).toBeNull(); expect(config.permissionMode).toBe('yolo'); expect(config.settingSources).toBe('project'); expect(config.claudeCliPath).toBe('/mock/claude'); }); it('includes thinking tokens when budget is set', () => { const ctx = createMockContext({ settings: createMockSettings({ thinkingBudget: 'high' }), }); const config = QueryOptionsBuilder.buildPersistentQueryConfig(ctx); expect(config.thinkingTokens).toBe(16000); }); it('includes effortLevel for adaptive model', () => { const ctx = createMockContext({ settings: createMockSettings({ model: 'sonnet', effortLevel: 'max' }), }); const config = QueryOptionsBuilder.buildPersistentQueryConfig(ctx); expect(config.effortLevel).toBe('max'); }); it('sets effortLevel to null for custom model', () => { const ctx = createMockContext({ settings: createMockSettings({ model: 'custom-model', effortLevel: 'high' }), }); const config = QueryOptionsBuilder.buildPersistentQueryConfig(ctx); expect(config.effortLevel).toBeNull(); }); it('includes enableChrome from settings', () => { const ctx = createMockContext({ settings: createMockSettings({ enableChrome: true }), }); const config = QueryOptionsBuilder.buildPersistentQueryConfig(ctx); expect(config.enableChrome).toBe(true); }); it('sets settingSources to user,project when loadUserClaudeSettings is true', () => { const ctx = createMockContext({ settings: createMockSettings({ loadUserClaudeSettings: true }), }); const config = QueryOptionsBuilder.buildPersistentQueryConfig(ctx); expect(config.settingSources).toBe('user,project'); }); }); describe('buildPersistentQueryOptions', () => { it('sets yolo mode options correctly', () => { const ctx = { ...createMockContext(), abortController: new AbortController(), hooks: {}, }; const options = QueryOptionsBuilder.buildPersistentQueryOptions(ctx); expect(options.permissionMode).toBe('bypassPermissions'); expect(options.allowDangerouslySkipPermissions).toBe(true); }); it('includes canUseTool in yolo mode when provided', () => { const canUseTool = jest.fn(); const ctx = { ...createMockContext(), abortController: new AbortController(), hooks: {}, canUseTool, }; const options = QueryOptionsBuilder.buildPersistentQueryOptions(ctx); expect(options.permissionMode).toBe('bypassPermissions'); expect(options.canUseTool).toBe(canUseTool); }); it('sets normal mode options correctly', () => { const canUseTool = jest.fn(); const ctx = { ...createMockContext({ settings: createMockSettings({ permissionMode: 'normal' }), }), abortController: new AbortController(), hooks: {}, canUseTool, }; const options = QueryOptionsBuilder.buildPersistentQueryOptions(ctx); expect(options.permissionMode).toBe('acceptEdits'); // Always true to enable dynamic switching to bypassPermissions without restart expect(options.allowDangerouslySkipPermissions).toBe(true); expect(options.canUseTool).toBe(canUseTool); }); it('sets plan mode options correctly', () => { const canUseTool = jest.fn(); const ctx = { ...createMockContext({ settings: createMockSettings({ permissionMode: 'plan' as any }), }), abortController: new AbortController(), hooks: {}, canUseTool, }; const options = QueryOptionsBuilder.buildPersistentQueryOptions(ctx); expect(options.permissionMode).toBe('plan'); expect(options.allowDangerouslySkipPermissions).toBe(true); expect(options.canUseTool).toBe(canUseTool); }); it('sets adaptive thinking with effort for Claude models', () => { const ctx = { ...createMockContext({ settings: createMockSettings({ model: 'sonnet', effortLevel: 'max' }), }), abortController: new AbortController(), hooks: {}, }; const options = QueryOptionsBuilder.buildPersistentQueryOptions(ctx); expect(options.thinking).toEqual({ type: 'adaptive' }); expect(options.effort).toBe('max'); expect(options.maxThinkingTokens).toBeUndefined(); }); it('sets thinking tokens for custom models', () => { const ctx = { ...createMockContext({ settings: createMockSettings({ model: 'custom-model', thinkingBudget: 'high' }), }), abortController: new AbortController(), hooks: {}, }; const options = QueryOptionsBuilder.buildPersistentQueryOptions(ctx); expect(options.maxThinkingTokens).toBe(16000); expect(options.thinking).toBeUndefined(); }); it('sets resume session ID when provided', () => { const ctx = { ...createMockContext(), abortController: new AbortController(), hooks: {}, resume: { sessionId: 'session-123' }, }; const options = QueryOptionsBuilder.buildPersistentQueryOptions(ctx); expect(options.resume).toBe('session-123'); }); it('sets extraArgs with chrome flag when enableChrome is enabled', () => { const ctx = { ...createMockContext({ settings: createMockSettings({ enableChrome: true }), }), abortController: new AbortController(), hooks: {}, }; const options = QueryOptionsBuilder.buildPersistentQueryOptions(ctx); expect(options.extraArgs).toBeDefined(); expect(options.extraArgs).toEqual({ chrome: null }); }); it('does not set extraArgs when enableChrome is disabled', () => { const ctx = { ...createMockContext({ settings: createMockSettings({ enableChrome: false }), }), abortController: new AbortController(), hooks: {}, }; const options = QueryOptionsBuilder.buildPersistentQueryOptions(ctx); expect(options.extraArgs).toBeUndefined(); }); it('sets additionalDirectories when externalContextPaths provided', () => { const ctx = { ...createMockContext(), abortController: new AbortController(), hooks: {}, externalContextPaths: ['/external/path1', '/external/path2'], }; const options = QueryOptionsBuilder.buildPersistentQueryOptions(ctx); expect(options.additionalDirectories).toEqual(['/external/path1', '/external/path2']); }); it('does not set additionalDirectories when externalContextPaths is empty', () => { const ctx = { ...createMockContext(), abortController: new AbortController(), hooks: {}, externalContextPaths: [], }; const options = QueryOptionsBuilder.buildPersistentQueryOptions(ctx); expect(options.additionalDirectories).toBeUndefined(); }); it('always enables file checkpointing', () => { const ctx = { ...createMockContext(), abortController: new AbortController(), hooks: {}, }; const options = QueryOptionsBuilder.buildPersistentQueryOptions(ctx); expect(options.enableFileCheckpointing).toBe(true); }); it('sets resumeSessionAt when provided in resume', () => { const ctx = { ...createMockContext(), abortController: new AbortController(), hooks: {}, resume: { sessionId: 'session-123', sessionAt: 'asst-uuid-456' }, }; const options = QueryOptionsBuilder.buildPersistentQueryOptions(ctx); expect(options.resumeSessionAt).toBe('asst-uuid-456'); }); it('does not set resumeSessionAt when resume has no sessionAt', () => { const ctx = { ...createMockContext(), abortController: new AbortController(), hooks: {}, resume: { sessionId: 'session-123' }, }; const options = QueryOptionsBuilder.buildPersistentQueryOptions(ctx); expect(options.resumeSessionAt).toBeUndefined(); }); it('sets forkSession when resume.fork is true', () => { const ctx = { ...createMockContext(), abortController: new AbortController(), hooks: {}, resume: { sessionId: 'session-123', fork: true }, }; const options = QueryOptionsBuilder.buildPersistentQueryOptions(ctx); expect(options.forkSession).toBe(true); }); it('does not set forkSession when resume has no fork', () => { const ctx = { ...createMockContext(), abortController: new AbortController(), hooks: {}, resume: { sessionId: 'session-123' }, }; const options = QueryOptionsBuilder.buildPersistentQueryOptions(ctx); expect(options.forkSession).toBeUndefined(); }); it('sets both forkSession and resumeSessionAt when fork resumes at specific point', () => { const ctx = { ...createMockContext(), abortController: new AbortController(), hooks: {}, resume: { sessionId: 'session-123', sessionAt: 'asst-uuid-456', fork: true }, }; const options = QueryOptionsBuilder.buildPersistentQueryOptions(ctx); expect(options.resume).toBe('session-123'); expect(options.resumeSessionAt).toBe('asst-uuid-456'); expect(options.forkSession).toBe(true); }); it('does not set resume options when no resume provided', () => { const ctx = { ...createMockContext(), abortController: new AbortController(), hooks: {}, }; const options = QueryOptionsBuilder.buildPersistentQueryOptions(ctx); expect(options.resume).toBeUndefined(); expect(options.resumeSessionAt).toBeUndefined(); expect(options.forkSession).toBeUndefined(); }); it('does not pass plugins or agents via SDK options (SDK auto-discovers from settings)', () => { const ctx = createMockContext(); const options = QueryOptionsBuilder.buildPersistentQueryOptions({ ...ctx, abortController: new AbortController(), hooks: {}, }); expect(options.plugins).toBeUndefined(); expect(options.agents).toBeUndefined(); }); }); describe('buildColdStartQueryOptions', () => { it('includes MCP servers when available', () => { const mcpManager = createMockMcpManager(); mcpManager.getActiveServers.mockReturnValue({ 'test-server': { command: 'test', args: [] }, }); const ctx = { ...createMockContext({ mcpManager }), abortController: new AbortController(), hooks: {}, mcpMentions: new Set(['test-server']), hasEditorContext: false, }; const options = QueryOptionsBuilder.buildColdStartQueryOptions(ctx); expect(options.mcpServers).toBeDefined(); expect(options.mcpServers?.['test-server']).toBeDefined(); }); it('uses model override when provided', () => { const ctx = { ...createMockContext({ settings: createMockSettings({ model: 'claude-sonnet-4-5' }), }), abortController: new AbortController(), hooks: {}, modelOverride: 'claude-opus-4-5', hasEditorContext: false, }; const options = QueryOptionsBuilder.buildColdStartQueryOptions(ctx); expect(options.model).toBe('claude-opus-4-5'); }); it('applies tool restriction when allowedTools is provided', () => { const ctx = { ...createMockContext(), abortController: new AbortController(), hooks: {}, allowedTools: ['Read', 'Grep'], hasEditorContext: false, }; const options = QueryOptionsBuilder.buildColdStartQueryOptions(ctx); expect(options.tools).toEqual(['Read', 'Grep']); }); it('sets extraArgs with chrome flag when enableChrome is enabled', () => { const ctx = { ...createMockContext({ settings: createMockSettings({ enableChrome: true }), }), abortController: new AbortController(), hooks: {}, hasEditorContext: false, }; const options = QueryOptionsBuilder.buildColdStartQueryOptions(ctx); expect(options.extraArgs).toBeDefined(); expect(options.extraArgs).toEqual({ chrome: null }); }); it('does not set extraArgs when enableChrome is disabled', () => { const ctx = { ...createMockContext({ settings: createMockSettings({ enableChrome: false }), }), abortController: new AbortController(), hooks: {}, hasEditorContext: false, }; const options = QueryOptionsBuilder.buildColdStartQueryOptions(ctx); expect(options.extraArgs).toBeUndefined(); }); it('sets additionalDirectories when externalContextPaths provided', () => { const ctx = { ...createMockContext(), abortController: new AbortController(), hooks: {}, hasEditorContext: false, externalContextPaths: ['/external/path'], }; const options = QueryOptionsBuilder.buildColdStartQueryOptions(ctx); expect(options.additionalDirectories).toEqual(['/external/path']); }); it('does not set additionalDirectories when externalContextPaths is empty', () => { const ctx = { ...createMockContext(), abortController: new AbortController(), hooks: {}, hasEditorContext: false, externalContextPaths: [], }; const options = QueryOptionsBuilder.buildColdStartQueryOptions(ctx); expect(options.additionalDirectories).toBeUndefined(); }); it('does not pass plugins via SDK options (CLI auto-discovers)', () => { const ctx = createMockContext(); const options = QueryOptionsBuilder.buildColdStartQueryOptions({ ...ctx, abortController: new AbortController(), hooks: {}, hasEditorContext: false, }); expect(options.plugins).toBeUndefined(); }); it('does not pass agents via SDK options (SDK auto-discovers from settings)', () => { const ctx = createMockContext(); const options = QueryOptionsBuilder.buildColdStartQueryOptions({ ...ctx, abortController: new AbortController(), hooks: {}, hasEditorContext: false, }); expect(options.agents).toBeUndefined(); }); }); }); ================================================ FILE: tests/unit/core/agent/SessionManager.test.ts ================================================ import { SessionManager } from '@/core/agent/SessionManager'; describe('SessionManager', () => { let manager: SessionManager; beforeEach(() => { manager = new SessionManager(); }); describe('getSessionId and setSessionId', () => { it('should initially return null', () => { expect(manager.getSessionId()).toBeNull(); }); it('should set and get session ID', () => { manager.setSessionId('test-session-123'); expect(manager.getSessionId()).toBe('test-session-123'); }); it('should allow setting session ID to null', () => { manager.setSessionId('some-session'); manager.setSessionId(null); expect(manager.getSessionId()).toBeNull(); }); it('should set session model when defaultModel is provided', () => { manager.setSessionId('test-session', 'claude-sonnet-4-5'); expect(manager.getSessionId()).toBe('test-session'); }); }); describe('reset', () => { it('should reset session without throwing', () => { expect(() => manager.reset()).not.toThrow(); }); it('should clear session ID', () => { manager.setSessionId('some-session'); expect(manager.getSessionId()).toBe('some-session'); manager.reset(); expect(manager.getSessionId()).toBeNull(); }); it('should clear interrupted state', () => { manager.markInterrupted(); expect(manager.wasInterrupted()).toBe(true); manager.reset(); expect(manager.wasInterrupted()).toBe(false); }); }); describe('interrupted state', () => { it('should initially not be interrupted', () => { expect(manager.wasInterrupted()).toBe(false); }); it('should mark as interrupted', () => { manager.markInterrupted(); expect(manager.wasInterrupted()).toBe(true); }); it('should clear interrupted state', () => { manager.markInterrupted(); manager.clearInterrupted(); expect(manager.wasInterrupted()).toBe(false); }); }); describe('pending model', () => { it('should set and clear pending model without throwing', () => { expect(() => manager.setPendingModel('claude-opus-4-5')).not.toThrow(); expect(() => manager.clearPendingModel()).not.toThrow(); }); }); describe('captureSession', () => { it('should capture session ID and pending model', () => { manager.setPendingModel('claude-opus-4-5'); manager.captureSession('new-session-id'); expect(manager.getSessionId()).toBe('new-session-id'); }); }); describe('invalidateSession', () => { it('should clear session ID and model', () => { manager.setSessionId('test-session', 'claude-sonnet-4-5'); manager.invalidateSession(); expect(manager.getSessionId()).toBeNull(); }); it('should mark invalidation and allow consumption', () => { manager.setSessionId('test-session'); manager.invalidateSession(); expect(manager.consumeInvalidation()).toBe(true); expect(manager.consumeInvalidation()).toBe(false); }); it('should clear invalidation when setting a new session', () => { manager.invalidateSession(); manager.setSessionId('new-session'); expect(manager.consumeInvalidation()).toBe(false); }); }); describe('session mismatch recovery', () => { it('should initially not need history rebuild', () => { expect(manager.needsHistoryRebuild()).toBe(false); }); it('should not set rebuild flag when capturing first session', () => { manager.captureSession('first-session'); expect(manager.needsHistoryRebuild()).toBe(false); }); it('should not set rebuild flag when same session ID is captured', () => { manager.captureSession('same-session'); manager.captureSession('same-session'); expect(manager.needsHistoryRebuild()).toBe(false); }); it('should set rebuild flag when different session ID is captured', () => { manager.captureSession('old-session'); manager.captureSession('new-session'); expect(manager.needsHistoryRebuild()).toBe(true); }); it('should clear rebuild flag with clearHistoryRebuild', () => { manager.captureSession('old-session'); manager.captureSession('new-session'); expect(manager.needsHistoryRebuild()).toBe(true); manager.clearHistoryRebuild(); expect(manager.needsHistoryRebuild()).toBe(false); }); it('should clear rebuild flag on reset', () => { manager.captureSession('old-session'); manager.captureSession('new-session'); expect(manager.needsHistoryRebuild()).toBe(true); manager.reset(); expect(manager.needsHistoryRebuild()).toBe(false); }); it('should not set rebuild flag after setSessionId (external restore)', () => { // setSessionId is for restoring from saved conversation, not SDK response manager.setSessionId('restored-session'); manager.captureSession('different-session'); // This is a mismatch - SDK gave different session than we expected expect(manager.needsHistoryRebuild()).toBe(true); }); it('should clear rebuild flag when setSessionId is called (session switch)', () => { // Scenario: mismatch occurs in conversation A, user switches to conversation B manager.captureSession('session-a'); manager.captureSession('different-session'); // Mismatch detected expect(manager.needsHistoryRebuild()).toBe(true); // User switches to conversation B via setSessionId manager.setSessionId('session-b'); // Flag should be cleared to prevent incorrectly prepending B's history expect(manager.needsHistoryRebuild()).toBe(false); }); it('should clear rebuild flag when setSessionId is called with null', () => { manager.captureSession('session-a'); manager.captureSession('different-session'); // Mismatch detected expect(manager.needsHistoryRebuild()).toBe(true); manager.setSessionId(null); expect(manager.needsHistoryRebuild()).toBe(false); }); }); }); ================================================ FILE: tests/unit/core/agent/customSpawn.test.ts ================================================ import type { SpawnOptions } from '@anthropic-ai/claude-agent-sdk'; import { spawn } from 'child_process'; import { createCustomSpawnFunction } from '@/core/agent/customSpawn'; import * as env from '@/utils/env'; jest.mock('child_process', () => ({ spawn: jest.fn(), })); describe('createCustomSpawnFunction', () => { const spawnMock = spawn as jest.MockedFunction<typeof spawn>; afterEach(() => { jest.restoreAllMocks(); spawnMock.mockReset(); }); const createMockProcess = () => { const stderr = { on: jest.fn() } as unknown as NodeJS.ReadableStream; return { stdin: {} as NodeJS.WritableStream, stdout: {} as NodeJS.ReadableStream, stderr, killed: false, exitCode: null, kill: jest.fn(), on: jest.fn(), once: jest.fn(), off: jest.fn(), }; }; it('resolves node command to full path when available', () => { const mockProcess = createMockProcess(); spawnMock.mockReturnValue(mockProcess as unknown as ReturnType<typeof spawn>); const findNodeExecutable = jest .spyOn(env, 'findNodeExecutable') .mockReturnValue('/custom/node'); const spawnFn = createCustomSpawnFunction('/enhanced/path'); const signal = new AbortController().signal; const options: SpawnOptions = { command: 'node', args: ['cli.js'], cwd: '/tmp', env: {}, signal, }; const result = spawnFn(options); expect(findNodeExecutable).toHaveBeenCalledWith('/enhanced/path'); expect(spawnMock).toHaveBeenCalledWith('/custom/node', ['cli.js'], expect.objectContaining({ cwd: '/tmp', })); expect(result).toBe(mockProcess); }); it('pipes stderr only when DEBUG_CLAUDE_AGENT_SDK is set', () => { const mockProcess = createMockProcess(); spawnMock.mockReturnValue(mockProcess as unknown as ReturnType<typeof spawn>); const spawnFn = createCustomSpawnFunction('/enhanced/path'); const signal = new AbortController().signal; spawnFn({ command: 'node', args: ['cli.js'], cwd: '/tmp', env: { DEBUG_CLAUDE_AGENT_SDK: '1' }, signal, }); const spawnOptions = spawnMock.mock.calls[0][2]; expect(spawnOptions.stdio).toEqual(['pipe', 'pipe', 'pipe']); expect(mockProcess.stderr?.on).toHaveBeenCalledWith('data', expect.any(Function)); }); it('ignores stderr when DEBUG_CLAUDE_AGENT_SDK is not set', () => { const mockProcess = createMockProcess(); spawnMock.mockReturnValue(mockProcess as unknown as ReturnType<typeof spawn>); const spawnFn = createCustomSpawnFunction('/enhanced/path'); const signal = new AbortController().signal; spawnFn({ command: 'node', args: ['cli.js'], cwd: '/tmp', env: {}, signal, }); const spawnOptions = spawnMock.mock.calls[0][2]; expect(spawnOptions.stdio).toEqual(['pipe', 'pipe', 'ignore']); expect(mockProcess.stderr?.on).not.toHaveBeenCalled(); }); it('throws when process streams are missing', () => { const mockProcess = { stdin: null, stdout: null, stderr: null, killed: false, exitCode: null, kill: jest.fn(), on: jest.fn(), once: jest.fn(), off: jest.fn(), }; spawnMock.mockReturnValue(mockProcess as unknown as ReturnType<typeof spawn>); const spawnFn = createCustomSpawnFunction('/enhanced/path'); const signal = new AbortController().signal; expect(() => spawnFn({ command: 'node', args: ['cli.js'], cwd: '/tmp', env: {}, signal, })).toThrow('Failed to create process streams'); }); it('falls back to original command when findNodeExecutable returns null', () => { const mockProcess = createMockProcess(); spawnMock.mockReturnValue(mockProcess as unknown as ReturnType<typeof spawn>); jest.spyOn(env, 'findNodeExecutable').mockReturnValue(null); const spawnFn = createCustomSpawnFunction('/enhanced/path'); const signal = new AbortController().signal; spawnFn({ command: 'node', args: ['cli.js'], cwd: '/tmp', env: {}, signal, }); // Should use 'node' as-is since findNodeExecutable returned null expect(spawnMock).toHaveBeenCalledWith('node', ['cli.js'], expect.any(Object)); }); it('does not resolve non-node commands', () => { const mockProcess = createMockProcess(); spawnMock.mockReturnValue(mockProcess as unknown as ReturnType<typeof spawn>); const findNodeExecutable = jest.spyOn(env, 'findNodeExecutable'); const spawnFn = createCustomSpawnFunction('/enhanced/path'); const signal = new AbortController().signal; spawnFn({ command: 'python', args: ['script.py'], cwd: '/tmp', env: {}, signal, }); expect(findNodeExecutable).not.toHaveBeenCalled(); expect(spawnMock).toHaveBeenCalledWith('python', ['script.py'], expect.any(Object)); }); it('does not pass signal to spawn options', () => { const mockProcess = createMockProcess(); spawnMock.mockReturnValue(mockProcess as unknown as ReturnType<typeof spawn>); const spawnFn = createCustomSpawnFunction('/enhanced/path'); const signal = new AbortController().signal; spawnFn({ command: 'node', args: ['cli.js'], cwd: '/tmp', env: {}, signal, }); const spawnOptions = spawnMock.mock.calls[0][2]; expect(spawnOptions).not.toHaveProperty('signal'); }); it('kills child immediately when signal is already aborted', () => { const mockProcess = createMockProcess(); spawnMock.mockReturnValue(mockProcess as unknown as ReturnType<typeof spawn>); const controller = new AbortController(); controller.abort(); const spawnFn = createCustomSpawnFunction('/enhanced/path'); spawnFn({ command: 'node', args: ['cli.js'], cwd: '/tmp', env: {}, signal: controller.signal, }); expect(mockProcess.kill).toHaveBeenCalled(); }); it('kills child when signal aborts after spawn', () => { const mockProcess = createMockProcess(); spawnMock.mockReturnValue(mockProcess as unknown as ReturnType<typeof spawn>); const controller = new AbortController(); const spawnFn = createCustomSpawnFunction('/enhanced/path'); spawnFn({ command: 'node', args: ['cli.js'], cwd: '/tmp', env: {}, signal: controller.signal, }); expect(mockProcess.kill).not.toHaveBeenCalled(); controller.abort(); expect(mockProcess.kill).toHaveBeenCalled(); }); it('does not kill child when signal is not provided', () => { const mockProcess = createMockProcess(); spawnMock.mockReturnValue(mockProcess as unknown as ReturnType<typeof spawn>); const spawnFn = createCustomSpawnFunction('/enhanced/path'); spawnFn({ command: 'node', args: ['cli.js'], cwd: '/tmp', env: {}, } as SpawnOptions); expect(mockProcess.kill).not.toHaveBeenCalled(); }); }); ================================================ FILE: tests/unit/core/agent/index.test.ts ================================================ import { ClaudianService, MessageChannel, QueryOptionsBuilder, SessionManager } from '@/core/agent'; describe('core/agent index', () => { it('re-exports runtime symbols', () => { expect(ClaudianService).toBeDefined(); expect(MessageChannel).toBeDefined(); expect(QueryOptionsBuilder).toBeDefined(); expect(SessionManager).toBeDefined(); }); }); ================================================ FILE: tests/unit/core/agent/types.test.ts ================================================ import { buildSDKMessage } from '@test/helpers/sdkMessages'; import { computeSystemPromptKey, createResponseHandler, isTurnCompleteMessage } from '@/core/agent/types'; describe('isTurnCompleteMessage', () => { it('returns true for result message', () => { const message = buildSDKMessage({ type: 'result' }); expect(isTurnCompleteMessage(message)).toBe(true); }); it('returns false for assistant message', () => { const message = buildSDKMessage({ type: 'assistant' }); expect(isTurnCompleteMessage(message)).toBe(false); }); it('returns false for user message', () => { const message = buildSDKMessage({ type: 'user' }); expect(isTurnCompleteMessage(message)).toBe(false); }); it('returns false for system message', () => { const message = buildSDKMessage({ type: 'system', subtype: 'status' }); expect(isTurnCompleteMessage(message)).toBe(false); }); }); describe('computeSystemPromptKey', () => { it('computes key from all settings', () => { // Note: Agents are passed via Options.agents, not system prompt, so not included in key. const settings = { mediaFolder: 'attachments', customPrompt: 'Be helpful', allowedExportPaths: ['/path/b', '/path/a'], vaultPath: '/vault', userName: 'Alice', }; const key = computeSystemPromptKey(settings); // Paths are sorted to keep the key stable. expect(key).toBe('attachments::Be helpful::/path/a|/path/b::/vault::Alice::false'); }); it('handles empty/undefined values', () => { const settings = { mediaFolder: '', customPrompt: '', allowedExportPaths: [], vaultPath: '', userName: '', }; const key = computeSystemPromptKey(settings); // 6 parts joined with '::' = 5 separators = 10 colons, last part is 'false' expect(key).toBe('::::::::::false'); }); it('produces different keys for different inputs', () => { const settings1 = { mediaFolder: 'attachments', customPrompt: 'Be helpful', allowedExportPaths: [], vaultPath: '/vault1', }; const settings2 = { mediaFolder: 'attachments', customPrompt: 'Be helpful', allowedExportPaths: [], vaultPath: '/vault2', }; expect(computeSystemPromptKey(settings1)).not.toBe(computeSystemPromptKey(settings2)); }); it('produces same key for equivalent inputs with different path order', () => { const settings1 = { mediaFolder: '', customPrompt: '', allowedExportPaths: ['/a', '/b', '/c'], vaultPath: '', }; const settings2 = { mediaFolder: '', customPrompt: '', allowedExportPaths: ['/c', '/a', '/b'], vaultPath: '', }; // Paths are sorted, so order shouldn't matter expect(computeSystemPromptKey(settings1)).toBe(computeSystemPromptKey(settings2)); }); it('produces different keys when allowExternalAccess differs', () => { const base = { mediaFolder: '', customPrompt: '', allowedExportPaths: [], vaultPath: '/vault', }; expect(computeSystemPromptKey({ ...base, allowExternalAccess: false })) .not.toBe(computeSystemPromptKey({ ...base, allowExternalAccess: true })); }); }); describe('createResponseHandler', () => { it('creates a handler with initial state values as false', () => { const handler = createResponseHandler({ id: 'test-handler', onChunk: jest.fn(), onDone: jest.fn(), onError: jest.fn(), }); expect(handler.sawStreamText).toBe(false); expect(handler.sawAnyChunk).toBe(false); }); it('markStreamTextSeen sets sawStreamText to true', () => { const handler = createResponseHandler({ id: 'test-handler', onChunk: jest.fn(), onDone: jest.fn(), onError: jest.fn(), }); expect(handler.sawStreamText).toBe(false); handler.markStreamTextSeen(); expect(handler.sawStreamText).toBe(true); }); it('resetStreamText sets sawStreamText back to false', () => { const handler = createResponseHandler({ id: 'test-handler', onChunk: jest.fn(), onDone: jest.fn(), onError: jest.fn(), }); handler.markStreamTextSeen(); expect(handler.sawStreamText).toBe(true); handler.resetStreamText(); expect(handler.sawStreamText).toBe(false); }); it('markChunkSeen sets sawAnyChunk to true', () => { const handler = createResponseHandler({ id: 'test-handler', onChunk: jest.fn(), onDone: jest.fn(), onError: jest.fn(), }); expect(handler.sawAnyChunk).toBe(false); handler.markChunkSeen(); expect(handler.sawAnyChunk).toBe(true); }); it('preserves id from options', () => { const handler = createResponseHandler({ id: 'my-unique-id', onChunk: jest.fn(), onDone: jest.fn(), onError: jest.fn(), }); expect(handler.id).toBe('my-unique-id'); }); it('calls onChunk callback when invoked', () => { const onChunk = jest.fn(); const handler = createResponseHandler({ id: 'test-handler', onChunk, onDone: jest.fn(), onError: jest.fn(), }); const chunk = { type: 'text' as const, content: 'hello' }; handler.onChunk(chunk); expect(onChunk).toHaveBeenCalledWith(chunk); }); it('calls onDone callback when invoked', () => { const onDone = jest.fn(); const handler = createResponseHandler({ id: 'test-handler', onChunk: jest.fn(), onDone, onError: jest.fn(), }); handler.onDone(); expect(onDone).toHaveBeenCalled(); }); it('calls onError callback when invoked', () => { const onError = jest.fn(); const handler = createResponseHandler({ id: 'test-handler', onChunk: jest.fn(), onDone: jest.fn(), onError, }); const error = new Error('test error'); handler.onError(error); expect(onError).toHaveBeenCalledWith(error); }); it('maintains independent state between handlers', () => { const handler1 = createResponseHandler({ id: 'handler-1', onChunk: jest.fn(), onDone: jest.fn(), onError: jest.fn(), }); const handler2 = createResponseHandler({ id: 'handler-2', onChunk: jest.fn(), onDone: jest.fn(), onError: jest.fn(), }); handler1.markStreamTextSeen(); handler1.markChunkSeen(); // handler2 should not be affected expect(handler1.sawStreamText).toBe(true); expect(handler1.sawAnyChunk).toBe(true); expect(handler2.sawStreamText).toBe(false); expect(handler2.sawAnyChunk).toBe(false); }); }); ================================================ FILE: tests/unit/core/agents/AgentManager.test.ts ================================================ import * as fs from 'fs'; import * as path from 'path'; // Mock fs and os modules BEFORE importing AgentManager jest.mock('fs'); jest.mock('os', () => ({ homedir: jest.fn().mockReturnValue('/home/user'), })); import { AgentManager } from '@/core/agents/AgentManager'; import type { PluginManager } from '@/core/plugins/PluginManager'; const mockFs = jest.mocked(fs); // Create a mock PluginManager function createMockPluginManager(plugins: Array<{ name: string; enabled: boolean; installPath: string }> = []): PluginManager { return { getPlugins: jest.fn().mockReturnValue(plugins.map(p => ({ id: `${p.name}@test`, name: p.name, enabled: p.enabled, scope: 'user' as const, installPath: p.installPath, }))), } as unknown as PluginManager; } // Helper to create mock Dirent objects function createMockDirent(name: string, isFile: boolean): fs.Dirent { return { name, isFile: () => isFile, isDirectory: () => !isFile, isBlockDevice: () => false, isCharacterDevice: () => false, isSymbolicLink: () => false, isFIFO: () => false, isSocket: () => false, path: '', parentPath: '', } as fs.Dirent; } // Sample agent file content const VALID_AGENT_FILE = `--- name: TestAgent description: A test agent for testing tools: [Read, Grep] disallowedTools: [Write] model: sonnet --- You are a helpful test agent.`; const MINIMAL_AGENT_FILE = `--- name: MinimalAgent description: Minimal agent --- Simple prompt.`; const PLUGIN_AGENT_FILE = `--- name: code-reviewer description: Reviews code for issues tools: [Read, Grep] model: sonnet --- You review code.`; const INVALID_AGENT_FILE = `--- name: [InvalidName] description: Valid description --- Body.`; describe('AgentManager', () => { const VAULT_PATH = '/test/vault'; const HOME_DIR = '/home/user'; const GLOBAL_AGENTS_DIR = path.join(HOME_DIR, '.claude', 'agents'); const VAULT_AGENTS_DIR = path.join(VAULT_PATH, '.claude/agents'); beforeEach(() => { jest.clearAllMocks(); // os.homedir is already mocked to return HOME_DIR mockFs.existsSync.mockReturnValue(false); mockFs.readdirSync.mockReturnValue([]); }); describe('constructor', () => { it('creates an AgentManager with vault path', () => { const manager = new AgentManager(VAULT_PATH, createMockPluginManager()); expect(manager).toBeInstanceOf(AgentManager); }); }); describe('loadAgents', () => { it('includes built-in agents by default', async () => { const manager = new AgentManager(VAULT_PATH, createMockPluginManager()); await manager.loadAgents(); const agents = manager.getAvailableAgents(); expect(agents.length).toBeGreaterThanOrEqual(4); expect(agents.find(a => a.id === 'Explore')).toBeDefined(); expect(agents.find(a => a.id === 'Plan')).toBeDefined(); expect(agents.find(a => a.id === 'Bash')).toBeDefined(); expect(agents.find(a => a.id === 'general-purpose')).toBeDefined(); }); it('built-in agents have correct properties', async () => { const manager = new AgentManager(VAULT_PATH, createMockPluginManager()); await manager.loadAgents(); const explore = manager.getAgentById('Explore'); expect(explore).toBeDefined(); expect(explore?.source).toBe('builtin'); expect(explore?.name).toBe('Explore'); expect(explore?.description).toBe('Fast codebase exploration and search'); }); it('loads agents from vault directory', async () => { const manager = new AgentManager(VAULT_PATH, createMockPluginManager()); mockFs.existsSync.mockImplementation((p) => p === VAULT_AGENTS_DIR); (mockFs.readdirSync as jest.Mock).mockImplementation((dir: string) => { if (dir === VAULT_AGENTS_DIR) { return [createMockDirent('test-agent.md', true)]; } return []; }); mockFs.readFileSync.mockReturnValue(VALID_AGENT_FILE); await manager.loadAgents(); const agents = manager.getAvailableAgents(); const vaultAgent = agents.find(a => a.id === 'TestAgent' && a.source === 'vault'); expect(vaultAgent).toBeDefined(); expect(vaultAgent?.name).toBe('TestAgent'); expect(vaultAgent?.description).toBe('A test agent for testing'); expect(vaultAgent?.tools).toEqual(['Read', 'Grep']); expect(vaultAgent?.disallowedTools).toEqual(['Write']); expect(vaultAgent?.model).toBe('sonnet'); }); it('loads agents from global directory', async () => { const manager = new AgentManager(VAULT_PATH, createMockPluginManager()); mockFs.existsSync.mockImplementation((p) => p === GLOBAL_AGENTS_DIR); (mockFs.readdirSync as jest.Mock).mockImplementation((dir: string) => { if (dir === GLOBAL_AGENTS_DIR) { return [createMockDirent('global-agent.md', true)]; } return []; }); mockFs.readFileSync.mockReturnValue(MINIMAL_AGENT_FILE); await manager.loadAgents(); const agents = manager.getAvailableAgents(); const globalAgent = agents.find(a => a.id === 'MinimalAgent' && a.source === 'global'); expect(globalAgent).toBeDefined(); expect(globalAgent?.source).toBe('global'); }); it('skips invalid agent files', async () => { const manager = new AgentManager(VAULT_PATH, createMockPluginManager()); mockFs.existsSync.mockImplementation((p) => p === VAULT_AGENTS_DIR); (mockFs.readdirSync as jest.Mock).mockImplementation((dir: string) => { if (dir === VAULT_AGENTS_DIR) { return [createMockDirent('invalid-agent.md', true)]; } return []; }); mockFs.readFileSync.mockReturnValue(INVALID_AGENT_FILE); await manager.loadAgents(); const agents = manager.getAvailableAgents(); // Should only have built-in agents (invalid agent skipped) expect(agents.every(a => a.source === 'builtin')).toBe(true); }); it('isolates errors per category so one failure does not block others', async () => { const pluginManager = createMockPluginManager([ { name: 'Broken', enabled: true, installPath: '/plugins/broken' }, ]); const manager = new AgentManager(VAULT_PATH, pluginManager); // Plugin agents dir exists but getPlugins throws internally on iteration // Vault agents load normally, global dir doesn't exist mockFs.existsSync.mockImplementation((p) => p === path.join('/plugins/broken', 'agents') || p === VAULT_AGENTS_DIR ); (mockFs.readdirSync as jest.Mock).mockImplementation((dir: string) => { if (dir === path.join('/plugins/broken', 'agents')) { throw new Error('Corrupt plugin directory'); } if (dir === VAULT_AGENTS_DIR) { return [createMockDirent('vault-agent.md', true)]; } return []; }); mockFs.readFileSync.mockReturnValue(MINIMAL_AGENT_FILE); await manager.loadAgents(); const agents = manager.getAvailableAgents(); // Vault agent should still be loaded despite plugin error expect(agents.some(a => a.source === 'vault')).toBe(true); }); it('skips duplicate agent IDs', async () => { const manager = new AgentManager(VAULT_PATH, createMockPluginManager()); // Both vault and global have same agent name mockFs.existsSync.mockReturnValue(true); (mockFs.readdirSync as jest.Mock).mockImplementation(() => { return [createMockDirent('duplicate.md', true)]; }); mockFs.readFileSync.mockReturnValue(MINIMAL_AGENT_FILE); await manager.loadAgents(); const agents = manager.getAvailableAgents(); // Should only have one MinimalAgent (vault takes priority over global) const minimalAgents = agents.filter(a => a.name === 'MinimalAgent'); expect(minimalAgents.length).toBe(1); expect(minimalAgents[0].source).toBe('vault'); }); it('handles directory read errors gracefully', async () => { const manager = new AgentManager(VAULT_PATH, createMockPluginManager()); mockFs.existsSync.mockReturnValue(true); mockFs.readdirSync.mockImplementation(() => { throw new Error('Permission denied'); }); await manager.loadAgents(); const agents = manager.getAvailableAgents(); // Should still have built-in agents expect(agents.length).toBeGreaterThanOrEqual(4); }); it('handles file read errors gracefully', async () => { const manager = new AgentManager(VAULT_PATH, createMockPluginManager()); mockFs.existsSync.mockImplementation((p) => p === VAULT_AGENTS_DIR); (mockFs.readdirSync as jest.Mock).mockImplementation((dir: string) => { if (dir === VAULT_AGENTS_DIR) { return [createMockDirent('error-agent.md', true)]; } return []; }); mockFs.readFileSync.mockImplementation(() => { throw new Error('File read error'); }); await manager.loadAgents(); const agents = manager.getAvailableAgents(); // Should only have built-in agents expect(agents.every(a => a.source === 'builtin')).toBe(true); }); it('ignores non-markdown files', async () => { const manager = new AgentManager(VAULT_PATH, createMockPluginManager()); mockFs.existsSync.mockImplementation((p) => p === VAULT_AGENTS_DIR); (mockFs.readdirSync as jest.Mock).mockImplementation((dir: string) => { if (dir === VAULT_AGENTS_DIR) { return [ createMockDirent('agent.txt', true), createMockDirent('agent.json', true), createMockDirent('valid-agent.md', true), ]; } return []; }); mockFs.readFileSync.mockReturnValue(MINIMAL_AGENT_FILE); await manager.loadAgents(); const agents = manager.getAvailableAgents(); // Only the .md file should be parsed const vaultAgents = agents.filter(a => a.source === 'vault'); expect(vaultAgents.length).toBe(1); }); it('ignores directories', async () => { const manager = new AgentManager(VAULT_PATH, createMockPluginManager()); mockFs.existsSync.mockImplementation((p) => p === VAULT_AGENTS_DIR); (mockFs.readdirSync as jest.Mock).mockImplementation((dir: string) => { if (dir === VAULT_AGENTS_DIR) { return [ createMockDirent('subdir', false), createMockDirent('valid-agent.md', true), ]; } return []; }); mockFs.readFileSync.mockReturnValue(MINIMAL_AGENT_FILE); await manager.loadAgents(); const agents = manager.getAvailableAgents(); // Only files should be processed const vaultAgents = agents.filter(a => a.source === 'vault'); expect(vaultAgents.length).toBe(1); }); it('loads plugin agents with namespaced IDs', async () => { const pluginManager = createMockPluginManager([ { name: 'PR Review Toolkit', enabled: true, installPath: '/plugins/pr-review' }, ]); const manager = new AgentManager(VAULT_PATH, pluginManager); const pluginAgentsDir = path.join('/plugins/pr-review', 'agents'); mockFs.existsSync.mockImplementation((p) => p === pluginAgentsDir); (mockFs.readdirSync as jest.Mock).mockImplementation((dir: string) => { if (dir === pluginAgentsDir) { return [createMockDirent('reviewer.md', true)]; } return []; }); mockFs.readFileSync.mockReturnValue(PLUGIN_AGENT_FILE); await manager.loadAgents(); const agents = manager.getAvailableAgents(); const pluginAgent = agents.find(a => a.source === 'plugin'); expect(pluginAgent).toBeDefined(); expect(pluginAgent?.id).toBe('pr-review-toolkit:code-reviewer'); expect(pluginAgent?.name).toBe('code-reviewer'); expect(pluginAgent?.description).toBe('Reviews code for issues'); expect(pluginAgent?.tools).toEqual(['Read', 'Grep']); expect(pluginAgent?.model).toBe('sonnet'); expect(pluginAgent?.pluginName).toBe('PR Review Toolkit'); }); it('skips disabled plugins', async () => { const pluginManager = createMockPluginManager([ { name: 'Disabled Plugin', enabled: false, installPath: '/plugins/disabled' }, ]); const manager = new AgentManager(VAULT_PATH, pluginManager); await manager.loadAgents(); const agents = manager.getAvailableAgents(); expect(agents.every(a => a.source !== 'plugin')).toBe(true); }); it('skips plugins without agents directory', async () => { const pluginManager = createMockPluginManager([ { name: 'No Agents', enabled: true, installPath: '/plugins/no-agents' }, ]); const manager = new AgentManager(VAULT_PATH, pluginManager); mockFs.existsSync.mockReturnValue(false); await manager.loadAgents(); const agents = manager.getAvailableAgents(); expect(agents.every(a => a.source !== 'plugin')).toBe(true); }); it('normalizes plugin name to lowercase with hyphens in agent ID', async () => { const pluginManager = createMockPluginManager([ { name: 'My Cool Plugin', enabled: true, installPath: '/plugins/cool' }, ]); const manager = new AgentManager(VAULT_PATH, pluginManager); const pluginAgentsDir = path.join('/plugins/cool', 'agents'); mockFs.existsSync.mockImplementation((p) => p === pluginAgentsDir); (mockFs.readdirSync as jest.Mock).mockImplementation((dir: string) => { if (dir === pluginAgentsDir) { return [createMockDirent('agent.md', true)]; } return []; }); mockFs.readFileSync.mockReturnValue(PLUGIN_AGENT_FILE); await manager.loadAgents(); const pluginAgent = manager.getAvailableAgents().find(a => a.source === 'plugin'); expect(pluginAgent?.id).toBe('my-cool-plugin:code-reviewer'); }); it('skips duplicate plugin agent IDs', async () => { const pluginManager = createMockPluginManager([ { name: 'Plugin A', enabled: true, installPath: '/plugins/a' }, { name: 'Plugin A', enabled: true, installPath: '/plugins/a-copy' }, ]); const manager = new AgentManager(VAULT_PATH, pluginManager); mockFs.existsSync.mockReturnValue(true); (mockFs.readdirSync as jest.Mock).mockReturnValue([createMockDirent('agent.md', true)]); mockFs.readFileSync.mockReturnValue(PLUGIN_AGENT_FILE); await manager.loadAgents(); const pluginAgents = manager.getAvailableAgents().filter(a => a.source === 'plugin'); expect(pluginAgents.length).toBe(1); }); it('handles malformed plugin agent files gracefully', async () => { const pluginManager = createMockPluginManager([ { name: 'Bad Plugin', enabled: true, installPath: '/plugins/bad' }, ]); const manager = new AgentManager(VAULT_PATH, pluginManager); const pluginAgentsDir = path.join('/plugins/bad', 'agents'); mockFs.existsSync.mockImplementation((p) => p === pluginAgentsDir); (mockFs.readdirSync as jest.Mock).mockImplementation((dir: string) => { if (dir === pluginAgentsDir) { return [createMockDirent('broken.md', true)]; } return []; }); mockFs.readFileSync.mockImplementation(() => { throw new Error('Read error'); }); await manager.loadAgents(); const agents = manager.getAvailableAgents(); expect(agents.every(a => a.source !== 'plugin')).toBe(true); }); }); describe('getAvailableAgents', () => { it('returns a copy of the agents array', async () => { const manager = new AgentManager(VAULT_PATH, createMockPluginManager()); await manager.loadAgents(); const agents1 = manager.getAvailableAgents(); const agents2 = manager.getAvailableAgents(); expect(agents1).not.toBe(agents2); expect(agents1).toEqual(agents2); }); }); describe('getAgentById', () => { it('returns agent by exact ID match', async () => { const manager = new AgentManager(VAULT_PATH, createMockPluginManager()); await manager.loadAgents(); const agent = manager.getAgentById('Explore'); expect(agent).toBeDefined(); expect(agent?.id).toBe('Explore'); }); it('returns undefined for non-existent ID', async () => { const manager = new AgentManager(VAULT_PATH, createMockPluginManager()); await manager.loadAgents(); const agent = manager.getAgentById('NonExistent'); expect(agent).toBeUndefined(); }); }); describe('searchAgents', () => { it('searches by name (case-insensitive)', async () => { const manager = new AgentManager(VAULT_PATH, createMockPluginManager()); await manager.loadAgents(); const results = manager.searchAgents('explore'); expect(results.length).toBeGreaterThanOrEqual(1); expect(results[0].id).toBe('Explore'); }); it('searches by ID', async () => { const manager = new AgentManager(VAULT_PATH, createMockPluginManager()); await manager.loadAgents(); const results = manager.searchAgents('general-purpose'); expect(results.length).toBeGreaterThanOrEqual(1); expect(results.some(r => r.id === 'general-purpose')).toBe(true); }); it('searches by description', async () => { const manager = new AgentManager(VAULT_PATH, createMockPluginManager()); await manager.loadAgents(); const results = manager.searchAgents('codebase'); expect(results.length).toBeGreaterThanOrEqual(1); expect(results.some(r => r.id === 'Explore')).toBe(true); }); it('returns empty array for no matches', async () => { const manager = new AgentManager(VAULT_PATH, createMockPluginManager()); await manager.loadAgents(); const results = manager.searchAgents('xyznonexistent'); expect(results).toEqual([]); }); it('returns multiple matches', async () => { const manager = new AgentManager(VAULT_PATH, createMockPluginManager()); await manager.loadAgents(); // 'a' should match multiple built-in agents const results = manager.searchAgents('a'); expect(results.length).toBeGreaterThan(1); }); }); describe('agent with missing optional fields', () => { it('handles agents without tools specification', async () => { const manager = new AgentManager(VAULT_PATH, createMockPluginManager()); mockFs.existsSync.mockImplementation((p) => p === VAULT_AGENTS_DIR); (mockFs.readdirSync as jest.Mock).mockImplementation((dir: string) => { if (dir === VAULT_AGENTS_DIR) { return [createMockDirent('minimal.md', true)]; } return []; }); mockFs.readFileSync.mockReturnValue(MINIMAL_AGENT_FILE); await manager.loadAgents(); const agent = manager.getAgentById('MinimalAgent'); expect(agent).toBeDefined(); expect(agent?.tools).toBeUndefined(); expect(agent?.disallowedTools).toBeUndefined(); expect(agent?.model).toBe('inherit'); }); }); describe('setBuiltinAgentNames', () => { it('updates built-in agents from init message names', async () => { const manager = new AgentManager(VAULT_PATH, createMockPluginManager()); mockFs.existsSync.mockReturnValue(false); await manager.loadAgents(); const before = manager.getAvailableAgents(); expect(before.some(a => a.id === 'Explore')).toBe(true); // Update with new names from init manager.setBuiltinAgentNames(['Explore', 'Plan', 'Bash', 'general-purpose', 'new-agent']); const after = manager.getAvailableAgents(); expect(after.some(a => a.id === 'new-agent' && a.source === 'builtin')).toBe(true); }); it('excludes file-loaded agents from built-in list', async () => { const manager = new AgentManager(VAULT_PATH, createMockPluginManager()); // Vault has an agent file matching an init agent name mockFs.existsSync.mockReturnValue(true); (mockFs.readdirSync as jest.Mock).mockImplementation((dir: string) => { if (dir.includes('.claude/agents')) { return [createMockDirent('custom.md', true)]; } return []; }); mockFs.readFileSync.mockReturnValue(`--- name: custom-agent description: Custom vault agent --- Prompt.`); await manager.loadAgents(); // Set init names that include 'custom-agent' (matches vault agent) manager.setBuiltinAgentNames(['Explore', 'custom-agent']); const agents = manager.getAvailableAgents(); // custom-agent should be vault-sourced, not built-in const customAgent = agents.find(a => a.id === 'custom-agent'); expect(customAgent).toBeDefined(); expect(customAgent?.source).toBe('vault'); // Should not have a duplicate built-in 'custom-agent' const customAgents = agents.filter(a => a.id === 'custom-agent'); expect(customAgents).toHaveLength(1); }); }); }); ================================================ FILE: tests/unit/core/agents/AgentStorage.test.ts ================================================ import { buildAgentFromFrontmatter, parseAgentFile, parseModel, parsePermissionMode, parseToolsList } from '@/core/agents/AgentStorage'; describe('parseAgentFile', () => { it('parses valid frontmatter and body', () => { const content = `--- name: TestAgent description: Handles tests tools: [Read, Grep] disallowedTools: [Write] model: sonnet --- You are helpful.`; const parsed = parseAgentFile(content); expect(parsed).not.toBeNull(); expect(parsed?.frontmatter.name).toBe('TestAgent'); expect(parsed?.frontmatter.description).toBe('Handles tests'); expect(parsed?.frontmatter.tools).toEqual(['Read', 'Grep']); expect(parsed?.frontmatter.disallowedTools).toEqual(['Write']); expect(parsed?.frontmatter.model).toBe('sonnet'); expect(parsed?.body).toBe('You are helpful.'); }); it('rejects non-string name', () => { const content = `--- name: [NotAString] description: Valid description --- Body.`; expect(parseAgentFile(content)).toBeNull(); }); it('rejects non-string description', () => { const content = `--- name: ValidName description: [NotAString] --- Body.`; expect(parseAgentFile(content)).toBeNull(); }); it('rejects invalid tools type', () => { const content = `--- name: ValidName description: Valid description tools: true --- Body.`; expect(parseAgentFile(content)).toBeNull(); }); it('rejects invalid disallowedTools type', () => { const content = `--- name: ValidName description: Valid description disallowedTools: 123 --- Body.`; expect(parseAgentFile(content)).toBeNull(); }); it('returns null for content without frontmatter', () => { const content = 'Just some text without frontmatter'; expect(parseAgentFile(content)).toBeNull(); }); it('returns null for incomplete frontmatter markers', () => { const content = `--- name: TestAgent description: Test Body without closing markers.`; expect(parseAgentFile(content)).toBeNull(); }); it('returns null for missing required name field', () => { const content = `--- description: Valid description --- Body.`; expect(parseAgentFile(content)).toBeNull(); }); it('returns null for missing required description field', () => { const content = `--- name: ValidName --- Body.`; expect(parseAgentFile(content)).toBeNull(); }); it('returns null for empty name', () => { const content = `--- name: description: Valid description --- Body.`; expect(parseAgentFile(content)).toBeNull(); }); it('returns null for empty description', () => { const content = `--- name: ValidName description: --- Body.`; expect(parseAgentFile(content)).toBeNull(); }); it('accepts tools as comma-separated string', () => { const content = `--- name: TestAgent description: Test agent tools: Read, Grep, Glob --- Body.`; const parsed = parseAgentFile(content); expect(parsed).not.toBeNull(); expect(parsed?.frontmatter.tools).toBe('Read, Grep, Glob'); }); it('parses skills array', () => { const content = `--- name: TestAgent description: Test agent skills: [my-skill, another-skill] --- Body.`; const parsed = parseAgentFile(content); expect(parsed).not.toBeNull(); expect(parsed?.frontmatter.skills).toEqual(['my-skill', 'another-skill']); }); it('returns undefined for missing optional fields', () => { const content = `--- name: TestAgent description: Test agent --- Body.`; const parsed = parseAgentFile(content); expect(parsed).not.toBeNull(); expect(parsed?.frontmatter.skills).toBeUndefined(); }); it('parses permissionMode from frontmatter', () => { const content = `--- name: TestAgent description: Test agent permissionMode: dontAsk --- Body.`; const parsed = parseAgentFile(content); expect(parsed).not.toBeNull(); expect(parsed?.frontmatter.permissionMode).toBe('dontAsk'); }); it('returns undefined permissionMode when not set', () => { const content = `--- name: TestAgent description: Test agent --- Body.`; const parsed = parseAgentFile(content); expect(parsed).not.toBeNull(); expect(parsed?.frontmatter.permissionMode).toBeUndefined(); }); it('returns undefined hooks when not set', () => { const content = `--- name: TestAgent description: Test agent --- Body.`; const parsed = parseAgentFile(content); expect(parsed).not.toBeNull(); expect(parsed?.frontmatter.hooks).toBeUndefined(); }); it('collects unknown frontmatter keys into extraFrontmatter', () => { const content = `--- name: TestAgent description: Test agent maxTurns: 10 customKey: hello --- Body.`; const parsed = parseAgentFile(content); expect(parsed).not.toBeNull(); expect(parsed?.frontmatter.extraFrontmatter).toEqual({ maxTurns: 10, customKey: 'hello', }); }); it('sets extraFrontmatter to undefined when no unknown keys', () => { const content = `--- name: TestAgent description: Test agent --- Body.`; const parsed = parseAgentFile(content); expect(parsed).not.toBeNull(); expect(parsed?.frontmatter.extraFrontmatter).toBeUndefined(); }); it('ignores non-object hooks values', () => { const content = `--- name: TestAgent description: Test agent hooks: not-an-object --- Body.`; const parsed = parseAgentFile(content); expect(parsed).not.toBeNull(); expect(parsed?.frontmatter.hooks).toBeUndefined(); }); }); describe('parseToolsList', () => { it('returns undefined for undefined input', () => { expect(parseToolsList(undefined)).toBeUndefined(); }); it('returns undefined for empty string', () => { expect(parseToolsList('')).toBeUndefined(); }); it('returns undefined for whitespace-only string', () => { expect(parseToolsList(' ')).toBeUndefined(); }); it('parses comma-separated string into array', () => { expect(parseToolsList('Read, Grep, Glob')).toEqual(['Read', 'Grep', 'Glob']); }); it('trims whitespace from tool names', () => { expect(parseToolsList(' Read , Grep ')).toEqual(['Read', 'Grep']); }); it('filters out empty entries from comma-separated string', () => { expect(parseToolsList('Read,,Grep,')).toEqual(['Read', 'Grep']); }); it('returns empty array for empty array input', () => { expect(parseToolsList([])).toEqual([]); }); it('returns array as-is when already an array', () => { expect(parseToolsList(['Read', 'Grep'])).toEqual(['Read', 'Grep']); }); it('trims and filters array elements', () => { expect(parseToolsList([' Read ', '', ' Grep ', ''])).toEqual(['Read', 'Grep']); }); it('converts non-string array elements to strings', () => { // Edge case: if someone passes numbers in YAML array expect(parseToolsList([123 as unknown as string, 'Read'])).toEqual(['123', 'Read']); }); it('handles single tool in string format', () => { expect(parseToolsList('Read')).toEqual(['Read']); }); it('handles single tool in array format', () => { expect(parseToolsList(['Read'])).toEqual(['Read']); }); }); describe('parseModel', () => { it('returns inherit for undefined input', () => { expect(parseModel(undefined)).toBe('inherit'); }); it('returns inherit for empty string', () => { expect(parseModel('')).toBe('inherit'); }); it('returns sonnet for valid sonnet input', () => { expect(parseModel('sonnet')).toBe('sonnet'); }); it('returns opus for valid opus input', () => { expect(parseModel('opus')).toBe('opus'); }); it('returns haiku for valid haiku input', () => { expect(parseModel('haiku')).toBe('haiku'); }); it('returns inherit for valid inherit input', () => { expect(parseModel('inherit')).toBe('inherit'); }); it('is case-insensitive', () => { expect(parseModel('SONNET')).toBe('sonnet'); expect(parseModel('Opus')).toBe('opus'); expect(parseModel('HAIKU')).toBe('haiku'); expect(parseModel('INHERIT')).toBe('inherit'); }); it('trims whitespace', () => { expect(parseModel(' sonnet ')).toBe('sonnet'); }); it('returns inherit for invalid model value', () => { expect(parseModel('claude-3')).toBe('inherit'); expect(parseModel('gpt-4')).toBe('inherit'); expect(parseModel('invalid')).toBe('inherit'); }); }); describe('parsePermissionMode', () => { it('returns undefined for undefined input', () => { expect(parsePermissionMode(undefined)).toBeUndefined(); }); it('returns undefined for empty string', () => { expect(parsePermissionMode('')).toBeUndefined(); }); it('returns default for valid default input', () => { expect(parsePermissionMode('default')).toBe('default'); }); it('returns acceptEdits for valid input', () => { expect(parsePermissionMode('acceptEdits')).toBe('acceptEdits'); }); it('returns dontAsk for valid input', () => { expect(parsePermissionMode('dontAsk')).toBe('dontAsk'); }); it('returns bypassPermissions for valid input', () => { expect(parsePermissionMode('bypassPermissions')).toBe('bypassPermissions'); }); it('returns plan for valid input', () => { expect(parsePermissionMode('plan')).toBe('plan'); }); it('returns delegate for valid input', () => { expect(parsePermissionMode('delegate')).toBe('delegate'); }); it('returns undefined for invalid value', () => { expect(parsePermissionMode('invalid')).toBeUndefined(); expect(parsePermissionMode('DONTASK')).toBeUndefined(); expect(parsePermissionMode('dont-ask')).toBeUndefined(); }); it('trims whitespace', () => { expect(parsePermissionMode(' dontAsk ')).toBe('dontAsk'); }); }); describe('buildAgentFromFrontmatter', () => { it('maps all frontmatter fields to AgentDefinition', () => { const result = buildAgentFromFrontmatter( { name: 'Test', description: 'A test agent', tools: ['Read', 'Grep'], disallowedTools: ['Bash'], model: 'opus', skills: ['my-skill'], permissionMode: 'dontAsk', hooks: { preToolUse: { command: 'echo hi' } }, }, 'You are helpful.', { id: 'test', source: 'vault', filePath: '/path/to/test.md' } ); expect(result.id).toBe('test'); expect(result.name).toBe('Test'); expect(result.description).toBe('A test agent'); expect(result.prompt).toBe('You are helpful.'); expect(result.tools).toEqual(['Read', 'Grep']); expect(result.disallowedTools).toEqual(['Bash']); expect(result.model).toBe('opus'); expect(result.source).toBe('vault'); expect(result.filePath).toBe('/path/to/test.md'); expect(result.skills).toEqual(['my-skill']); expect(result.permissionMode).toBe('dontAsk'); expect(result.hooks).toEqual({ preToolUse: { command: 'echo hi' } }); }); it('propagates pluginName from meta', () => { const result = buildAgentFromFrontmatter( { name: 'PluginAgent', description: 'From plugin' }, 'Prompt.', { id: 'my-plugin:agent', source: 'plugin', pluginName: 'my-plugin' } ); expect(result.pluginName).toBe('my-plugin'); expect(result.source).toBe('plugin'); }); it('defaults model to inherit for invalid value', () => { const result = buildAgentFromFrontmatter( { name: 'Test', description: 'Desc', model: 'gpt-4' }, 'Prompt.', { id: 'test', source: 'vault' } ); expect(result.model).toBe('inherit'); }); it('returns undefined permissionMode for invalid value', () => { const result = buildAgentFromFrontmatter( { name: 'Test', description: 'Desc', permissionMode: 'INVALID' }, 'Prompt.', { id: 'test', source: 'vault' } ); expect(result.permissionMode).toBeUndefined(); }); }); ================================================ FILE: tests/unit/core/agents/index.test.ts ================================================ import { AgentManager } from '@/core/agents'; import { buildAgentFromFrontmatter, parseAgentFile } from '@/core/agents'; describe('core/agents index', () => { it('re-exports runtime symbols', () => { expect(AgentManager).toBeDefined(); expect(buildAgentFromFrontmatter).toBeDefined(); expect(parseAgentFile).toBeDefined(); }); }); ================================================ FILE: tests/unit/core/commands/builtInCommands.test.ts ================================================ import { BUILT_IN_COMMANDS, detectBuiltInCommand, getBuiltInCommandsForDropdown, } from '../../../../src/core/commands/builtInCommands'; describe('builtInCommands', () => { describe('detectBuiltInCommand', () => { it('detects /clear command', () => { const result = detectBuiltInCommand('/clear'); expect(result).not.toBeNull(); expect(result?.command.name).toBe('clear'); expect(result?.command.action).toBe('clear'); expect(result?.args).toBe(''); }); it('detects /new command as alias for clear', () => { const result = detectBuiltInCommand('/new'); expect(result).not.toBeNull(); expect(result?.command.name).toBe('clear'); expect(result?.command.action).toBe('clear'); }); it('is case-insensitive', () => { expect(detectBuiltInCommand('/CLEAR')).not.toBeNull(); expect(detectBuiltInCommand('/Clear')).not.toBeNull(); expect(detectBuiltInCommand('/NEW')).not.toBeNull(); }); it('detects command with trailing whitespace', () => { const result = detectBuiltInCommand('/clear '); expect(result).not.toBeNull(); expect(result?.command.name).toBe('clear'); expect(result?.args).toBe(''); }); it('detects command with arguments', () => { const result = detectBuiltInCommand('/clear some arguments'); expect(result).not.toBeNull(); expect(result?.command.name).toBe('clear'); expect(result?.args).toBe('some arguments'); }); it('detects /add-dir command with path argument', () => { const result = detectBuiltInCommand('/add-dir /path/to/dir'); expect(result).not.toBeNull(); expect(result?.command.name).toBe('add-dir'); expect(result?.command.action).toBe('add-dir'); expect(result?.args).toBe('/path/to/dir'); }); it('detects /add-dir command with home path', () => { const result = detectBuiltInCommand('/add-dir ~/projects'); expect(result).not.toBeNull(); expect(result?.command.name).toBe('add-dir'); expect(result?.args).toBe('~/projects'); }); it('returns null for non-slash input', () => { expect(detectBuiltInCommand('clear')).toBeNull(); expect(detectBuiltInCommand('hello /clear')).toBeNull(); }); it('returns null for unknown commands', () => { expect(detectBuiltInCommand('/unknown')).toBeNull(); expect(detectBuiltInCommand('/foo')).toBeNull(); }); it('returns null for empty input', () => { expect(detectBuiltInCommand('')).toBeNull(); expect(detectBuiltInCommand(' ')).toBeNull(); }); it('returns null for just slash', () => { expect(detectBuiltInCommand('/')).toBeNull(); }); it('detects /resume command', () => { const result = detectBuiltInCommand('/resume'); expect(result).not.toBeNull(); expect(result?.command.name).toBe('resume'); expect(result?.command.action).toBe('resume'); expect(result?.args).toBe(''); }); it('detects /fork command', () => { const result = detectBuiltInCommand('/fork'); expect(result).not.toBeNull(); expect(result?.command.name).toBe('fork'); expect(result?.command.action).toBe('fork'); expect(result?.args).toBe(''); }); it('detects /fork case-insensitively', () => { expect(detectBuiltInCommand('/FORK')).not.toBeNull(); expect(detectBuiltInCommand('/Fork')).not.toBeNull(); }); }); describe('getBuiltInCommandsForDropdown', () => { it('returns all built-in commands with proper format', () => { const commands = getBuiltInCommandsForDropdown(); expect(commands.length).toBe(BUILT_IN_COMMANDS.length); const clearCmd = commands.find((c) => c.name === 'clear'); expect(clearCmd).toBeDefined(); expect(clearCmd?.id).toBe('builtin:clear'); expect(clearCmd?.description).toBe('Start a new conversation'); expect(clearCmd?.content).toBe(''); }); it('returns commands compatible with SlashCommand interface', () => { const commands = getBuiltInCommandsForDropdown(); for (const cmd of commands) { expect(cmd).toHaveProperty('id'); expect(cmd).toHaveProperty('name'); expect(cmd).toHaveProperty('description'); expect(cmd).toHaveProperty('content'); } }); }); describe('BUILT_IN_COMMANDS', () => { it('has clear command with new alias', () => { const clearCmd = BUILT_IN_COMMANDS.find((c) => c.name === 'clear'); expect(clearCmd).toBeDefined(); expect(clearCmd?.aliases).toContain('new'); expect(clearCmd?.action).toBe('clear'); }); it('has add-dir command that accepts args', () => { const addDirCmd = BUILT_IN_COMMANDS.find((c) => c.name === 'add-dir'); expect(addDirCmd).toBeDefined(); expect(addDirCmd?.action).toBe('add-dir'); expect(addDirCmd?.hasArgs).toBe(true); expect(addDirCmd?.description).toBe('Add external context directory'); }); it('has resume command', () => { const resumeCmd = BUILT_IN_COMMANDS.find((c) => c.name === 'resume'); expect(resumeCmd).toBeDefined(); expect(resumeCmd?.action).toBe('resume'); expect(resumeCmd?.description).toBe('Resume a previous conversation'); }); it('has fork command without args', () => { const forkCmd = BUILT_IN_COMMANDS.find((c) => c.name === 'fork'); expect(forkCmd).toBeDefined(); expect(forkCmd?.action).toBe('fork'); expect(forkCmd?.hasArgs).toBeUndefined(); }); }); }); ================================================ FILE: tests/unit/core/hooks/SecurityHooks.test.ts ================================================ import { type BlocklistContext, createBlocklistHook, createVaultRestrictionHook, type VaultRestrictionContext, } from '@/core/hooks/SecurityHooks'; import type { PathAccessType } from '@/utils/path'; describe('SecurityHooks', () => { describe('createBlocklistHook', () => { const createHookInput = (command: string) => ({ hook_event_name: 'PreToolUse' as const, session_id: 'test-session', transcript_path: '/tmp/transcript', cwd: '/vault', tool_name: 'Bash', tool_input: { command }, tool_use_id: 'tool-1', }); it('blocks commands in the blocklist when blocklist is enabled', async () => { const context: BlocklistContext = { blockedCommands: { unix: ['rm -rf', 'chmod 777'], windows: [], }, enableBlocklist: true, }; const hook = createBlocklistHook(() => context); const result = await hook.hooks[0]( createHookInput('rm -rf /'), 'tool-1', { signal: new AbortController().signal } ); expect(result).toEqual({ continue: false, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: expect.stringContaining('Command blocked by blocklist'), }, }); }); it('allows commands not in the blocklist', async () => { const context: BlocklistContext = { blockedCommands: { unix: ['rm -rf'], windows: [], }, enableBlocklist: true, }; const hook = createBlocklistHook(() => context); const result = await hook.hooks[0]( createHookInput('ls -la'), 'tool-1', { signal: new AbortController().signal } ); expect(result).toEqual({ continue: true }); }); it('allows all commands when blocklist is disabled', async () => { const context: BlocklistContext = { blockedCommands: { unix: ['rm -rf'], windows: [], }, enableBlocklist: false, }; const hook = createBlocklistHook(() => context); const result = await hook.hooks[0]( createHookInput('rm -rf /'), 'tool-1', { signal: new AbortController().signal } ); expect(result).toEqual({ continue: true }); }); it('handles empty command', async () => { const context: BlocklistContext = { blockedCommands: { unix: ['rm -rf'], windows: [], }, enableBlocklist: true, }; const hook = createBlocklistHook(() => context); const result = await hook.hooks[0]( createHookInput(''), 'tool-1', { signal: new AbortController().signal } ); expect(result).toEqual({ continue: true }); }); it('handles undefined command', async () => { const context: BlocklistContext = { blockedCommands: { unix: ['rm -rf'], windows: [], }, enableBlocklist: true, }; const hook = createBlocklistHook(() => context); const result = await hook.hooks[0]( { hook_event_name: 'PreToolUse' as const, session_id: 'test-session', transcript_path: '/tmp/transcript', cwd: '/vault', tool_name: 'Bash', tool_input: {}, tool_use_id: 'tool-1', }, 'tool-1', { signal: new AbortController().signal } ); expect(result).toEqual({ continue: true }); }); it('matcher is set to Bash tool', () => { const hook = createBlocklistHook(() => ({ blockedCommands: { unix: [], windows: [] }, enableBlocklist: true, })); expect(hook.matcher).toBe('Bash'); }); }); describe('createVaultRestrictionHook', () => { const createHookInput = (toolName: string, toolInput: Record<string, unknown>) => ({ hook_event_name: 'PreToolUse' as const, session_id: 'test-session', transcript_path: '/tmp/transcript', cwd: '/vault', tool_name: toolName, tool_input: toolInput, tool_use_id: 'tool-1', }); describe('Bash commands', () => { it('allows Bash commands with paths inside vault', async () => { const context: VaultRestrictionContext = { getPathAccessType: (path: string): PathAccessType => { if (path.startsWith('/vault')) return 'vault'; return 'none'; }, }; const hook = createVaultRestrictionHook(context); const result = await hook.hooks[0]( createHookInput('Bash', { command: 'cat /vault/file.txt' }), 'tool-1', { signal: new AbortController().signal } ); expect(result).toEqual({ continue: true }); }); it('blocks Bash commands with paths outside vault', async () => { const context: VaultRestrictionContext = { getPathAccessType: (): PathAccessType => 'none', }; const hook = createVaultRestrictionHook(context); const result = await hook.hooks[0]( createHookInput('Bash', { command: 'cat /etc/passwd' }), 'tool-1', { signal: new AbortController().signal } ); expect(result).toEqual({ continue: false, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: expect.stringContaining('outside the vault'), }, }); }); it('blocks read from export paths in Bash commands', async () => { const context: VaultRestrictionContext = { getPathAccessType: (path: string): PathAccessType => { if (path.includes('Desktop')) return 'export'; return 'none'; }, }; const hook = createVaultRestrictionHook(context); const result = await hook.hooks[0]( createHookInput('Bash', { command: 'cat ~/Desktop/file.txt' }), 'tool-1', { signal: new AbortController().signal } ); expect(result).toEqual({ continue: false, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: expect.stringContaining('write-only'), }, }); }); }); describe('File tools', () => { it('allows Read tool with vault paths', async () => { const context: VaultRestrictionContext = { getPathAccessType: (): PathAccessType => 'vault', }; const hook = createVaultRestrictionHook(context); const result = await hook.hooks[0]( createHookInput('Read', { file_path: '/vault/notes/test.md' }), 'tool-1', { signal: new AbortController().signal } ); expect(result).toEqual({ continue: true }); }); it('allows Read tool with readwrite paths', async () => { const context: VaultRestrictionContext = { getPathAccessType: (): PathAccessType => 'readwrite', }; const hook = createVaultRestrictionHook(context); const result = await hook.hooks[0]( createHookInput('Read', { file_path: '/external/project/src/file.ts' }), 'tool-1', { signal: new AbortController().signal } ); expect(result).toEqual({ continue: true }); }); it('allows Read tool with context paths', async () => { const context: VaultRestrictionContext = { getPathAccessType: (): PathAccessType => 'context', }; const hook = createVaultRestrictionHook(context); const result = await hook.hooks[0]( createHookInput('Read', { file_path: '/context/file.ts' }), 'tool-1', { signal: new AbortController().signal } ); expect(result).toEqual({ continue: true }); }); it('blocks Read tool with export paths (write-only)', async () => { const context: VaultRestrictionContext = { getPathAccessType: (): PathAccessType => 'export', }; const hook = createVaultRestrictionHook(context); const result = await hook.hooks[0]( createHookInput('Read', { file_path: '~/Desktop/exported.pdf' }), 'tool-1', { signal: new AbortController().signal } ); expect(result).toEqual({ continue: false, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: expect.stringContaining('write-only'), }, }); }); it('blocks Read tool with blocked paths', async () => { const context: VaultRestrictionContext = { getPathAccessType: (): PathAccessType => 'none', }; const hook = createVaultRestrictionHook(context); const result = await hook.hooks[0]( createHookInput('Read', { file_path: '/etc/passwd' }), 'tool-1', { signal: new AbortController().signal } ); expect(result).toEqual({ continue: false, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: expect.stringContaining('outside the vault'), }, }); }); it('allows Write tool with export paths', async () => { const context: VaultRestrictionContext = { getPathAccessType: (): PathAccessType => 'export', }; const hook = createVaultRestrictionHook(context); const result = await hook.hooks[0]( createHookInput('Write', { file_path: '~/Desktop/output.pdf', content: 'data' }), 'tool-1', { signal: new AbortController().signal } ); expect(result).toEqual({ continue: true }); }); it('allows Edit tool with export paths', async () => { const context: VaultRestrictionContext = { getPathAccessType: (): PathAccessType => 'export', }; const hook = createVaultRestrictionHook(context); const result = await hook.hooks[0]( createHookInput('Edit', { file_path: '~/Downloads/file.txt', old_string: 'a', new_string: 'b' }), 'tool-1', { signal: new AbortController().signal } ); expect(result).toEqual({ continue: true }); }); it('blocks Glob tool with blocked paths', async () => { const context: VaultRestrictionContext = { getPathAccessType: (): PathAccessType => 'none', }; const hook = createVaultRestrictionHook(context); const result = await hook.hooks[0]( createHookInput('Glob', { path: '/home/user/secrets', pattern: '*.key' }), 'tool-1', { signal: new AbortController().signal } ); expect(result).toEqual({ continue: false, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: expect.stringContaining('outside the vault'), }, }); }); it('blocks Grep tool with blocked paths', async () => { const context: VaultRestrictionContext = { getPathAccessType: (): PathAccessType => 'none', }; const hook = createVaultRestrictionHook(context); const result = await hook.hooks[0]( createHookInput('Grep', { path: '/var/log', pattern: 'password' }), 'tool-1', { signal: new AbortController().signal } ); expect(result).toEqual({ continue: false, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: expect.stringContaining('outside the vault'), }, }); }); }); describe('Non-file tools', () => { it('allows non-file tools without path checking', async () => { const context: VaultRestrictionContext = { getPathAccessType: jest.fn(), }; const hook = createVaultRestrictionHook(context); const result = await hook.hooks[0]( createHookInput('WebSearch', { query: 'test query' }), 'tool-1', { signal: new AbortController().signal } ); expect(result).toEqual({ continue: true }); expect(context.getPathAccessType).not.toHaveBeenCalled(); }); it('allows Agent tool without path checking', async () => { const context: VaultRestrictionContext = { getPathAccessType: jest.fn(), }; const hook = createVaultRestrictionHook(context); const result = await hook.hooks[0]( createHookInput('Agent', { prompt: 'analyze codebase', subagent_type: 'Explore' }), 'tool-1', { signal: new AbortController().signal } ); expect(result).toEqual({ continue: true }); expect(context.getPathAccessType).not.toHaveBeenCalled(); }); it('allows legacy Task tool without path checking', async () => { const context: VaultRestrictionContext = { getPathAccessType: jest.fn(), }; const hook = createVaultRestrictionHook(context); const result = await hook.hooks[0]( createHookInput('Task', { prompt: 'analyze codebase', subagent_type: 'Explore' }), 'tool-1', { signal: new AbortController().signal } ); expect(result).toEqual({ continue: true }); expect(context.getPathAccessType).not.toHaveBeenCalled(); }); it('allows TodoWrite tool without path checking', async () => { const context: VaultRestrictionContext = { getPathAccessType: jest.fn(), }; const hook = createVaultRestrictionHook(context); const result = await hook.hooks[0]( createHookInput('TodoWrite', { todos: [] }), 'tool-1', { signal: new AbortController().signal } ); expect(result).toEqual({ continue: true }); expect(context.getPathAccessType).not.toHaveBeenCalled(); }); }); describe('Edge cases', () => { it('allows file tools without path in input', async () => { const context: VaultRestrictionContext = { getPathAccessType: jest.fn(), }; const hook = createVaultRestrictionHook(context); const result = await hook.hooks[0]( createHookInput('Read', {}), 'tool-1', { signal: new AbortController().signal } ); expect(result).toEqual({ continue: true }); }); it('handles LS tool with path', async () => { const context: VaultRestrictionContext = { getPathAccessType: (): PathAccessType => 'vault', }; const hook = createVaultRestrictionHook(context); const result = await hook.hooks[0]( createHookInput('LS', { path: '/vault/src' }), 'tool-1', { signal: new AbortController().signal } ); expect(result).toEqual({ continue: true }); }); it('handles NotebookEdit tool', async () => { const context: VaultRestrictionContext = { getPathAccessType: (): PathAccessType => 'vault', }; const hook = createVaultRestrictionHook(context); const result = await hook.hooks[0]( createHookInput('NotebookEdit', { notebook_path: '/vault/notebook.ipynb', new_source: 'code' }), 'tool-1', { signal: new AbortController().signal } ); expect(result).toEqual({ continue: true }); }); it('no matcher set (applies to all tools)', () => { const hook = createVaultRestrictionHook({ getPathAccessType: () => 'vault', }); expect(hook.matcher).toBeUndefined(); }); }); }); }); ================================================ FILE: tests/unit/core/hooks/SubagentHooks.test.ts ================================================ import { createStopSubagentHook, type SubagentHookState, } from '@/core/hooks/SubagentHooks'; describe('SubagentHooks', () => { describe('createStopSubagentHook', () => { const createHookInput = () => ({ hook_event_name: 'Stop' as const, session_id: 'test-session', transcript_path: '/tmp/transcript', cwd: '/vault', stop_hook_active: true, }); it('allows stop when no running subagents', async () => { const state: SubagentHookState = { hasRunning: false, }; const hook = createStopSubagentHook(() => state); const result = await hook.hooks[0](createHookInput(), undefined, { signal: new AbortController().signal }); expect(result).toEqual({}); }); it('blocks stop when subagents are still running', async () => { const state: SubagentHookState = { hasRunning: true, }; const hook = createStopSubagentHook(() => state); const result = await hook.hooks[0](createHookInput(), undefined, { signal: new AbortController().signal }); expect(result).toEqual({ decision: 'block', reason: expect.stringContaining('still running'), }); expect((result as any).reason).toContain('TaskOutput'); }); it('resolves state dynamically at execution time', async () => { let running = true; const getState = (): SubagentHookState => ({ hasRunning: running, }); const hook = createStopSubagentHook(getState); const opts = { signal: new AbortController().signal }; const result1 = await hook.hooks[0](createHookInput(), undefined, opts); expect((result1 as any).decision).toBe('block'); running = false; const result2 = await hook.hooks[0](createHookInput(), undefined, opts); expect(result2).toEqual({}); }); it('fails closed when reading subagent state throws', async () => { const hook = createStopSubagentHook(() => { throw new Error('tab already torn down'); }); const result = await hook.hooks[0]( createHookInput(), undefined, { signal: new AbortController().signal } ); expect(result).toEqual({ decision: 'block', reason: expect.stringContaining('still running'), }); }); it('has no matcher (applies to all stop events)', () => { const hook = createStopSubagentHook( () => ({ hasRunning: false }) ); expect(hook.matcher).toBeUndefined(); }); }); }); ================================================ FILE: tests/unit/core/mcp/McpServerManager.test.ts ================================================ import { McpServerManager } from '@/core/mcp'; import type { ClaudianMcpServer } from '@/core/types'; const createManager = async (servers: ClaudianMcpServer[]) => { const manager = new McpServerManager({ load: async () => servers, }); await manager.loadServers(); return manager; }; describe('McpServerManager', () => { describe('getDisallowedMcpTools', () => { it('returns empty array when no servers are loaded', async () => { const manager = await createManager([]); expect(manager.getDisallowedMcpTools(new Set())).toEqual([]); }); it('formats disabled tools for enabled servers', async () => { const manager = await createManager([ { name: 'alpha', config: { command: 'alpha-cmd' }, enabled: true, contextSaving: false, disabledTools: ['tool_a', 'tool_b'], }, ]); expect(manager.getDisallowedMcpTools(new Set())).toEqual([ 'mcp__alpha__tool_a', 'mcp__alpha__tool_b', ]); }); it('skips disabled servers', async () => { const manager = await createManager([ { name: 'alpha', config: { command: 'alpha-cmd' }, enabled: false, contextSaving: false, disabledTools: ['tool_a'], }, { name: 'beta', config: { command: 'beta-cmd' }, enabled: true, contextSaving: false, disabledTools: ['tool_b'], }, ]); expect(manager.getDisallowedMcpTools(new Set())).toEqual(['mcp__beta__tool_b']); }); it('trims tool names and ignores blanks', async () => { const manager = await createManager([ { name: 'alpha', config: { command: 'alpha-cmd' }, enabled: true, contextSaving: false, disabledTools: [' tool_a ', ''], }, ]); expect(manager.getDisallowedMcpTools(new Set())).toEqual(['mcp__alpha__tool_a']); }); it('skips context-saving servers not mentioned', async () => { const manager = await createManager([ { name: 'alpha', config: { command: 'alpha-cmd' }, enabled: true, contextSaving: true, disabledTools: ['tool_a'], }, { name: 'beta', config: { command: 'beta-cmd' }, enabled: true, contextSaving: false, disabledTools: ['tool_b'], }, ]); expect(manager.getDisallowedMcpTools(new Set())).toEqual(['mcp__beta__tool_b']); }); it('includes context-saving servers when mentioned', async () => { const manager = await createManager([ { name: 'alpha', config: { command: 'alpha-cmd' }, enabled: true, contextSaving: true, disabledTools: ['tool_a'], }, { name: 'beta', config: { command: 'beta-cmd' }, enabled: true, contextSaving: false, disabledTools: ['tool_b'], }, ]); expect(manager.getDisallowedMcpTools(new Set(['alpha']))).toEqual([ 'mcp__alpha__tool_a', 'mcp__beta__tool_b', ]); }); }); describe('getActiveServers', () => { it('returns empty record when no servers loaded', async () => { const manager = await createManager([]); expect(manager.getActiveServers(new Set())).toEqual({}); }); it('returns enabled non-context-saving servers', async () => { const manager = await createManager([ { name: 'alpha', config: { command: 'alpha-cmd', args: ['--flag'] }, enabled: true, contextSaving: false, }, ]); const result = manager.getActiveServers(new Set()); expect(result).toEqual({ alpha: { command: 'alpha-cmd', args: ['--flag'] }, }); }); it('excludes disabled servers', async () => { const manager = await createManager([ { name: 'disabled-one', config: { command: 'cmd' }, enabled: false, contextSaving: false, }, { name: 'enabled-one', config: { command: 'cmd2' }, enabled: true, contextSaving: false, }, ]); const result = manager.getActiveServers(new Set()); expect(Object.keys(result)).toEqual(['enabled-one']); }); it('excludes context-saving servers not mentioned', async () => { const manager = await createManager([ { name: 'ctx-server', config: { command: 'ctx-cmd' }, enabled: true, contextSaving: true, }, { name: 'normal-server', config: { command: 'normal-cmd' }, enabled: true, contextSaving: false, }, ]); const result = manager.getActiveServers(new Set()); expect(Object.keys(result)).toEqual(['normal-server']); }); it('includes context-saving servers when mentioned', async () => { const manager = await createManager([ { name: 'ctx-server', config: { command: 'ctx-cmd' }, enabled: true, contextSaving: true, }, ]); const result = manager.getActiveServers(new Set(['ctx-server'])); expect(result).toEqual({ 'ctx-server': { command: 'ctx-cmd' } }); }); }); describe('getAllDisallowedMcpTools', () => { it('returns empty array when no servers', async () => { const manager = await createManager([]); expect(manager.getAllDisallowedMcpTools()).toEqual([]); }); it('includes disabled tools from all enabled servers regardless of context-saving', async () => { const manager = await createManager([ { name: 'ctx-server', config: { command: 'cmd' }, enabled: true, contextSaving: true, disabledTools: ['tool_x'], }, { name: 'normal-server', config: { command: 'cmd2' }, enabled: true, contextSaving: false, disabledTools: ['tool_y'], }, ]); const result = manager.getAllDisallowedMcpTools(); expect(result).toEqual([ 'mcp__ctx-server__tool_x', 'mcp__normal-server__tool_y', ]); }); it('skips disabled servers', async () => { const manager = await createManager([ { name: 'off', config: { command: 'cmd' }, enabled: false, contextSaving: false, disabledTools: ['tool_a'], }, ]); expect(manager.getAllDisallowedMcpTools()).toEqual([]); }); it('returns sorted results', async () => { const manager = await createManager([ { name: 'beta', config: { command: 'cmd' }, enabled: true, contextSaving: false, disabledTools: ['zzz'], }, { name: 'alpha', config: { command: 'cmd' }, enabled: true, contextSaving: false, disabledTools: ['aaa'], }, ]); const result = manager.getAllDisallowedMcpTools(); expect(result).toEqual(['mcp__alpha__aaa', 'mcp__beta__zzz']); }); it('skips servers with no disabledTools', async () => { const manager = await createManager([ { name: 'no-disabled', config: { command: 'cmd' }, enabled: true, contextSaving: false, }, { name: 'empty-disabled', config: { command: 'cmd' }, enabled: true, contextSaving: false, disabledTools: [], }, ]); expect(manager.getAllDisallowedMcpTools()).toEqual([]); }); }); describe('getEnabledCount', () => { it('returns 0 for no servers', async () => { const manager = await createManager([]); expect(manager.getEnabledCount()).toBe(0); }); it('counts only enabled servers', async () => { const manager = await createManager([ { name: 'a', config: { command: 'a' }, enabled: true, contextSaving: false }, { name: 'b', config: { command: 'b' }, enabled: false, contextSaving: false }, { name: 'c', config: { command: 'c' }, enabled: true, contextSaving: false }, ]); expect(manager.getEnabledCount()).toBe(2); }); }); describe('hasServers', () => { it('returns false for empty', async () => { const manager = await createManager([]); expect(manager.hasServers()).toBe(false); }); it('returns true when servers exist', async () => { const manager = await createManager([ { name: 'a', config: { command: 'a' }, enabled: false, contextSaving: false }, ]); expect(manager.hasServers()).toBe(true); }); }); describe('getServers', () => { it('returns loaded servers', async () => { const servers: ClaudianMcpServer[] = [ { name: 'x', config: { command: 'x' }, enabled: true, contextSaving: false }, ]; const manager = await createManager(servers); expect(manager.getServers()).toEqual(servers); }); }); describe('getContextSavingServers', () => { it('returns only enabled context-saving servers', async () => { const manager = await createManager([ { name: 'ctx-on', config: { command: 'a' }, enabled: true, contextSaving: true }, { name: 'ctx-off', config: { command: 'b' }, enabled: true, contextSaving: false }, { name: 'disabled-ctx', config: { command: 'c' }, enabled: false, contextSaving: true }, ]); const result = manager.getContextSavingServers(); expect(result).toHaveLength(1); expect(result[0].name).toBe('ctx-on'); }); }); describe('extractMentions', () => { it('extracts mentions of context-saving servers from text', async () => { const manager = await createManager([ { name: 'my-server', config: { command: 'a' }, enabled: true, contextSaving: true }, { name: 'other', config: { command: 'b' }, enabled: true, contextSaving: false }, ]); const mentions = manager.extractMentions('please use @my-server for this'); expect(mentions).toEqual(new Set(['my-server'])); }); it('ignores mentions of non-context-saving servers', async () => { const manager = await createManager([ { name: 'normal', config: { command: 'a' }, enabled: true, contextSaving: false }, ]); const mentions = manager.extractMentions('@normal do something'); expect(mentions.size).toBe(0); }); it('ignores mentions of disabled context-saving servers', async () => { const manager = await createManager([ { name: 'disabled-ctx', config: { command: 'a' }, enabled: false, contextSaving: true }, ]); const mentions = manager.extractMentions('@disabled-ctx'); expect(mentions.size).toBe(0); }); }); describe('transformMentions', () => { it('appends MCP after context-saving server mentions', async () => { const manager = await createManager([ { name: 'my-server', config: { command: 'a' }, enabled: true, contextSaving: true }, ]); const result = manager.transformMentions('use @my-server please'); expect(result).toBe('use @my-server MCP please'); }); it('does not transform non-context-saving server mentions', async () => { const manager = await createManager([ { name: 'normal', config: { command: 'a' }, enabled: true, contextSaving: false }, ]); const result = manager.transformMentions('use @normal please'); expect(result).toBe('use @normal please'); }); it('returns text unchanged when no context-saving servers', async () => { const manager = await createManager([]); const text = 'hello @world'; expect(manager.transformMentions(text)).toBe(text); }); }); describe('loadServers', () => { it('populates servers from storage', async () => { const servers: ClaudianMcpServer[] = [ { name: 'a', config: { command: 'cmd-a' }, enabled: true, contextSaving: false }, { name: 'b', config: { type: 'sse', url: 'http://localhost' }, enabled: false, contextSaving: true }, ]; const manager = new McpServerManager({ load: async () => servers }); expect(manager.getServers()).toEqual([]); await manager.loadServers(); expect(manager.getServers()).toEqual(servers); }); }); }); ================================================ FILE: tests/unit/core/mcp/McpTester.test.ts ================================================ import { testMcpServer } from '@/core/mcp/McpTester'; import type { ClaudianMcpServer } from '@/core/types'; // Mock the MCP SDK transports and client jest.mock('@modelcontextprotocol/sdk/client', () => ({ Client: jest.fn().mockImplementation(() => ({ connect: jest.fn(), getServerVersion: jest.fn().mockReturnValue({ name: 'test-server', version: '1.0.0' }), listTools: jest.fn().mockResolvedValue({ tools: [ { name: 'tool1', description: 'A test tool', inputSchema: { type: 'object' } }, { name: 'tool2' }, ], }), close: jest.fn(), })), })); jest.mock('@modelcontextprotocol/sdk/client/sse', () => ({ SSEClientTransport: jest.fn(), })); jest.mock('@modelcontextprotocol/sdk/client/stdio', () => ({ StdioClientTransport: jest.fn(), })); jest.mock('@modelcontextprotocol/sdk/client/streamableHttp', () => ({ StreamableHTTPClientTransport: jest.fn(), })); jest.mock('@/utils/env', () => ({ getEnhancedPath: jest.fn((p?: string) => p || '/usr/bin'), })); jest.mock('@/utils/mcp', () => ({ parseCommand: jest.fn((cmd: string, args?: string[]) => { if (args && args.length > 0) return { cmd, args }; const parts = cmd.split(' '); return { cmd: parts[0] || '', args: parts.slice(1) }; }), })); describe('testMcpServer', () => { beforeEach(() => { jest.clearAllMocks(); }); describe('stdio server', () => { it('should connect and return tools for a valid stdio server', async () => { const server: ClaudianMcpServer = { name: 'test', config: { command: 'node server.js', args: ['--port', '3000'] }, enabled: true, contextSaving: false, }; const result = await testMcpServer(server); expect(result.success).toBe(true); expect(result.serverName).toBe('test-server'); expect(result.serverVersion).toBe('1.0.0'); expect(result.tools).toHaveLength(2); expect(result.tools[0].name).toBe('tool1'); expect(result.tools[0].description).toBe('A test tool'); expect(result.tools[1].name).toBe('tool2'); }); it('should return error for missing command', async () => { const { parseCommand } = jest.requireMock('@/utils/mcp'); parseCommand.mockReturnValueOnce({ cmd: '', args: [] }); const server: ClaudianMcpServer = { name: 'empty', config: { command: '' }, enabled: true, contextSaving: false, }; const result = await testMcpServer(server); expect(result.success).toBe(false); expect(result.error).toBe('Missing command'); expect(result.tools).toEqual([]); }); }); describe('sse server', () => { it('should connect to an SSE server', async () => { const { SSEClientTransport } = jest.requireMock('@modelcontextprotocol/sdk/client/sse'); const server: ClaudianMcpServer = { name: 'sse-test', config: { type: 'sse' as const, url: 'https://example.com/sse' }, enabled: true, contextSaving: false, }; const result = await testMcpServer(server); expect(result.success).toBe(true); expect(result.tools).toHaveLength(2); expect(SSEClientTransport).toHaveBeenCalledWith( expect.any(URL), expect.objectContaining({ fetch: expect.any(Function), }), ); }); }); describe('http server', () => { it('should connect to an HTTP server', async () => { const { StreamableHTTPClientTransport } = jest.requireMock('@modelcontextprotocol/sdk/client/streamableHttp'); const server: ClaudianMcpServer = { name: 'http-test', config: { type: 'http' as const, url: 'https://example.com/api' }, enabled: true, contextSaving: false, }; const result = await testMcpServer(server); expect(result.success).toBe(true); expect(StreamableHTTPClientTransport).toHaveBeenCalledWith( expect.any(URL), expect.objectContaining({ fetch: expect.any(Function), }), ); }); it('should pass headers when configured', async () => { const { StreamableHTTPClientTransport } = jest.requireMock('@modelcontextprotocol/sdk/client/streamableHttp'); const server: ClaudianMcpServer = { name: 'http-auth', config: { type: 'http' as const, url: 'https://example.com/api', headers: { Authorization: 'Bearer token' }, }, enabled: true, contextSaving: false, }; const result = await testMcpServer(server); expect(result.success).toBe(true); expect(StreamableHTTPClientTransport).toHaveBeenCalledWith( expect.any(URL), expect.objectContaining({ fetch: expect.any(Function), requestInit: { headers: { Authorization: 'Bearer token' } }, }), ); }); }); describe('error handling', () => { it('should return error when transport creation fails', async () => { const { SSEClientTransport } = jest.requireMock('@modelcontextprotocol/sdk/client/sse'); SSEClientTransport.mockImplementationOnce(() => { throw new Error('Transport init failed'); }); const server: ClaudianMcpServer = { name: 'bad-sse', config: { type: 'sse' as const, url: 'https://example.com/sse' }, enabled: true, contextSaving: false, }; const result = await testMcpServer(server); expect(result.success).toBe(false); expect(result.error).toBe('Transport init failed'); expect(result.tools).toEqual([]); }); it('should return generic error for non-Error transport failures', async () => { const { StreamableHTTPClientTransport } = jest.requireMock('@modelcontextprotocol/sdk/client/streamableHttp'); StreamableHTTPClientTransport.mockImplementationOnce(() => { throw 'string error'; // eslint-disable-line no-throw-literal }); const server: ClaudianMcpServer = { name: 'bad-http', config: { type: 'http' as const, url: 'https://example.com' }, enabled: true, contextSaving: false, }; const result = await testMcpServer(server); expect(result.success).toBe(false); expect(result.error).toBe('Invalid server configuration'); }); it('should return error when connection fails', async () => { const { Client } = jest.requireMock('@modelcontextprotocol/sdk/client'); Client.mockImplementationOnce(() => ({ connect: jest.fn().mockRejectedValue(new Error('Connection refused')), close: jest.fn(), })); const server: ClaudianMcpServer = { name: 'refused', config: { command: 'node server.js' }, enabled: true, contextSaving: false, }; const result = await testMcpServer(server); expect(result.success).toBe(false); expect(result.error).toBe('Connection refused'); }); it('should return unknown error for non-Error connection failures', async () => { const { Client } = jest.requireMock('@modelcontextprotocol/sdk/client'); Client.mockImplementationOnce(() => ({ connect: jest.fn().mockRejectedValue(42), close: jest.fn(), })); const server: ClaudianMcpServer = { name: 'weird-error', config: { command: 'node server.js' }, enabled: true, contextSaving: false, }; const result = await testMcpServer(server); expect(result.success).toBe(false); expect(result.error).toBe('Unknown error'); }); it('should handle listTools failure gracefully (partial success)', async () => { const { Client } = jest.requireMock('@modelcontextprotocol/sdk/client'); Client.mockImplementationOnce(() => ({ connect: jest.fn(), getServerVersion: jest.fn().mockReturnValue({ name: 'partial', version: '0.1' }), listTools: jest.fn().mockRejectedValue(new Error('listTools not supported')), close: jest.fn(), })); const server: ClaudianMcpServer = { name: 'partial', config: { command: 'node server.js' }, enabled: true, contextSaving: false, }; const result = await testMcpServer(server); expect(result.success).toBe(true); expect(result.serverName).toBe('partial'); expect(result.tools).toEqual([]); }); it('should handle close errors silently', async () => { const { Client } = jest.requireMock('@modelcontextprotocol/sdk/client'); Client.mockImplementationOnce(() => ({ connect: jest.fn(), getServerVersion: jest.fn().mockReturnValue(null), listTools: jest.fn().mockResolvedValue({ tools: [] }), close: jest.fn().mockRejectedValue(new Error('close failed')), })); const server: ClaudianMcpServer = { name: 'close-fail', config: { command: 'node server.js' }, enabled: true, contextSaving: false, }; const result = await testMcpServer(server); expect(result.success).toBe(true); expect(result.serverName).toBeUndefined(); }); }); }); ================================================ FILE: tests/unit/core/mcp/createNodeFetch.test.ts ================================================ import * as http from 'http'; import type { AddressInfo } from 'net'; import { createNodeFetch } from '@/core/mcp/McpTester'; interface ReceivedRequest { method: string; url: string; headers: http.IncomingHttpHeaders; body: string; } function createTestServer(handler?: (req: ReceivedRequest, res: http.ServerResponse) => void): { server: http.Server; getUrl: () => string; received: ReceivedRequest[]; } { const received: ReceivedRequest[] = []; const server = http.createServer((req, res) => { const chunks: Buffer[] = []; req.on('data', (chunk: Buffer) => chunks.push(chunk)); req.on('end', () => { const entry: ReceivedRequest = { method: req.method ?? 'GET', url: req.url ?? '/', headers: req.headers, body: Buffer.concat(chunks).toString('utf-8'), }; received.push(entry); if (handler) { handler(entry, res); } else { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: true })); } }); }); server.listen(0); return { server, getUrl: () => { const addr = server.address() as AddressInfo; return `http://127.0.0.1:${addr.port}`; }, received, }; } describe('createNodeFetch', () => { let server: http.Server; let getUrl: () => string; let received: ReceivedRequest[]; let nodeFetch: ReturnType<typeof createNodeFetch>; const serversToClose: http.Server[] = []; beforeAll(() => { ({ server, getUrl, received } = createTestServer()); nodeFetch = createNodeFetch(); }); afterAll(() => new Promise<void>((resolve) => { server.close(() => resolve()); })); afterEach(async () => { received.length = 0; await Promise.all( serversToClose.map((s) => new Promise<void>((resolve) => s.close(() => resolve()))), ); serversToClose.length = 0; }); it('should set Content-Length header for POST with body', async () => { const body = JSON.stringify({ jsonrpc: '2.0', method: 'initialize', id: 1 }); const response = await nodeFetch(getUrl(), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body, }); expect(response.ok).toBe(true); expect(received).toHaveLength(1); expect(received[0].headers['content-length']).toBe(String(Buffer.byteLength(body))); expect(received[0].headers['transfer-encoding']).toBeUndefined(); }); it('should deliver valid JSON body without chunk framing', async () => { const payload = { jsonrpc: '2.0', method: 'tools/list', id: 2 }; const body = JSON.stringify(payload); await nodeFetch(getUrl(), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body, }); expect(received).toHaveLength(1); const parsed = JSON.parse(received[0].body); expect(parsed).toEqual(payload); }); it('should not set Content-Length for GET requests without body', async () => { await nodeFetch(getUrl(), { method: 'GET' }); expect(received).toHaveLength(1); expect(received[0].headers['content-length']).toBeUndefined(); expect(received[0].method).toBe('GET'); }); it('should forward custom headers', async () => { await nodeFetch(getUrl(), { method: 'GET', headers: { 'X-Custom': 'test-value', Authorization: 'Bearer token123' }, }); expect(received).toHaveLength(1); expect(received[0].headers['x-custom']).toBe('test-value'); expect(received[0].headers['authorization']).toBe('Bearer token123'); }); it('should return response status and body', async () => { const response = await nodeFetch(getUrl(), { method: 'GET' }); expect(response.status).toBe(200); expect(response.ok).toBe(true); const data = await response.json() as { ok: boolean }; expect(data).toEqual({ ok: true }); }); it('should handle non-200 responses', async () => { const { server: errorServer, getUrl: errorUrl, received: errorReceived } = createTestServer( (_req, res) => { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'not found' })); }, ); serversToClose.push(errorServer); const response = await nodeFetch(errorUrl(), { method: 'GET' }); expect(response.status).toBe(404); expect(response.ok).toBe(false); expect(errorReceived).toHaveLength(1); const data = await response.json() as { error: string }; expect(data).toEqual({ error: 'not found' }); }); it('should support abort signal', async () => { const controller = new AbortController(); controller.abort(); await expect( nodeFetch(getUrl(), { method: 'GET', signal: controller.signal }), ).rejects.toThrow(); }); it('should accept URL object as input', async () => { const url = new URL(getUrl()); const response = await nodeFetch(url, { method: 'GET' }); expect(response.ok).toBe(true); expect(received).toHaveLength(1); }); it('should handle multi-byte characters in body with correct Content-Length', async () => { const body = JSON.stringify({ text: '你好世界' }); await nodeFetch(getUrl(), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body, }); expect(received).toHaveLength(1); // Content-Length should be byte length, not character length expect(received[0].headers['content-length']).toBe(String(Buffer.byteLength(body))); const parsed = JSON.parse(received[0].body) as { text: string }; expect(parsed.text).toBe('你好世界'); }); }); ================================================ FILE: tests/unit/core/plugins/PluginManager.test.ts ================================================ import * as fs from 'fs'; import * as path from 'path'; const homeDir = '/Users/testuser'; const vaultPath = '/Users/testuser/Documents/vault'; // Mock os.homedir before any module imports jest.mock('os', () => ({ homedir: jest.fn(() => homeDir), })); // Mock fs module jest.mock('fs'); // Mock obsidian jest.mock('obsidian', () => ({ Notice: jest.fn(), })); import { Notice } from 'obsidian'; import { PluginManager } from '@/core/plugins/PluginManager'; const mockFs = fs as jest.Mocked<typeof fs>; // Create a mock CCSettingsStorage function createMockCCSettingsStorage() { return { getEnabledPlugins: jest.fn().mockResolvedValue({}), setPluginEnabled: jest.fn().mockResolvedValue(undefined), } as any; } const installedPluginsPath = path.join(homeDir, '.claude', 'plugins', 'installed_plugins.json'); const globalSettingsPath = path.join(homeDir, '.claude', 'settings.json'); const projectSettingsPath = path.join(vaultPath, '.claude', 'settings.json'); describe('PluginManager', () => { beforeEach(() => { jest.clearAllMocks(); }); describe('loadPlugins', () => { it('returns empty array when no installed_plugins.json exists', async () => { mockFs.existsSync.mockReturnValue(false); const ccSettings = createMockCCSettingsStorage(); const manager = new PluginManager(vaultPath, ccSettings); await manager.loadPlugins(); expect(manager.getPlugins()).toEqual([]); }); it('loads plugins from installed_plugins.json with enabled state from settings', async () => { const installedPlugins = { version: 2, plugins: { 'test-plugin@marketplace': [{ scope: 'user', installPath: '/path/to/test-plugin', version: '1.0.0', installedAt: '2026-01-01T00:00:00.000Z', lastUpdated: '2026-01-01T00:00:00.000Z', }], }, }; const globalSettings = { enabledPlugins: { 'test-plugin@marketplace': true }, }; mockFs.existsSync.mockReturnValue(true); mockFs.readFileSync.mockImplementation((p: fs.PathOrFileDescriptor) => { if (String(p) === installedPluginsPath) return JSON.stringify(installedPlugins); if (String(p) === globalSettingsPath) return JSON.stringify(globalSettings); return '{}'; }); const ccSettings = createMockCCSettingsStorage(); const manager = new PluginManager(vaultPath, ccSettings); await manager.loadPlugins(); const plugins = manager.getPlugins(); expect(plugins.length).toBe(1); expect(plugins[0].id).toBe('test-plugin@marketplace'); expect(plugins[0].name).toBe('test-plugin'); expect(plugins[0].enabled).toBe(true); expect(plugins[0].scope).toBe('user'); expect(plugins[0].installPath).toBe('/path/to/test-plugin'); }); it('defaults to enabled for installed plugins not in settings', async () => { const installedPlugins = { version: 2, plugins: { 'new-plugin@marketplace': [{ scope: 'user', installPath: '/path/to/new-plugin', version: '1.0.0', installedAt: '2026-01-01T00:00:00.000Z', lastUpdated: '2026-01-01T00:00:00.000Z', }], }, }; mockFs.existsSync.mockImplementation((p: fs.PathLike) => { return String(p) === installedPluginsPath; }); mockFs.readFileSync.mockReturnValue(JSON.stringify(installedPlugins)); const ccSettings = createMockCCSettingsStorage(); const manager = new PluginManager(vaultPath, ccSettings); await manager.loadPlugins(); const plugins = manager.getPlugins(); expect(plugins.length).toBe(1); expect(plugins[0].enabled).toBe(true); // Default to enabled }); it('project false overrides global true', async () => { const installedPlugins = { version: 2, plugins: { 'plugin-a@marketplace': [{ scope: 'user', installPath: '/path/to/plugin-a', version: '1.0.0', installedAt: '2026-01-01T00:00:00.000Z', lastUpdated: '2026-01-01T00:00:00.000Z', }], }, }; const globalSettings = { enabledPlugins: { 'plugin-a@marketplace': true }, }; const projectSettings = { enabledPlugins: { 'plugin-a@marketplace': false }, }; mockFs.existsSync.mockReturnValue(true); mockFs.readFileSync.mockImplementation((p: fs.PathOrFileDescriptor) => { if (String(p) === installedPluginsPath) return JSON.stringify(installedPlugins); if (String(p) === globalSettingsPath) return JSON.stringify(globalSettings); if (String(p) === projectSettingsPath) return JSON.stringify(projectSettings); return '{}'; }); const ccSettings = createMockCCSettingsStorage(); const manager = new PluginManager(vaultPath, ccSettings); await manager.loadPlugins(); const plugins = manager.getPlugins(); expect(plugins[0].enabled).toBe(false); expect(plugins[0].scope).toBe('user'); // Scope reflects installation, not settings location }); it('extracts plugin name from ID correctly', async () => { const installedPlugins = { version: 2, plugins: { 'feature-dev@claude-plugins-official': [{ scope: 'user', installPath: '/path/to/feature-dev', version: '1.0.0', installedAt: '2026-01-01T00:00:00.000Z', lastUpdated: '2026-01-01T00:00:00.000Z', }], }, }; mockFs.existsSync.mockImplementation((p: fs.PathLike) => { return String(p) === installedPluginsPath; }); mockFs.readFileSync.mockReturnValue(JSON.stringify(installedPlugins)); const ccSettings = createMockCCSettingsStorage(); const manager = new PluginManager(vaultPath, ccSettings); await manager.loadPlugins(); const plugins = manager.getPlugins(); expect(plugins[0].name).toBe('feature-dev'); }); it('sorts plugins: project first, then user', async () => { const installedPlugins = { version: 2, plugins: { 'user-plugin@marketplace': [{ scope: 'user', installPath: '/path/to/user-plugin', version: '1.0.0', installedAt: '2026-01-01T00:00:00.000Z', lastUpdated: '2026-01-01T00:00:00.000Z', }], 'project-plugin@marketplace': [{ scope: 'project', installPath: '/path/to/project-plugin', version: '1.0.0', installedAt: '2026-01-01T00:00:00.000Z', lastUpdated: '2026-01-01T00:00:00.000Z', projectPath: vaultPath, }], }, }; mockFs.existsSync.mockImplementation((p: fs.PathLike) => { return String(p) === installedPluginsPath; }); mockFs.readFileSync.mockReturnValue(JSON.stringify(installedPlugins)); const ccSettings = createMockCCSettingsStorage(); const manager = new PluginManager(vaultPath, ccSettings); await manager.loadPlugins(); const plugins = manager.getPlugins(); expect(plugins.length).toBe(2); expect(plugins[0].scope).toBe('project'); expect(plugins[1].scope).toBe('user'); }); it('excludes project plugins installed for other vaults', async () => { const installedPlugins = { version: 2, plugins: { 'other-project-plugin@marketplace': [{ scope: 'project', installPath: '/path/to/other-project-plugin', version: '1.0.0', installedAt: '2026-01-01T00:00:00.000Z', lastUpdated: '2026-01-01T00:00:00.000Z', projectPath: '/Users/testuser/Documents/other-vault', }], }, }; mockFs.existsSync.mockImplementation((p: fs.PathLike) => { return String(p) === installedPluginsPath; }); mockFs.readFileSync.mockReturnValue(JSON.stringify(installedPlugins)); const ccSettings = createMockCCSettingsStorage(); const manager = new PluginManager(vaultPath, ccSettings); await manager.loadPlugins(); expect(manager.getPlugins()).toEqual([]); }); it('prefers project plugin entry for the current vault', async () => { const installedPlugins = { version: 2, plugins: { 'multi-scope-plugin@marketplace': [ { scope: 'user', installPath: '/path/to/user-install', version: '1.0.0', installedAt: '2026-01-01T00:00:00.000Z', lastUpdated: '2026-01-01T00:00:00.000Z', }, { scope: 'project', installPath: '/path/to/project-install', version: '1.0.0', installedAt: '2026-01-01T00:00:00.000Z', lastUpdated: '2026-01-01T00:00:00.000Z', projectPath: vaultPath, }, ], }, }; mockFs.existsSync.mockImplementation((p: fs.PathLike) => { return String(p) === installedPluginsPath; }); mockFs.readFileSync.mockReturnValue(JSON.stringify(installedPlugins)); const ccSettings = createMockCCSettingsStorage(); const manager = new PluginManager(vaultPath, ccSettings); await manager.loadPlugins(); const plugins = manager.getPlugins(); expect(plugins.length).toBe(1); expect(plugins[0].scope).toBe('project'); expect(plugins[0].installPath).toBe('/path/to/project-install'); }); }); describe('togglePlugin', () => { it('disables an enabled plugin', async () => { const installedPlugins = { version: 2, plugins: { 'test-plugin@marketplace': [{ scope: 'user', installPath: '/path/to/test-plugin', version: '1.0.0', installedAt: '2026-01-01T00:00:00.000Z', lastUpdated: '2026-01-01T00:00:00.000Z', }], }, }; mockFs.existsSync.mockImplementation((p: fs.PathLike) => { return String(p) === installedPluginsPath; }); mockFs.readFileSync.mockReturnValue(JSON.stringify(installedPlugins)); const ccSettings = createMockCCSettingsStorage(); const manager = new PluginManager(vaultPath, ccSettings); await manager.loadPlugins(); expect(manager.getPlugins()[0].enabled).toBe(true); await manager.togglePlugin('test-plugin@marketplace'); expect(manager.getPlugins()[0].enabled).toBe(false); expect(ccSettings.setPluginEnabled).toHaveBeenCalledWith('test-plugin@marketplace', false); }); it('does nothing when plugin not found', async () => { mockFs.existsSync.mockReturnValue(false); const ccSettings = createMockCCSettingsStorage(); const manager = new PluginManager(vaultPath, ccSettings); await manager.loadPlugins(); await manager.togglePlugin('nonexistent-plugin'); expect(ccSettings.setPluginEnabled).not.toHaveBeenCalled(); }); }); describe('getPluginsKey', () => { it('returns empty string when no plugins are enabled', async () => { const installedPlugins = { version: 2, plugins: { 'test-plugin@marketplace': [{ scope: 'user', installPath: '/path/to/test-plugin', version: '1.0.0', installedAt: '2026-01-01T00:00:00.000Z', lastUpdated: '2026-01-01T00:00:00.000Z', }], }, }; const globalSettings = { enabledPlugins: { 'test-plugin@marketplace': false }, }; mockFs.existsSync.mockReturnValue(true); mockFs.readFileSync.mockImplementation((p: fs.PathOrFileDescriptor) => { if (String(p) === installedPluginsPath) return JSON.stringify(installedPlugins); if (String(p) === globalSettingsPath) return JSON.stringify(globalSettings); return '{}'; }); const ccSettings = createMockCCSettingsStorage(); const manager = new PluginManager(vaultPath, ccSettings); await manager.loadPlugins(); expect(manager.getPluginsKey()).toBe(''); }); it('returns stable key for enabled plugins', async () => { const installedPlugins = { version: 2, plugins: { 'plugin-b@marketplace': [{ scope: 'user', installPath: '/path/to/plugin-b', version: '1.0.0', installedAt: '2026-01-01T00:00:00.000Z', lastUpdated: '2026-01-01T00:00:00.000Z', }], 'plugin-a@marketplace': [{ scope: 'user', installPath: '/path/to/plugin-a', version: '1.0.0', installedAt: '2026-01-01T00:00:00.000Z', lastUpdated: '2026-01-01T00:00:00.000Z', }], }, }; mockFs.existsSync.mockImplementation((p: fs.PathLike) => { return String(p) === installedPluginsPath; }); mockFs.readFileSync.mockReturnValue(JSON.stringify(installedPlugins)); const ccSettings = createMockCCSettingsStorage(); const manager = new PluginManager(vaultPath, ccSettings); await manager.loadPlugins(); const key = manager.getPluginsKey(); // Should be sorted alphabetically by ID expect(key).toBe('plugin-a@marketplace:/path/to/plugin-a|plugin-b@marketplace:/path/to/plugin-b'); }); }); describe('hasEnabledPlugins', () => { it('returns true when at least one plugin is enabled', async () => { const installedPlugins = { version: 2, plugins: { 'test-plugin@marketplace': [{ scope: 'user', installPath: '/path/to/test-plugin', version: '1.0.0', installedAt: '2026-01-01T00:00:00.000Z', lastUpdated: '2026-01-01T00:00:00.000Z', }], }, }; mockFs.existsSync.mockImplementation((p: fs.PathLike) => { return String(p) === installedPluginsPath; }); mockFs.readFileSync.mockReturnValue(JSON.stringify(installedPlugins)); const ccSettings = createMockCCSettingsStorage(); const manager = new PluginManager(vaultPath, ccSettings); await manager.loadPlugins(); expect(manager.hasEnabledPlugins()).toBe(true); }); it('returns false when all plugins are disabled', async () => { const installedPlugins = { version: 2, plugins: { 'test-plugin@marketplace': [{ scope: 'user', installPath: '/path/to/test-plugin', version: '1.0.0', installedAt: '2026-01-01T00:00:00.000Z', lastUpdated: '2026-01-01T00:00:00.000Z', }], }, }; const globalSettings = { enabledPlugins: { 'test-plugin@marketplace': false }, }; mockFs.existsSync.mockReturnValue(true); mockFs.readFileSync.mockImplementation((p: fs.PathOrFileDescriptor) => { if (String(p) === installedPluginsPath) return JSON.stringify(installedPlugins); if (String(p) === globalSettingsPath) return JSON.stringify(globalSettings); return '{}'; }); const ccSettings = createMockCCSettingsStorage(); const manager = new PluginManager(vaultPath, ccSettings); await manager.loadPlugins(); expect(manager.hasEnabledPlugins()).toBe(false); }); }); describe('enablePlugin', () => { it('enables a disabled plugin', async () => { const installedPlugins = { version: 2, plugins: { 'test-plugin@marketplace': [{ scope: 'user', installPath: '/path/to/test-plugin', version: '1.0.0', installedAt: '2026-01-01T00:00:00.000Z', lastUpdated: '2026-01-01T00:00:00.000Z', }], }, }; const globalSettings = { enabledPlugins: { 'test-plugin@marketplace': false }, }; mockFs.existsSync.mockReturnValue(true); mockFs.readFileSync.mockImplementation((p: fs.PathOrFileDescriptor) => { if (String(p) === installedPluginsPath) return JSON.stringify(installedPlugins); if (String(p) === globalSettingsPath) return JSON.stringify(globalSettings); return '{}'; }); const ccSettings = createMockCCSettingsStorage(); const manager = new PluginManager(vaultPath, ccSettings); await manager.loadPlugins(); expect(manager.getPlugins()[0].enabled).toBe(false); await manager.enablePlugin('test-plugin@marketplace'); expect(manager.getPlugins()[0].enabled).toBe(true); expect(ccSettings.setPluginEnabled).toHaveBeenCalledWith('test-plugin@marketplace', true); }); it('does nothing when plugin is already enabled', async () => { const installedPlugins = { version: 2, plugins: { 'test-plugin@marketplace': [{ scope: 'user', installPath: '/path/to/test-plugin', version: '1.0.0', installedAt: '2026-01-01T00:00:00.000Z', lastUpdated: '2026-01-01T00:00:00.000Z', }], }, }; mockFs.existsSync.mockImplementation((p: fs.PathLike) => { return String(p) === installedPluginsPath; }); mockFs.readFileSync.mockReturnValue(JSON.stringify(installedPlugins)); const ccSettings = createMockCCSettingsStorage(); const manager = new PluginManager(vaultPath, ccSettings); await manager.loadPlugins(); await manager.enablePlugin('test-plugin@marketplace'); expect(ccSettings.setPluginEnabled).not.toHaveBeenCalled(); }); it('does nothing for nonexistent plugin', async () => { mockFs.existsSync.mockReturnValue(false); const ccSettings = createMockCCSettingsStorage(); const manager = new PluginManager(vaultPath, ccSettings); await manager.loadPlugins(); await manager.enablePlugin('nonexistent'); expect(ccSettings.setPluginEnabled).not.toHaveBeenCalled(); }); }); describe('disablePlugin', () => { it('disables an enabled plugin', async () => { const installedPlugins = { version: 2, plugins: { 'test-plugin@marketplace': [{ scope: 'user', installPath: '/path/to/test-plugin', version: '1.0.0', installedAt: '2026-01-01T00:00:00.000Z', lastUpdated: '2026-01-01T00:00:00.000Z', }], }, }; mockFs.existsSync.mockImplementation((p: fs.PathLike) => { return String(p) === installedPluginsPath; }); mockFs.readFileSync.mockReturnValue(JSON.stringify(installedPlugins)); const ccSettings = createMockCCSettingsStorage(); const manager = new PluginManager(vaultPath, ccSettings); await manager.loadPlugins(); expect(manager.getPlugins()[0].enabled).toBe(true); await manager.disablePlugin('test-plugin@marketplace'); expect(manager.getPlugins()[0].enabled).toBe(false); expect(ccSettings.setPluginEnabled).toHaveBeenCalledWith('test-plugin@marketplace', false); }); it('does nothing when plugin is already disabled', async () => { const installedPlugins = { version: 2, plugins: { 'test-plugin@marketplace': [{ scope: 'user', installPath: '/path/to/test-plugin', version: '1.0.0', installedAt: '2026-01-01T00:00:00.000Z', lastUpdated: '2026-01-01T00:00:00.000Z', }], }, }; const globalSettings = { enabledPlugins: { 'test-plugin@marketplace': false }, }; mockFs.existsSync.mockReturnValue(true); mockFs.readFileSync.mockImplementation((p: fs.PathOrFileDescriptor) => { if (String(p) === installedPluginsPath) return JSON.stringify(installedPlugins); if (String(p) === globalSettingsPath) return JSON.stringify(globalSettings); return '{}'; }); const ccSettings = createMockCCSettingsStorage(); const manager = new PluginManager(vaultPath, ccSettings); await manager.loadPlugins(); await manager.disablePlugin('test-plugin@marketplace'); expect(ccSettings.setPluginEnabled).not.toHaveBeenCalled(); }); it('does nothing for nonexistent plugin', async () => { mockFs.existsSync.mockReturnValue(false); const ccSettings = createMockCCSettingsStorage(); const manager = new PluginManager(vaultPath, ccSettings); await manager.loadPlugins(); await manager.disablePlugin('nonexistent'); expect(ccSettings.setPluginEnabled).not.toHaveBeenCalled(); }); }); describe('getEnabledCount', () => { it('returns count of enabled plugins', async () => { const installedPlugins = { version: 2, plugins: { 'plugin-a@marketplace': [{ scope: 'user', installPath: '/path/to/a', version: '1.0.0', installedAt: '2026-01-01T00:00:00.000Z', lastUpdated: '2026-01-01T00:00:00.000Z', }], 'plugin-b@marketplace': [{ scope: 'user', installPath: '/path/to/b', version: '1.0.0', installedAt: '2026-01-01T00:00:00.000Z', lastUpdated: '2026-01-01T00:00:00.000Z', }], }, }; const globalSettings = { enabledPlugins: { 'plugin-a@marketplace': true, 'plugin-b@marketplace': false, }, }; mockFs.existsSync.mockReturnValue(true); mockFs.readFileSync.mockImplementation((p: fs.PathOrFileDescriptor) => { if (String(p) === installedPluginsPath) return JSON.stringify(installedPlugins); if (String(p) === globalSettingsPath) return JSON.stringify(globalSettings); return '{}'; }); const ccSettings = createMockCCSettingsStorage(); const manager = new PluginManager(vaultPath, ccSettings); await manager.loadPlugins(); expect(manager.getEnabledCount()).toBe(1); }); it('returns 0 when no plugins loaded', async () => { mockFs.existsSync.mockReturnValue(false); const ccSettings = createMockCCSettingsStorage(); const manager = new PluginManager(vaultPath, ccSettings); await manager.loadPlugins(); expect(manager.getEnabledCount()).toBe(0); }); }); describe('hasPlugins', () => { it('returns true when plugins exist', async () => { const installedPlugins = { version: 2, plugins: { 'test-plugin@marketplace': [{ scope: 'user', installPath: '/path/to/test-plugin', version: '1.0.0', installedAt: '2026-01-01T00:00:00.000Z', lastUpdated: '2026-01-01T00:00:00.000Z', }], }, }; mockFs.existsSync.mockImplementation((p: fs.PathLike) => { return String(p) === installedPluginsPath; }); mockFs.readFileSync.mockReturnValue(JSON.stringify(installedPlugins)); const ccSettings = createMockCCSettingsStorage(); const manager = new PluginManager(vaultPath, ccSettings); await manager.loadPlugins(); expect(manager.hasPlugins()).toBe(true); }); it('returns false when no plugins exist', async () => { mockFs.existsSync.mockReturnValue(false); const ccSettings = createMockCCSettingsStorage(); const manager = new PluginManager(vaultPath, ccSettings); await manager.loadPlugins(); expect(manager.hasPlugins()).toBe(false); }); }); describe('non-array plugin entries normalization', () => { it('loads plugin when entries is a single object instead of array', async () => { const installedPlugins = { version: 2, plugins: { 'solo-plugin@marketplace': { scope: 'user', installPath: '/path/to/solo-plugin', version: '1.0.0', installedAt: '2026-01-01T00:00:00.000Z', lastUpdated: '2026-01-01T00:00:00.000Z', }, }, }; mockFs.existsSync.mockImplementation((p: fs.PathLike) => { return String(p) === installedPluginsPath; }); mockFs.readFileSync.mockReturnValue(JSON.stringify(installedPlugins)); const ccSettings = createMockCCSettingsStorage(); const manager = new PluginManager(vaultPath, ccSettings); await manager.loadPlugins(); const plugins = manager.getPlugins(); expect(plugins.length).toBe(1); expect(plugins[0].id).toBe('solo-plugin@marketplace'); expect(plugins[0].installPath).toBe('/path/to/solo-plugin'); expect(plugins[0].enabled).toBe(true); expect(Notice).toHaveBeenCalledWith( expect.stringContaining('solo-plugin@marketplace'), ); }); }); describe('readJsonFile error handling', () => { it('returns null when JSON parse fails', async () => { mockFs.existsSync.mockReturnValue(true); mockFs.readFileSync.mockReturnValue('not valid json {{{'); const ccSettings = createMockCCSettingsStorage(); const manager = new PluginManager(vaultPath, ccSettings); await manager.loadPlugins(); // Should gracefully handle parse error and return empty plugins expect(manager.getPlugins()).toEqual([]); }); }); describe('extractPluginName without @', () => { it('returns full ID when no @ is present', async () => { const installedPlugins = { version: 2, plugins: { 'simple-plugin': [{ scope: 'user' as const, installPath: '/path/to/simple-plugin', version: '1.0.0', installedAt: '2026-01-01T00:00:00.000Z', lastUpdated: '2026-01-01T00:00:00.000Z', }], }, }; mockFs.existsSync.mockImplementation((p: fs.PathLike) => { return String(p) === installedPluginsPath; }); mockFs.readFileSync.mockReturnValue(JSON.stringify(installedPlugins)); const ccSettings = createMockCCSettingsStorage(); const manager = new PluginManager(vaultPath, ccSettings); await manager.loadPlugins(); const plugins = manager.getPlugins(); expect(plugins.length).toBe(1); expect(plugins[0].name).toBe('simple-plugin'); expect(plugins[0].id).toBe('simple-plugin'); }); }); describe('normalizePathForComparison', () => { it('uses realpathSync when available', async () => { const installedPlugins = { version: 2, plugins: { 'project-plugin@marketplace': [{ scope: 'project' as const, installPath: '/path/to/project-plugin', version: '1.0.0', installedAt: '2026-01-01T00:00:00.000Z', lastUpdated: '2026-01-01T00:00:00.000Z', projectPath: vaultPath, }], }, }; mockFs.existsSync.mockImplementation((p: fs.PathLike) => { return String(p) === installedPluginsPath; }); mockFs.readFileSync.mockReturnValue(JSON.stringify(installedPlugins)); // realpathSync returns the resolved path mockFs.realpathSync.mockReturnValue(vaultPath); const ccSettings = createMockCCSettingsStorage(); const manager = new PluginManager(vaultPath, ccSettings); await manager.loadPlugins(); const plugins = manager.getPlugins(); expect(plugins.length).toBe(1); expect(plugins[0].scope).toBe('project'); }); }); }); ================================================ FILE: tests/unit/core/plugins/index.test.ts ================================================ import { PluginManager } from '@/core/plugins'; describe('core/plugins index', () => { it('re-exports runtime symbols', () => { expect(PluginManager).toBeDefined(); }); }); ================================================ FILE: tests/unit/core/prompts/instructionRefine.test.ts ================================================ import { buildRefineSystemPrompt } from '@/core/prompts/instructionRefine'; describe('buildRefineSystemPrompt', () => { describe('without existing instructions', () => { it('should return base prompt when existing instructions is empty', () => { const result = buildRefineSystemPrompt(''); expect(result).toContain('You are an expert Prompt Engineer'); expect(result).toContain('**Your Goal**'); expect(result).toContain('**Process**'); expect(result).toContain('**Guidelines**'); expect(result).toContain('**Output Format**'); expect(result).toContain('**Examples**'); }); it('should not include existing instructions section when empty', () => { const result = buildRefineSystemPrompt(''); expect(result).not.toContain('EXISTING INSTRUCTIONS'); expect(result).not.toContain('already in the user\'s system prompt'); }); it('should not include existing instructions section for whitespace-only input', () => { const result = buildRefineSystemPrompt(' \n\t '); expect(result).not.toContain('EXISTING INSTRUCTIONS'); }); }); describe('with existing instructions', () => { it('should include existing instructions section', () => { const existingInstructions = '- Use TypeScript for all code'; const result = buildRefineSystemPrompt(existingInstructions); expect(result).toContain('EXISTING INSTRUCTIONS'); expect(result).toContain('already in the user\'s system prompt'); expect(result).toContain('- Use TypeScript for all code'); }); it('should wrap existing instructions in code block', () => { const existingInstructions = '- Rule 1\n- Rule 2'; const result = buildRefineSystemPrompt(existingInstructions); expect(result).toContain('```\n- Rule 1\n- Rule 2\n```'); }); it('should include conflict avoidance guidance', () => { const existingInstructions = '- Some rule'; const result = buildRefineSystemPrompt(existingInstructions); expect(result).toContain('Consider how it fits with existing instructions'); expect(result).toContain('Avoid duplicating existing instructions'); expect(result).toContain('conflicts with an existing one'); expect(result).toContain('Match the format of existing instructions'); }); it('should trim whitespace from existing instructions', () => { const existingInstructions = ' \n - Trimmed rule \n '; const result = buildRefineSystemPrompt(existingInstructions); expect(result).toContain('```\n- Trimmed rule\n```'); }); }); describe('prompt structure', () => { it('should contain process steps for analyzing intent', () => { const result = buildRefineSystemPrompt(''); expect(result).toContain('**Analyze Intent**'); expect(result).toContain('**Check Context**'); expect(result).toContain('**Refine**'); expect(result).toContain('**Format**'); }); it('should include conflict handling guidance', () => { const result = buildRefineSystemPrompt(''); expect(result).toContain('*No Conflict*'); expect(result).toContain('*Conflict*'); expect(result).toContain('merged instruction'); }); it('should specify output format with instruction tags', () => { const result = buildRefineSystemPrompt(''); expect(result).toContain('<instruction>'); expect(result).toContain('</instruction>'); expect(result).toContain('**Success**'); expect(result).toContain('**Ambiguity**'); }); it('should include examples', () => { const result = buildRefineSystemPrompt(''); expect(result).toContain('Input: "typescript for code"'); expect(result).toContain('Input: "be concise"'); expect(result).toContain('Input: "organize coding style rules"'); expect(result).toContain('Input: "use that thing from before"'); }); it('should show example outputs with proper tag wrapping', () => { const result = buildRefineSystemPrompt(''); expect(result).toContain('Output: <instruction>'); expect(result).toContain('**Code Language**'); expect(result).toContain('**Conciseness**'); expect(result).toContain('## Coding Standards'); }); it('should include ambiguity handling example', () => { const result = buildRefineSystemPrompt(''); expect(result).toContain('I\'m not sure what you\'re referring to'); expect(result).toContain('Could you please clarify'); }); }); describe('guidelines', () => { it('should specify clarity guideline', () => { const result = buildRefineSystemPrompt(''); expect(result).toContain('**Clarity**'); expect(result).toContain('Use precise language'); }); it('should specify scope guideline', () => { const result = buildRefineSystemPrompt(''); expect(result).toContain('**Scope**'); expect(result).toContain('Keep it focused'); }); it('should specify format guideline', () => { const result = buildRefineSystemPrompt(''); expect(result).toContain('**Format**'); expect(result).toContain('Valid Markdown'); }); it('should specify no header guideline', () => { const result = buildRefineSystemPrompt(''); expect(result).toContain('**No Header**'); expect(result).toContain('# Custom Instructions'); }); it('should specify conflict handling guideline', () => { const result = buildRefineSystemPrompt(''); expect(result).toContain('**Conflict Handling**'); expect(result).toContain('directly contradicts'); }); }); describe('multiline existing instructions', () => { it('should handle multi-line existing instructions', () => { const existingInstructions = `## Code Style - Use TypeScript - Prefer functional patterns ## Documentation - Add JSDoc comments - Include examples`; const result = buildRefineSystemPrompt(existingInstructions); expect(result).toContain('## Code Style'); expect(result).toContain('## Documentation'); expect(result).toContain('- Use TypeScript'); expect(result).toContain('- Add JSDoc comments'); }); it('should preserve formatting within existing instructions', () => { const existingInstructions = '- Item 1\n - Nested item\n- Item 2'; const result = buildRefineSystemPrompt(existingInstructions); expect(result).toContain('- Item 1\n - Nested item\n- Item 2'); }); }); }); ================================================ FILE: tests/unit/core/prompts/systemPrompt.test.ts ================================================ jest.mock('@/utils/date', () => ({ getTodayDate: () => 'Mocked Date', })); import { getInlineEditSystemPrompt } from '@/core/prompts/inlineEdit'; import { buildSystemPrompt } from '@/core/prompts/mainAgent'; describe('systemPrompt', () => { describe('buildSystemPrompt', () => { it('should append custom prompt section when provided', () => { const prompt = buildSystemPrompt({ customPrompt: 'Always be concise.' }); expect(prompt).toContain('# Custom Instructions'); expect(prompt).toContain('Always be concise.'); }); it('should not append custom prompt section when empty', () => { const prompt = buildSystemPrompt({ customPrompt: ' ' }); expect(prompt).not.toContain('# Custom Instructions'); }); it('should not append custom prompt section when undefined', () => { const prompt = buildSystemPrompt({}); expect(prompt).not.toContain('# Custom Instructions'); }); it('should include base system prompt elements', () => { const prompt = buildSystemPrompt(); expect(prompt).toContain('Mocked Date'); expect(prompt).toContain('Claudian'); expect(prompt).toContain('# Path Rules'); expect(prompt).toContain('# User Message Format'); }); it('should include allowed export paths instructions when configured', () => { const prompt = buildSystemPrompt({ allowedExportPaths: ['~/Desktop', '/tmp'] }); expect(prompt).toContain('# Allowed Export Paths'); expect(prompt).toContain('- ~/Desktop'); expect(prompt).toContain('- /tmp'); expect(prompt).toContain('write-only'); }); it('should not include export paths when all paths are whitespace-only', () => { const prompt = buildSystemPrompt({ allowedExportPaths: [' ', '', ' '] }); expect(prompt).not.toContain('# Allowed Export Paths'); }); it('should deduplicate and filter export paths', () => { const prompt = buildSystemPrompt({ allowedExportPaths: ['~/Desktop', '~/Desktop', '', '/tmp'] }); expect(prompt).toContain('# Allowed Export Paths'); expect(prompt).toContain('- ~/Desktop'); expect(prompt).toContain('- /tmp'); }); it('should switch to unrestricted path guidance when external access is enabled', () => { const prompt = buildSystemPrompt({ allowExternalAccess: true, allowedExportPaths: ['~/Desktop'], }); expect(prompt).toContain('| **External paths** | Read/Write |'); expect(prompt).toContain('# Preferred Export Paths'); expect(prompt).toContain('Suggested destinations for exports outside the vault'); expect(prompt).not.toContain('Write-only destinations outside the vault'); expect(prompt).not.toContain('NEVER use absolute paths in subagent prompts.'); }); }); describe('userName in system prompt', () => { it('should include user context when userName is provided', () => { const prompt = buildSystemPrompt({ userName: 'Alice' }); expect(prompt).toContain('## User Context'); expect(prompt).toContain('You are collaborating with **Alice**.'); }); it('should not include user context when userName is empty', () => { const prompt = buildSystemPrompt({ userName: '' }); expect(prompt).not.toContain('## User Context'); }); it('should not include user context when userName is whitespace only', () => { const prompt = buildSystemPrompt({ userName: ' ' }); expect(prompt).not.toContain('## User Context'); }); it('should not include user context when userName is undefined', () => { const prompt = buildSystemPrompt({}); expect(prompt).not.toContain('## User Context'); }); it('should trim whitespace from userName', () => { const prompt = buildSystemPrompt({ userName: ' Bob ' }); expect(prompt).toContain('You are collaborating with **Bob**.'); expect(prompt).not.toContain('** Bob **'); }); }); describe('media folder instructions', () => { it('should use vault root path when mediaFolder is empty', () => { const prompt = buildSystemPrompt({ mediaFolder: '' }); expect(prompt).toContain('Located in media folder: `.`'); expect(prompt).toContain('Read file_path="image.jpg"'); }); it('should use vault root path when mediaFolder is whitespace only', () => { const prompt = buildSystemPrompt({ mediaFolder: ' ' }); expect(prompt).toContain('Located in media folder: `.`'); }); it('should use custom mediaFolder path when provided', () => { const prompt = buildSystemPrompt({ mediaFolder: 'attachments' }); expect(prompt).toContain('Located in media folder: `./attachments`'); expect(prompt).toContain('Read file_path="attachments/image.jpg"'); }); it('should handle mediaFolder with special characters', () => { const prompt = buildSystemPrompt({ mediaFolder: '- attachments' }); expect(prompt).toContain('Located in media folder: `./- attachments`'); expect(prompt).toContain('Read file_path="- attachments/image.jpg"'); }); it('should include external image handling instructions', () => { const prompt = buildSystemPrompt({ mediaFolder: 'media' }); expect(prompt).toContain('WebFetch does NOT support images'); expect(prompt).toContain('Download to media folder'); expect(prompt).toContain('curl'); expect(prompt).toContain('replace the markdown link'); }); }); describe('getInlineEditSystemPrompt', () => { it('should include inline edit critical output rules', () => { const prompt = getInlineEditSystemPrompt(); expect(prompt).toContain('ABSOLUTE RULE'); expect(prompt).toContain('<replacement>'); }); it('should include read-only tool descriptions', () => { const prompt = getInlineEditSystemPrompt(); expect(prompt).toContain('Read, Grep, Glob, LS, WebSearch, WebFetch'); expect(prompt).toContain('read-only'); }); it('should include example scenarios', () => { const prompt = getInlineEditSystemPrompt(); expect(prompt).toContain('translate to French'); expect(prompt).toContain('Bonjour le monde'); expect(prompt).toContain('asking for clarification'); }); it('should include date from utils', () => { const prompt = getInlineEditSystemPrompt(); expect(prompt).toContain('Mocked Date'); }); it('should switch inline edit path guidance when external access is enabled', () => { const prompt = getInlineEditSystemPrompt(true); expect(prompt).toContain('Prefer RELATIVE paths for vault files'); expect(prompt).toContain('Use absolute or `~` paths only'); expect(prompt).not.toContain('Must be RELATIVE to vault root'); }); }); }); ================================================ FILE: tests/unit/core/prompts/titleGeneration.test.ts ================================================ import { TITLE_GENERATION_SYSTEM_PROMPT } from '@/core/prompts/titleGeneration'; describe('titleGeneration', () => { it('exports a non-empty system prompt string', () => { expect(typeof TITLE_GENERATION_SYSTEM_PROMPT).toBe('string'); expect(TITLE_GENERATION_SYSTEM_PROMPT.length).toBeGreaterThan(0); }); it('includes the max character constraint', () => { expect(TITLE_GENERATION_SYSTEM_PROMPT).toContain('max 50 chars'); }); it('instructs to start with a strong verb', () => { expect(TITLE_GENERATION_SYSTEM_PROMPT).toContain('strong verb'); }); it('instructs to return only the raw title text', () => { expect(TITLE_GENERATION_SYSTEM_PROMPT).toContain('ONLY the raw title text'); }); }); ================================================ FILE: tests/unit/core/sdk/transformSDKMessage.test.ts ================================================ import { buildSDKMessage } from '@test/helpers/sdkMessages'; import { transformSDKMessage } from '@/core/sdk/transformSDKMessage'; const msg = buildSDKMessage; describe('transformSDKMessage', () => { describe('system messages', () => { it('yields session_init event for init subtype with session_id', () => { const message = msg({ type: 'system', subtype: 'init', session_id: 'test-session-123', }); const results = [...transformSDKMessage(message)]; expect(results).toEqual([ { type: 'session_init', sessionId: 'test-session-123', agents: undefined, permissionMode: 'default', }, ]); }); it('yields nothing for system messages without init subtype', () => { const message = msg({ type: 'system', subtype: 'status', session_id: 'test-session', }); const results = [...transformSDKMessage(message)]; expect(results).toEqual([]); }); it('yields compact_boundary event for compact_boundary subtype', () => { const message = msg({ type: 'system', subtype: 'compact_boundary', }); const results = [...transformSDKMessage(message)]; expect(results).toEqual([ { type: 'compact_boundary' }, ]); }); it('captures agents from init message', () => { const message = msg({ type: 'system', subtype: 'init', session_id: 'test-session-456', agents: ['Explore', 'Plan', 'custom-agent'], skills: ['commit', 'review-pr'], slash_commands: ['clear', 'add-dir'], }); const results = [...transformSDKMessage(message)]; expect(results).toHaveLength(1); expect(results[0]).toEqual({ type: 'session_init', sessionId: 'test-session-456', agents: ['Explore', 'Plan', 'custom-agent'], permissionMode: 'default', }); }); it('captures permissionMode from init message', () => { const message = msg({ type: 'system', subtype: 'init', session_id: 'test-session-789', permissionMode: 'plan', }); const results = [...transformSDKMessage(message)]; expect(results).toHaveLength(1); expect(results[0]).toEqual({ type: 'session_init', sessionId: 'test-session-789', permissionMode: 'plan', }); }); }); describe('assistant messages', () => { it('yields text content block', () => { const message = msg({ type: 'assistant', message: { content: [ { type: 'text', text: 'Hello, world!' }, ], }, }); const results = [...transformSDKMessage(message)]; expect(results).toEqual([ { type: 'text', content: 'Hello, world!', parentToolUseId: null }, ]); }); it('yields thinking content block', () => { const message = msg({ type: 'assistant', message: { content: [ { type: 'thinking', thinking: 'Let me think about this...' }, ], }, }); const results = [...transformSDKMessage(message)]; expect(results).toEqual([ { type: 'thinking', content: 'Let me think about this...', parentToolUseId: null }, ]); }); it('yields tool_use content block with all fields', () => { const message = msg({ type: 'assistant', message: { content: [ { type: 'tool_use', id: 'tool-123', name: 'Read', input: { file_path: '/test/file.ts' }, }, ], }, }); const results = [...transformSDKMessage(message)]; expect(results).toEqual([ { type: 'tool_use', id: 'tool-123', name: 'Read', input: { file_path: '/test/file.ts' }, parentToolUseId: null, }, ]); }); it('generates fallback id for tool_use without id', () => { const message = msg({ type: 'assistant', message: { content: [ { type: 'tool_use', name: 'Bash' }, ], }, }); const results = [...transformSDKMessage(message)]; expect(results.length).toBe(1); expect(results[0].type).toBe('tool_use'); expect((results[0] as any).id).toMatch(/^tool-\d+-\w+$/); expect((results[0] as any).name).toBe('Bash'); expect((results[0] as any).input).toEqual({}); }); it('handles multiple content blocks', () => { const message = msg({ type: 'assistant', message: { content: [ { type: 'thinking', thinking: 'Thinking...' }, { type: 'text', text: 'Here is my response' }, { type: 'tool_use', id: 'tool-1', name: 'Read', input: {} }, ], }, }); const results = [...transformSDKMessage(message)]; expect(results).toHaveLength(3); expect(results[0]).toEqual({ type: 'thinking', content: 'Thinking...', parentToolUseId: null }); expect(results[1]).toEqual({ type: 'text', content: 'Here is my response', parentToolUseId: null }); expect(results[2]).toMatchObject({ type: 'tool_use', id: 'tool-1', name: 'Read' }); }); it('preserves parent_tool_use_id for subagent context', () => { const message = msg({ type: 'assistant', parent_tool_use_id: 'parent-tool-abc', message: { content: [ { type: 'text', text: 'Subagent response' }, ], }, }); const results = [...transformSDKMessage(message)]; expect(results).toEqual([ { type: 'text', content: 'Subagent response', parentToolUseId: 'parent-tool-abc' }, ]); }); it('handles empty content array', () => { const message = msg({ type: 'assistant', message: { content: [] }, }); const results = [...transformSDKMessage(message)]; expect(results).toEqual([]); }); it('handles missing message.content', () => { const message = msg({ type: 'assistant', message: {}, }); const results = [...transformSDKMessage(message)]; expect(results).toEqual([]); }); it('skips empty text blocks', () => { const message = msg({ type: 'assistant', message: { content: [ { type: 'text', text: '' }, { type: 'text', text: 'Valid text' }, ], }, }); const results = [...transformSDKMessage(message)]; expect(results).toEqual([ { type: 'text', content: 'Valid text', parentToolUseId: null }, ]); }); it('skips "(no content)" placeholder text blocks', () => { const message = msg({ type: 'assistant', message: { content: [ { type: 'text', text: '(no content)' }, { type: 'tool_use', id: 'tool-1', name: 'Skill', input: { skill: 'md2docx' } }, ], }, }); const results = [...transformSDKMessage(message)]; expect(results).toEqual([ { type: 'tool_use', id: 'tool-1', name: 'Skill', input: { skill: 'md2docx' }, parentToolUseId: null }, ]); }); it('skips empty thinking blocks', () => { const message = msg({ type: 'assistant', message: { content: [ { type: 'thinking', thinking: '' }, { type: 'thinking', thinking: 'Valid thinking' }, ], }, }); const results = [...transformSDKMessage(message)]; expect(results).toEqual([ { type: 'thinking', content: 'Valid thinking', parentToolUseId: null }, ]); }); it('yields error event for assistant message with error field', () => { const message = msg({ type: 'assistant', error: 'rate_limit', message: { content: [ { type: 'text', text: 'Partial response' }, ], }, }); const results = [...transformSDKMessage(message)]; expect(results).toEqual([ { type: 'error', content: 'rate_limit' }, { type: 'text', content: 'Partial response', parentToolUseId: null }, ]); }); }); describe('user messages', () => { it('yields blocked event for blocked tool calls', () => { const message = msg({ type: 'user', _blocked: true, _blockReason: 'Command blocked: rm -rf /', }); const results = [...transformSDKMessage(message)]; expect(results).toEqual([ { type: 'blocked', content: 'Command blocked: rm -rf /' }, ]); }); it('yields tool_result for tool_use_result with parent_tool_use_id', () => { const message = msg({ type: 'user', parent_tool_use_id: 'tool-123', tool_use_result: 'File contents here', }); const results = [...transformSDKMessage(message)]; expect(results).toEqual([ { type: 'tool_result', id: 'tool-123', content: 'File contents here', isError: false, parentToolUseId: 'tool-123', toolUseResult: 'File contents here', }, ]); }); it('stringifies non-string tool_use_result', () => { const message = msg({ type: 'user', parent_tool_use_id: 'tool-123', tool_use_result: { status: 'success', data: [1, 2, 3] }, }); const results = [...transformSDKMessage(message)]; expect(results.length).toBe(1); expect(results[0].type).toBe('tool_result'); expect((results[0] as any).content).toContain('"status": "success"'); }); it('extracts text from array-based tool_use_result content', () => { const toolUseResult = [ { type: 'text', text: 'Agent completed successfully.' }, { type: 'text', text: 'Saved summary to notes.md' }, ]; const message = msg({ type: 'user', parent_tool_use_id: 'tool-123', tool_use_result: toolUseResult, }); const results = [...transformSDKMessage(message)]; expect(results).toEqual([ { type: 'tool_result', id: 'tool-123', content: 'Agent completed successfully.\nSaved summary to notes.md', isError: false, parentToolUseId: 'tool-123', toolUseResult, }, ]); }); it('yields tool_result from message.content blocks', () => { const message = msg({ type: 'user', message: { content: [ { type: 'tool_result', tool_use_id: 'tool-456', content: 'Result content', is_error: false, }, ], }, }); const results = [...transformSDKMessage(message)]; expect(results).toEqual([ { type: 'tool_result', id: 'tool-456', content: 'Result content', isError: false, parentToolUseId: null, }, ]); }); it('handles tool_result with is_error flag', () => { const message = msg({ type: 'user', message: { content: [ { type: 'tool_result', tool_use_id: 'tool-error', content: 'Error: File not found', is_error: true, }, ], }, }); const results = [...transformSDKMessage(message)]; expect(results).toEqual([ { type: 'tool_result', id: 'tool-error', content: 'Error: File not found', isError: true, parentToolUseId: null, }, ]); }); it('extracts text from array content in tool_result blocks', () => { const message = msg({ type: 'user', message: { content: [ { type: 'tool_result', tool_use_id: 'tool-agent', content: [ { type: 'text', text: 'Agent completed successfully.' }, { type: 'text', text: 'Next step queued.' }, ], }, ], }, }); const results = [...transformSDKMessage(message)]; expect(results).toEqual([ { type: 'tool_result', id: 'tool-agent', content: 'Agent completed successfully.\nNext step queued.', isError: false, parentToolUseId: null, }, ]); }); it('stringifies non-string object content in tool_result blocks', () => { const message = msg({ type: 'user', message: { content: [ { type: 'tool_result', tool_use_id: 'tool-obj', content: { key: 'value' }, }, ], }, }); const results = [...transformSDKMessage(message)]; expect(results.length).toBe(1); expect((results[0] as any).content).toContain('"key": "value"'); }); it('preserves tool_reference array content in tool_result blocks', () => { const toolRefs = [ { type: 'tool_reference', tool_name: 'WebSearch' }, { type: 'tool_reference', tool_name: 'Grep' }, ]; const message = msg({ type: 'user', message: { content: [ { type: 'tool_result', tool_use_id: 'tool-search-1', content: toolRefs, }, ], }, }); const results = [...transformSDKMessage(message)]; expect(results.length).toBe(1); expect((results[0] as any).content).toBe(JSON.stringify(toolRefs, null, 2)); }); it('uses parent_tool_use_id as fallback for tool_result id', () => { const message = msg({ type: 'user', parent_tool_use_id: 'fallback-id', message: { content: [ { type: 'tool_result', content: 'Some result' }, ], }, }); const results = [...transformSDKMessage(message)]; expect(results.length).toBe(1); expect((results[0] as any).id).toBe('fallback-id'); }); it('yields nothing for user messages without tool results', () => { const message = msg({ type: 'user', }); const results = [...transformSDKMessage(message)]; expect(results).toEqual([]); }); }); describe('stream_event messages', () => { it('yields tool_use for content_block_start with tool_use', () => { const message = msg({ type: 'stream_event', event: { type: 'content_block_start', content_block: { type: 'tool_use', id: 'stream-tool-1', name: 'Write', input: { file_path: '/test.ts' }, }, }, }); const results = [...transformSDKMessage(message)]; expect(results).toEqual([ { type: 'tool_use', id: 'stream-tool-1', name: 'Write', input: { file_path: '/test.ts' }, parentToolUseId: null, }, ]); }); it('generates fallback id for content_block_start without id', () => { const message = msg({ type: 'stream_event', event: { type: 'content_block_start', content_block: { type: 'tool_use', name: 'Glob', }, }, }); const results = [...transformSDKMessage(message)]; expect(results.length).toBe(1); expect((results[0] as any).id).toMatch(/^tool-\d+$/); }); it('yields thinking for content_block_start with thinking', () => { const message = msg({ type: 'stream_event', event: { type: 'content_block_start', content_block: { type: 'thinking', thinking: 'Initial thinking...', }, }, }); const results = [...transformSDKMessage(message)]; expect(results).toEqual([ { type: 'thinking', content: 'Initial thinking...', parentToolUseId: null }, ]); }); it('yields text for content_block_start with text', () => { const message = msg({ type: 'stream_event', event: { type: 'content_block_start', content_block: { type: 'text', text: 'Starting response...', }, }, }); const results = [...transformSDKMessage(message)]; expect(results).toEqual([ { type: 'text', content: 'Starting response...', parentToolUseId: null }, ]); }); it('yields thinking for thinking_delta', () => { const message = msg({ type: 'stream_event', event: { type: 'content_block_delta', delta: { type: 'thinking_delta', thinking: 'More thinking...', }, }, }); const results = [...transformSDKMessage(message)]; expect(results).toEqual([ { type: 'thinking', content: 'More thinking...', parentToolUseId: null }, ]); }); it('yields text for text_delta', () => { const message = msg({ type: 'stream_event', event: { type: 'content_block_delta', delta: { type: 'text_delta', text: ' additional text', }, }, }); const results = [...transformSDKMessage(message)]; expect(results).toEqual([ { type: 'text', content: ' additional text', parentToolUseId: null }, ]); }); it('yields nothing for empty thinking in content_block_start', () => { const message = msg({ type: 'stream_event', event: { type: 'content_block_start', content_block: { type: 'thinking', thinking: '', }, }, }); const results = [...transformSDKMessage(message)]; expect(results).toEqual([]); }); it('yields nothing for empty text in content_block_start', () => { const message = msg({ type: 'stream_event', event: { type: 'content_block_start', content_block: { type: 'text', text: '', }, }, }); const results = [...transformSDKMessage(message)]; expect(results).toEqual([]); }); it('yields nothing for empty thinking_delta', () => { const message = msg({ type: 'stream_event', event: { type: 'content_block_delta', delta: { type: 'thinking_delta', thinking: '', }, }, }); const results = [...transformSDKMessage(message)]; expect(results).toEqual([]); }); it('yields nothing for empty text_delta', () => { const message = msg({ type: 'stream_event', event: { type: 'content_block_delta', delta: { type: 'text_delta', text: '', }, }, }); const results = [...transformSDKMessage(message)]; expect(results).toEqual([]); }); it('preserves parent_tool_use_id in stream events', () => { const message = msg({ type: 'stream_event', parent_tool_use_id: 'subagent-parent', event: { type: 'content_block_delta', delta: { type: 'text_delta', text: 'Subagent stream text', }, }, }); const results = [...transformSDKMessage(message)]; expect(results).toEqual([ { type: 'text', content: 'Subagent stream text', parentToolUseId: 'subagent-parent' }, ]); }); it('handles missing event property', () => { const message = msg({ type: 'stream_event', }); const results = [...transformSDKMessage(message)]; expect(results).toEqual([]); }); }); describe('result messages', () => { it('yields context_window_update for successful result messages with modelUsage', () => { const message = msg({ type: 'result', modelUsage: { 'claude-sonnet-4-5-20250514': { inputTokens: 1000, cacheCreationInputTokens: 500, cacheReadInputTokens: 200, outputTokens: 300, webSearchRequests: 0, costUSD: 0.01, contextWindow: 200000, maxOutputTokens: 8192, }, }, }); const results = [...transformSDKMessage(message)]; expect(results).toEqual([ { type: 'context_window_update', contextWindow: 200000 }, ]); }); it('yields error and context_window_update for failed result messages', () => { const message = msg({ type: 'result', subtype: 'error_max_turns', errors: ['Hit maximum turn limit'], }); const results = [...transformSDKMessage(message)]; expect(results).toEqual([ { type: 'error', content: 'Hit maximum turn limit' }, { type: 'context_window_update', contextWindow: 200000 }, ]); }); it('yields context_window_update with 1M for [1m] models', () => { const message = msg({ type: 'result', modelUsage: { 'claude-opus-4-6[1m]': { inputTokens: 1000, outputTokens: 300, cacheReadInputTokens: 0, cacheCreationInputTokens: 0, webSearchRequests: 0, costUSD: 0.01, contextWindow: 1000000, maxOutputTokens: 32000, }, }, }); const results = [...transformSDKMessage(message)]; expect(results).toEqual([ { type: 'context_window_update', contextWindow: 1000000 }, ]); }); it('prefers the exact intended model when modelUsage includes multiple entries', () => { const message = msg({ type: 'result', modelUsage: { 'custom-subagent-model': { inputTokens: 1000, outputTokens: 300, cacheReadInputTokens: 0, cacheCreationInputTokens: 0, webSearchRequests: 0, costUSD: 0.01, contextWindow: 1000000, maxOutputTokens: 32000, }, 'custom-main-model': { inputTokens: 1000, outputTokens: 300, cacheReadInputTokens: 0, cacheCreationInputTokens: 0, webSearchRequests: 0, costUSD: 0.01, contextWindow: 200000, maxOutputTokens: 32000, }, }, }); const results = [...transformSDKMessage(message, { intendedModel: 'custom-main-model' })]; expect(results).toEqual([ { type: 'context_window_update', contextWindow: 200000 }, ]); }); it('matches built-in aliases against SDK modelUsage keys when unambiguous', () => { const message = msg({ type: 'result', modelUsage: { 'claude-sonnet-4-5-20250514': { inputTokens: 1000, outputTokens: 300, cacheReadInputTokens: 0, cacheCreationInputTokens: 0, webSearchRequests: 0, costUSD: 0.01, contextWindow: 200000, maxOutputTokens: 32000, }, 'claude-opus-4-6[1m]': { inputTokens: 1000, outputTokens: 300, cacheReadInputTokens: 0, cacheCreationInputTokens: 0, webSearchRequests: 0, costUSD: 0.01, contextWindow: 1000000, maxOutputTokens: 32000, }, }, }); const results = [...transformSDKMessage(message, { intendedModel: 'opus[1m]' })]; expect(results).toEqual([ { type: 'context_window_update', contextWindow: 1000000 }, ]); }); it('does not override the heuristic when multi-model result usage is ambiguous', () => { const message = msg({ type: 'result', modelUsage: { 'claude-sonnet-4-5-20250514': { inputTokens: 1000, outputTokens: 300, cacheReadInputTokens: 0, cacheCreationInputTokens: 0, webSearchRequests: 0, costUSD: 0.01, contextWindow: 200000, maxOutputTokens: 32000, }, 'claude-sonnet-4-6-20260101': { inputTokens: 1000, outputTokens: 300, cacheReadInputTokens: 0, cacheCreationInputTokens: 0, webSearchRequests: 0, costUSD: 0.01, contextWindow: 500000, maxOutputTokens: 32000, }, }, }); const results = [...transformSDKMessage(message, { intendedModel: 'sonnet' })]; expect(results).toEqual([]); }); }); describe('assistant message usage extraction', () => { it('yields usage info from main agent assistant message', () => { const message = msg({ type: 'assistant', parent_tool_use_id: null, // Main agent message: { content: [{ type: 'text', text: 'Hello' }], usage: { input_tokens: 1000, output_tokens: 500, cache_creation_input_tokens: 300, cache_read_input_tokens: 200, }, }, }); const results = [...transformSDKMessage(message, { intendedModel: 'sonnet' })]; const usageResults = results.filter(r => r.type === 'usage'); expect(usageResults).toHaveLength(1); const usage = (usageResults[0] as any).usage; expect(usage.inputTokens).toBe(1000); expect(usage.cacheCreationInputTokens).toBe(300); expect(usage.cacheReadInputTokens).toBe(200); expect(usage.contextTokens).toBe(1500); // 1000 + 300 + 200 expect(usage.contextWindow).toBe(200000); // Standard context window expect(usage.percentage).toBe(1); // 1500 / 200000 * 100 rounded }); it('skips usage extraction for subagent messages', () => { const message = msg({ type: 'assistant', parent_tool_use_id: 'subagent-task-123', // Subagent message: { content: [{ type: 'text', text: 'Subagent response' }], usage: { input_tokens: 5000, output_tokens: 1000, cache_creation_input_tokens: 500, cache_read_input_tokens: 100, }, }, }); const results = [...transformSDKMessage(message)]; const usageResults = results.filter(r => r.type === 'usage'); expect(usageResults).toHaveLength(0); }); it('uses custom context limits when provided', () => { const message = msg({ type: 'assistant', parent_tool_use_id: null, message: { content: [{ type: 'text', text: 'Hello' }], usage: { input_tokens: 50000, output_tokens: 10000, cache_creation_input_tokens: 0, cache_read_input_tokens: 0, }, }, }); const results = [...transformSDKMessage(message, { intendedModel: 'custom-model', customContextLimits: { 'custom-model': 500000 }, })]; const usageResults = results.filter(r => r.type === 'usage'); expect(usageResults).toHaveLength(1); const usage = (usageResults[0] as any).usage; expect(usage.contextWindow).toBe(500000); // Custom context limit expect(usage.percentage).toBe(10); // 50000 / 500000 * 100 = 10% }); it('uses custom context limits over standard window', () => { const message = msg({ type: 'assistant', parent_tool_use_id: null, message: { content: [{ type: 'text', text: 'Hello' }], usage: { input_tokens: 100000, output_tokens: 10000, cache_creation_input_tokens: 0, cache_read_input_tokens: 0, }, }, }); const results = [...transformSDKMessage(message, { intendedModel: 'sonnet', customContextLimits: { 'sonnet': 256000 }, })]; const usageResults = results.filter(r => r.type === 'usage'); expect(usageResults).toHaveLength(1); const usage = (usageResults[0] as any).usage; expect(usage.contextWindow).toBe(256000); // Custom limit takes precedence expect(usage.percentage).toBe(39); // 100000 / 256000 * 100 ≈ 39% }); it('handles missing usage field gracefully', () => { const message = msg({ type: 'assistant', parent_tool_use_id: null, message: { content: [{ type: 'text', text: 'Hello' }], }, }); const results = [...transformSDKMessage(message)]; const usageResults = results.filter(r => r.type === 'usage'); expect(usageResults).toHaveLength(0); }); it('handles missing token fields with defaults', () => { const message = msg({ type: 'assistant', parent_tool_use_id: null, message: { content: [{ type: 'text', text: 'Hello' }], usage: {}, // Empty usage object }, }); const results = [...transformSDKMessage(message, { intendedModel: 'sonnet' })]; const usageResults = results.filter(r => r.type === 'usage'); expect(usageResults).toHaveLength(1); const usage = (usageResults[0] as any).usage; expect(usage.inputTokens).toBe(0); expect(usage.cacheCreationInputTokens).toBe(0); expect(usage.cacheReadInputTokens).toBe(0); expect(usage.contextTokens).toBe(0); }); }); describe('error messages', () => { it('yields error event from assistant message with error field', () => { const message = msg({ type: 'assistant', error: 'unknown', message: { content: [] }, }); const results = [...transformSDKMessage(message)]; expect(results).toEqual([ { type: 'error', content: 'unknown' }, ]); }); it('yields nothing for assistant message without error field', () => { const message = msg({ type: 'assistant', message: { content: [] }, }); const results = [...transformSDKMessage(message)]; expect(results).toEqual([]); }); }); describe('unhandled message types', () => { it('yields nothing for tool_progress messages', () => { const message = msg({ type: 'tool_progress', tool_use_id: 'tool-1', tool_name: 'Bash', elapsed_time_seconds: 5, }); const results = [...transformSDKMessage(message)]; expect(results).toEqual([]); }); it('yields nothing for auth_status messages', () => { const message = msg({ type: 'auth_status', isAuthenticating: true, output: [], }); const results = [...transformSDKMessage(message)]; expect(results).toEqual([]); }); }); }); ================================================ FILE: tests/unit/core/sdk/typeGuards.test.ts ================================================ import { isSessionInitEvent, isStreamChunk } from '@/core/sdk/typeGuards'; import type { TransformEvent } from '@/core/sdk/types'; describe('isSessionInitEvent', () => { it('should return true for session_init events', () => { const event: TransformEvent = { type: 'session_init', sessionId: 'abc-123' }; expect(isSessionInitEvent(event)).toBe(true); }); it('should return true for session_init with agents', () => { const event: TransformEvent = { type: 'session_init', sessionId: 'abc', agents: ['agent1'] }; expect(isSessionInitEvent(event)).toBe(true); }); it('should return false for stream chunk events', () => { const textChunk: TransformEvent = { type: 'text', content: 'hello' }; const doneChunk: TransformEvent = { type: 'done' }; const toolUseChunk: TransformEvent = { type: 'tool_use', id: 't1', name: 'Read', input: {} }; expect(isSessionInitEvent(textChunk)).toBe(false); expect(isSessionInitEvent(doneChunk)).toBe(false); expect(isSessionInitEvent(toolUseChunk)).toBe(false); }); }); describe('isStreamChunk', () => { it('should return true for stream chunk events', () => { const textChunk: TransformEvent = { type: 'text', content: 'hello' }; const doneChunk: TransformEvent = { type: 'done' }; const errorChunk: TransformEvent = { type: 'error', content: 'oops' }; const blockedChunk: TransformEvent = { type: 'blocked', content: 'blocked cmd' }; const toolUseChunk: TransformEvent = { type: 'tool_use', id: 't1', name: 'Read', input: {} }; const toolResultChunk: TransformEvent = { type: 'tool_result', id: 't1', content: 'result' }; expect(isStreamChunk(textChunk)).toBe(true); expect(isStreamChunk(doneChunk)).toBe(true); expect(isStreamChunk(errorChunk)).toBe(true); expect(isStreamChunk(blockedChunk)).toBe(true); expect(isStreamChunk(toolUseChunk)).toBe(true); expect(isStreamChunk(toolResultChunk)).toBe(true); }); it('should return false for session_init events', () => { const event: TransformEvent = { type: 'session_init', sessionId: 'abc-123' }; expect(isStreamChunk(event)).toBe(false); }); }); ================================================ FILE: tests/unit/core/security/ApprovalManager.test.ts ================================================ import { buildPermissionUpdates, getActionDescription, getActionPattern, matchesRulePattern, } from '../../../../src/core/security/ApprovalManager'; describe('getActionPattern', () => { it('extracts command from Bash tool input', () => { expect(getActionPattern('Bash', { command: 'git status' })).toBe('git status'); }); it('trims whitespace from Bash commands', () => { expect(getActionPattern('Bash', { command: ' git status ' })).toBe('git status'); }); it('returns empty string for non-string Bash command', () => { expect(getActionPattern('Bash', { command: 123 })).toBe(''); }); it('extracts file_path for Read/Write/Edit tools', () => { expect(getActionPattern('Read', { file_path: '/test/file.md' })).toBe('/test/file.md'); expect(getActionPattern('Write', { file_path: '/test/output.md' })).toBe('/test/output.md'); expect(getActionPattern('Edit', { file_path: '/test/edit.md' })).toBe('/test/edit.md'); }); it('returns null when file_path is missing', () => { expect(getActionPattern('Read', {})).toBeNull(); }); it('extracts notebook_path for NotebookEdit tool', () => { expect(getActionPattern('NotebookEdit', { notebook_path: '/test/notebook.ipynb' })).toBe('/test/notebook.ipynb'); }); it('falls back to file_path for NotebookEdit when notebook_path is missing', () => { expect(getActionPattern('NotebookEdit', { file_path: '/test/notebook.ipynb' })).toBe('/test/notebook.ipynb'); }); it('returns null for NotebookEdit when both paths are missing', () => { expect(getActionPattern('NotebookEdit', {})).toBeNull(); }); it('returns null when file_path is empty string', () => { expect(getActionPattern('Read', { file_path: '' })).toBeNull(); }); it('extracts pattern for Glob/Grep tools', () => { expect(getActionPattern('Glob', { pattern: '**/*.md' })).toBe('**/*.md'); expect(getActionPattern('Grep', { pattern: 'TODO' })).toBe('TODO'); }); it('returns JSON for unknown tools', () => { expect(getActionPattern('UnknownTool', { foo: 'bar' })).toBe('{"foo":"bar"}'); }); }); describe('getActionDescription', () => { it('describes Bash tool actions', () => { expect(getActionDescription('Bash', { command: 'git status' })).toBe('Run command: git status'); }); it('describes file tool actions', () => { expect(getActionDescription('Read', { file_path: '/f.md' })).toBe('Read file: /f.md'); expect(getActionDescription('Write', { file_path: '/f.md' })).toBe('Write to file: /f.md'); expect(getActionDescription('Edit', { file_path: '/f.md' })).toBe('Edit file: /f.md'); }); it('describes search tool actions', () => { expect(getActionDescription('Glob', { pattern: '*.md' })).toBe('Search files matching: *.md'); expect(getActionDescription('Grep', { pattern: 'TODO' })).toBe('Search content matching: TODO'); }); it('describes unknown tools with JSON', () => { expect(getActionDescription('Custom', { a: 1 })).toBe('Custom: {"a":1}'); }); }); describe('matchesRulePattern', () => { it('matches when no rule pattern is provided', () => { expect(matchesRulePattern('Bash', 'git status', undefined)).toBe(true); }); it('matches wildcard rule', () => { expect(matchesRulePattern('Bash', 'anything', '*')).toBe(true); }); it('matches exact rule', () => { expect(matchesRulePattern('Bash', 'git status', 'git status')).toBe(true); }); it('rejects non-matching Bash rule without wildcard', () => { expect(matchesRulePattern('Bash', 'git status', 'git commit')).toBe(false); }); it('matches Bash wildcard prefix', () => { expect(matchesRulePattern('Bash', 'git status', 'git *')).toBe(true); expect(matchesRulePattern('Bash', 'git commit', 'git *')).toBe(true); expect(matchesRulePattern('Bash', 'npm install', 'git *')).toBe(false); }); it('matches Bash CC-format colon wildcard', () => { expect(matchesRulePattern('Bash', 'npm install', 'npm:*')).toBe(true); expect(matchesRulePattern('Bash', 'npm run build', 'npm run:*')).toBe(true); expect(matchesRulePattern('Bash', 'yarn install', 'npm:*')).toBe(false); }); it('does not allow Bash prefix collisions without a separator', () => { expect(matchesRulePattern('Bash', 'github status', 'git:*')).toBe(false); expect(matchesRulePattern('Bash', 'npmish install', 'npm:*')).toBe(false); expect(matchesRulePattern('Bash', 'npm runner build', 'npm run:*')).toBe(false); }); it('matches file path prefix for Read tool', () => { expect(matchesRulePattern('Read', '/test/vault/notes/file.md', '/test/vault/')).toBe(true); expect(matchesRulePattern('Read', '/other/path/file.md', '/test/vault/')).toBe(false); }); it('respects path segment boundaries', () => { expect(matchesRulePattern('Read', '/test/vault/notes/file.md', '/test/vault/notes')).toBe(true); expect(matchesRulePattern('Read', '/test/vault/notes2/file.md', '/test/vault/notes')).toBe(false); }); it('matches exact file path (same length, no trailing slash)', () => { expect(matchesRulePattern('Read', '/test/vault/file.md', '/test/vault/file.md')).toBe(true); }); it('matches file path with backslash normalization for same-length paths', () => { // Both normalize to the same path via backslash→forward slash replacement, // caught by the early exact match check (line 77) before isPathPrefixMatch. expect(matchesRulePattern('Write', '/test/vault\\file.md', '/test/vault/file.md')).toBe(true); }); it('allows simple prefix matching for non-file, non-bash tools', () => { expect(matchesRulePattern('Glob', '**/*.md', '**/*')).toBe(true); expect(matchesRulePattern('Grep', 'TODO in file', 'TODO')).toBe(true); }); it('returns false for non-file, non-bash tools when prefix does not match', () => { expect(matchesRulePattern('Glob', 'src/**', 'tests/**')).toBe(false); }); it('matches exact Bash prefix without trailing space/wildcard via CC format', () => { // matchesBashPrefix exact match: action === prefix expect(matchesRulePattern('Bash', 'npm', 'npm:*')).toBe(true); }); it('does not match when action pattern is null', () => { expect(matchesRulePattern('Read', null, '/test/vault/')).toBe(false); expect(matchesRulePattern('Read', null, '*')).toBe(false); }); it('still matches when no rule pattern and action is null', () => { expect(matchesRulePattern('Read', null, undefined)).toBe(true); }); }); describe('buildPermissionUpdates', () => { it('constructs allow rule for allow decision', () => { const updates = buildPermissionUpdates('Bash', { command: 'git status' }, 'allow'); expect(updates).toEqual([{ type: 'addRules', behavior: 'allow', rules: [{ toolName: 'Bash', ruleContent: 'git status' }], destination: 'session', }]); }); it('uses projectSettings destination for always decisions', () => { const updates = buildPermissionUpdates('Bash', { command: 'git status' }, 'allow-always'); expect(updates[0].destination).toBe('projectSettings'); }); it('uses SDK suggestions when available', () => { const suggestions = [{ type: 'addRules' as const, behavior: 'allow' as const, rules: [{ toolName: 'Bash', ruleContent: 'git *' }], destination: 'session' as const, }]; const updates = buildPermissionUpdates('Bash', { command: 'git status' }, 'allow-always', suggestions); expect(updates).toEqual([{ type: 'addRules', behavior: 'allow', rules: [{ toolName: 'Bash', ruleContent: 'git *' }], destination: 'projectSettings', }]); }); it('falls back to constructed rule when no addRules suggestions', () => { const updates = buildPermissionUpdates('Bash', { command: 'ls' }, 'allow', []); expect(updates).toEqual([{ type: 'addRules', behavior: 'allow', rules: [{ toolName: 'Bash', ruleContent: 'ls' }], destination: 'session', }]); }); it('omits ruleContent when pattern is null (missing file_path)', () => { const updates = buildPermissionUpdates('Read', {}, 'allow'); expect(updates).toEqual([{ type: 'addRules', behavior: 'allow', rules: [{ toolName: 'Read' }], destination: 'session', }]); }); it('includes addDirectories suggestions without overriding destination', () => { const suggestions = [ { type: 'addRules' as const, behavior: 'allow' as const, rules: [{ toolName: 'Read', ruleContent: '/external/path/*' }], destination: 'session' as const, }, { type: 'addDirectories' as const, directories: ['/external/path'], destination: 'session' as const, }, ]; const updates = buildPermissionUpdates('Read', { file_path: '/external/path/file.md' }, 'allow-always', suggestions); expect(updates).toHaveLength(2); expect(updates[0]).toEqual({ type: 'addRules', behavior: 'allow', rules: [{ toolName: 'Read', ruleContent: '/external/path/*' }], destination: 'projectSettings', }); expect(updates[1]).toEqual({ type: 'addDirectories', directories: ['/external/path'], destination: 'session', }); }); it('includes removeDirectories suggestions without overriding destination', () => { const suggestions = [ { type: 'removeDirectories' as const, directories: ['/revoked/path'], destination: 'session' as const, }, ]; const updates = buildPermissionUpdates('Bash', { command: 'ls' }, 'allow-always', suggestions); expect(updates).toHaveLength(2); expect(updates[0]).toEqual({ type: 'addRules', behavior: 'allow', rules: [{ toolName: 'Bash', ruleContent: 'ls' }], destination: 'projectSettings', }); expect(updates[1]).toEqual({ type: 'removeDirectories', directories: ['/revoked/path'], destination: 'session', }); }); it('includes setMode suggestions without overriding destination', () => { const suggestions = [ { type: 'setMode' as const, mode: 'default' as const, destination: 'session' as const, }, ]; const updates = buildPermissionUpdates('Bash', { command: 'echo hi' }, 'allow-always', suggestions); expect(updates).toHaveLength(2); expect(updates[0]).toEqual({ type: 'addRules', behavior: 'allow', rules: [{ toolName: 'Bash', ruleContent: 'echo hi' }], destination: 'projectSettings', }); expect(updates[1]).toEqual({ type: 'setMode', mode: 'default', destination: 'session', }); }); it('prepends constructed addRules when suggestions have no addRules type', () => { const suggestions = [ { type: 'addDirectories' as const, directories: ['/new/dir'], destination: 'session' as const, }, ]; const updates = buildPermissionUpdates('Read', { file_path: '/new/dir/file.md' }, 'allow', suggestions); expect(updates).toHaveLength(2); expect(updates[0].type).toBe('addRules'); expect(updates[1].type).toBe('addDirectories'); }); it('does not prepend addRules when replaceRules suggestion is present', () => { const suggestions = [ { type: 'replaceRules' as const, behavior: 'allow' as const, rules: [{ toolName: 'Bash', ruleContent: 'git *' }], destination: 'session' as const, }, ]; const updates = buildPermissionUpdates('Bash', { command: 'git status' }, 'allow-always', suggestions); expect(updates).toHaveLength(1); expect(updates[0]).toEqual({ type: 'replaceRules', behavior: 'allow', rules: [{ toolName: 'Bash', ruleContent: 'git *' }], destination: 'projectSettings', }); }); it('prepends addRules when only removeRules suggestion is present', () => { const suggestions = [ { type: 'removeRules' as const, behavior: 'allow' as const, rules: [{ toolName: 'Bash', ruleContent: 'old-pattern' }], destination: 'session' as const, }, ]; const updates = buildPermissionUpdates('Bash', { command: 'git status' }, 'allow', suggestions); expect(updates).toHaveLength(2); expect(updates[0].type).toBe('addRules'); expect(updates[0]).toMatchObject({ behavior: 'allow', rules: [{ toolName: 'Bash', ruleContent: 'git status' }], destination: 'session', }); expect(updates[1].type).toBe('removeRules'); }); it('preserves original behavior on removeRules suggestions', () => { const suggestions = [ { type: 'removeRules' as const, behavior: 'deny' as const, rules: [{ toolName: 'Bash', ruleContent: 'git status' }], destination: 'session' as const, }, ]; // removeRules.behavior is NOT overridden — it specifies which list to remove from const updates = buildPermissionUpdates('Bash', { command: 'git status' }, 'allow-always', suggestions); const removeEntry = updates.find(u => u.type === 'removeRules'); expect(removeEntry).toBeDefined(); expect(removeEntry!.behavior).toBe('deny'); expect(removeEntry!.destination).toBe('session'); }); }); ================================================ FILE: tests/unit/core/security/BashPathValidator.test.ts ================================================ import { checkBashPathAccess, cleanPathToken, findBashCommandPathViolation, findBashPathViolationInSegment, getBashSegmentCommandName, isBashInputRedirectOperator, isBashOutputOptionExpectingValue, isBashOutputRedirectOperator, isPathLikeToken, splitBashTokensIntoSegments, tokenizeBashCommand, } from '@/core/security/BashPathValidator'; import type { PathAccessType } from '@/utils/path'; describe('BashPathValidator', () => { const isWindows = process.platform === 'win32'; const createMockPathContext = (pathMap: Record<string, PathAccessType>) => ({ getPathAccessType: jest.fn().mockImplementation((path: string) => { return pathMap[path] || 'none'; }), }); describe('tokenizeBashCommand', () => { it('splits simple command', () => { const tokens = tokenizeBashCommand('ls -la'); expect(tokens).toEqual(['ls', '-la']); }); it('handles quoted strings', () => { const tokens = tokenizeBashCommand('echo "hello world"'); expect(tokens).toEqual(['echo', 'hello world']); }); it('handles backticked strings as command substitution (not quotes)', () => { const tokens = tokenizeBashCommand('echo `test`'); expect(tokens).toEqual(['echo', '`test`']); }); it('handles mixed quotes and spaces', () => { const tokens = tokenizeBashCommand('git commit -m "added feature"'); expect(tokens).toEqual(['git', 'commit', '-m', 'added feature']); }); it('handles pipes', () => { const tokens = tokenizeBashCommand('cat file.txt | grep "pattern"'); expect(tokens).toEqual(['cat', 'file.txt', '|', 'grep', 'pattern']); }); it('handles output redirection', () => { const tokens = tokenizeBashCommand('ls > output.txt'); expect(tokens).toEqual(['ls', '>', 'output.txt']); }); it('handles input redirection', () => { const tokens = tokenizeBashCommand('cat < input.txt'); expect(tokens).toEqual(['cat', '<', 'input.txt']); }); it('handles chained commands', () => { const tokens = tokenizeBashCommand('cd /tmp && ls && pwd'); expect(tokens).toEqual(['cd', '/tmp', '&&', 'ls', '&&', 'pwd']); }); it('handles semicolon separator', () => { const tokens = tokenizeBashCommand('echo first; echo second'); expect(tokens).toEqual(['echo', 'first;', 'echo', 'second']); }); it('handles OR separator', () => { const tokens = tokenizeBashCommand('cat file1.txt || cat file2.txt'); expect(tokens).toEqual(['cat', 'file1.txt', '||', 'cat', 'file2.txt']); }); }); describe('splitBashTokensIntoSegments', () => { it('splits by && operator', () => { const tokens = ['echo', 'a', '&&', 'echo', 'b', '&&', 'echo', 'c']; const segments = splitBashTokensIntoSegments(tokens); expect(segments).toEqual([ ['echo', 'a'], ['echo', 'b'], ['echo', 'c'], ]); }); it('splits by || operator', () => { const tokens = ['cat', 'a.txt', '||', 'cat', 'b.txt']; const segments = splitBashTokensIntoSegments(tokens); expect(segments).toEqual([ ['cat', 'a.txt'], ['cat', 'b.txt'], ]); }); it('handles single segment', () => { const tokens = ['ls', '-la']; const segments = splitBashTokensIntoSegments(tokens); expect(segments).toEqual([['ls', '-la']]); }); }); describe('getBashSegmentCommandName', () => { it('extracts command name', () => { const result = getBashSegmentCommandName(['git', 'commit', '-m', 'message']); expect(result).toEqual({ cmdName: 'git', cmdIndex: 0 }); }); it('skips sudo wrapper', () => { const result = getBashSegmentCommandName(['sudo', 'cat', '/etc/passwd']); expect(result).toEqual({ cmdName: 'cat', cmdIndex: 1 }); }); it('skips env wrapper', () => { const result = getBashSegmentCommandName(['env', 'EDITOR=vim', 'vim']); expect(result).toEqual({ cmdName: 'vim', cmdIndex: 2 }); }); it('handles segment with only wrappers', () => { const result = getBashSegmentCommandName(['sudo', 'env']); expect(result).toEqual({ cmdName: '', cmdIndex: 2 }); }); it('skips multiple VAR=value tokens', () => { const result = getBashSegmentCommandName(['env', 'A=1', 'B=2', 'C=3', 'cat']); expect(result).toEqual({ cmdName: 'cat', cmdIndex: 4 }); }); it('skips VAR=value tokens even without env prefix', () => { // Inline env vars: VAR=value command const result = getBashSegmentCommandName(['PATH=/bin', 'ls']); expect(result).toEqual({ cmdName: 'ls', cmdIndex: 1 }); }); it('handles segment with only VAR=value tokens', () => { const result = getBashSegmentCommandName(['VAR1=val1', 'VAR2=val2']); expect(result).toEqual({ cmdName: '', cmdIndex: 2 }); }); it('does not skip flags with equals signs', () => { // --option=value is a flag, not an env var assignment - cmdIndex should be 0 // cmdName is path.basename() of the token, which extracts 'path' from '--output=/path' const result = getBashSegmentCommandName(['--output=/path', 'command']); expect(result.cmdIndex).toBe(0); }); it('does not skip short flags with equals signs', () => { // -o=value starts with -, so it's not skipped - cmdIndex should be 0 const result = getBashSegmentCommandName(['-o=output.txt', 'command']); expect(result.cmdIndex).toBe(0); }); it('handles token with equals at start', () => { // =value contains = but doesn't start with -, so it's treated as VAR=value const result = getBashSegmentCommandName(['=value', 'cat']); expect(result).toEqual({ cmdName: 'cat', cmdIndex: 1 }); }); it('handles empty value assignment', () => { const result = getBashSegmentCommandName(['env', 'VAR=', 'cat']); expect(result).toEqual({ cmdName: 'cat', cmdIndex: 2 }); }); }); describe('Bash redirect operators', () => { describe('isBashOutputRedirectOperator', () => { it.each(['>', '>>', '1>', '2>', '&>', '&>>', '>|'])( 'detects %s as output redirect', (op) => { expect(isBashOutputRedirectOperator(op)).toBe(true); } ); it.each(['ls', '|', '&&'])( 'does not detect %s as output redirect', (token) => { expect(isBashOutputRedirectOperator(token)).toBe(false); } ); }); describe('isBashInputRedirectOperator', () => { it.each(['<', '<<', '0<', '0<<'])( 'detects %s as input redirect', (op) => { expect(isBashInputRedirectOperator(op)).toBe(true); } ); it.each(['ls', '>', '&&'])( 'does not detect %s as input redirect', (token) => { expect(isBashInputRedirectOperator(token)).toBe(false); } ); }); describe('isBashOutputOptionExpectingValue', () => { it.each(['-o', '--output', '--out', '--output-file'])( 'detects %s as output option', (opt) => { expect(isBashOutputOptionExpectingValue(opt)).toBe(true); } ); it.each(['ls', '-v', '--help'])( 'does not detect %s as output option', (token) => { expect(isBashOutputOptionExpectingValue(token)).toBe(false); } ); }); }); describe('cleanPathToken', () => { it.each([ ['"path/to/file"', 'path/to/file'], ["'path/to/file'", 'path/to/file'], ['`path/to/file`', 'path/to/file'], ['(path/to/file)', 'path/to/file'], ['{path/to/file}', 'path/to/file'], ['["path/to/file"]', 'path/to/file'], ['path/to/file;', 'path/to/file'], ['path/to/file,', 'path/to/file'], ])('strips delimiters from %s', (input, expected) => { expect(cleanPathToken(input)).toBe(expected); }); it.each(['.', '/', '\\', '--', '', ' '])( 'returns null for %j', (input) => { expect(cleanPathToken(input)).toBeNull(); } ); it('strips nested quotes after delimiters', () => { expect(cleanPathToken('("path/to/file")')).toBe('path/to/file'); expect(cleanPathToken("['path/to/file']")).toBe('path/to/file'); expect(cleanPathToken('{`path/to/file`}')).toBe('path/to/file'); }); it.each(['""', "''", '``'])( 'returns null for empty %s after stripping', (input) => { expect(cleanPathToken(input)).toBeNull(); } ); it('strips unmatched leading single quote', () => { expect(cleanPathToken("'/etc/passwd")).toBe('/etc/passwd'); }); it('strips unmatched leading double quote', () => { expect(cleanPathToken('"/etc/passwd')).toBe('/etc/passwd'); }); it('strips unmatched leading backtick', () => { expect(cleanPathToken('`/etc/passwd')).toBe('/etc/passwd'); }); it('strips unmatched trailing single quote', () => { expect(cleanPathToken("/etc/passwd'")).toBe('/etc/passwd'); }); it('strips unmatched trailing double quote', () => { expect(cleanPathToken('/etc/passwd"')).toBe('/etc/passwd'); }); it('strips unmatched trailing backtick', () => { expect(cleanPathToken('/etc/passwd`')).toBe('/etc/passwd'); }); it('handles mismatched quotes by stripping both', () => { expect(cleanPathToken("\"path/to'")).toBe("path/to"); expect(cleanPathToken("'path/to\"")).toBe("path/to"); }); }); describe('isPathLikeToken', () => { it('detects Unix-style home paths', () => { expect(isPathLikeToken('~/notes')).toBe(true); expect(isPathLikeToken('~')).toBe(true); }); it('detects Windows-style home paths only on Windows', () => { expect(isPathLikeToken('~\\notes')).toBe(isWindows); }); it('detects Unix-style relative paths', () => { expect(isPathLikeToken('./notes')).toBe(true); expect(isPathLikeToken('../notes')).toBe(true); expect(isPathLikeToken('..')).toBe(true); }); it('detects Windows-style relative paths only on Windows', () => { expect(isPathLikeToken('.\\notes')).toBe(isWindows); expect(isPathLikeToken('..\\notes')).toBe(isWindows); }); it('detects Unix-style absolute paths', () => { expect(isPathLikeToken('/tmp/note.md')).toBe(true); }); it('detects Windows-style absolute paths only on Windows', () => { expect(isPathLikeToken('C:\\temp\\note.md')).toBe(isWindows); expect(isPathLikeToken('\\\\server\\share\\note.md')).toBe(isWindows); }); it('does not treat dot-prefixed names as parent directories', () => { expect(isPathLikeToken('..hidden')).toBe(false); }); it('detects forward-slash paths on all platforms', () => { expect(isPathLikeToken('foo/bar')).toBe(true); }); it('rejects non-path tokens', () => { expect(isPathLikeToken('.')).toBe(false); expect(isPathLikeToken('/')).toBe(false); expect(isPathLikeToken('\\')).toBe(false); expect(isPathLikeToken('--')).toBe(false); expect(isPathLikeToken('')).toBe(false); expect(isPathLikeToken('plainword')).toBe(false); }); }); describe('isPathLikeToken with mocked platforms', () => { const originalPlatform = process.platform; afterEach(() => { Object.defineProperty(process, 'platform', { value: originalPlatform }); }); describe('on Windows (mocked)', () => { beforeEach(() => { Object.defineProperty(process, 'platform', { value: 'win32' }); }); it('detects Windows drive letter paths', () => { expect(isPathLikeToken('C:\\Users\\test')).toBe(true); expect(isPathLikeToken('D:/Projects/vault')).toBe(true); }); it('detects Windows UNC paths', () => { expect(isPathLikeToken('\\\\server\\share')).toBe(true); expect(isPathLikeToken('//server/share')).toBe(true); }); it('detects Windows-style home and relative paths', () => { expect(isPathLikeToken('~\\Documents')).toBe(true); expect(isPathLikeToken('.\\local')).toBe(true); expect(isPathLikeToken('..\\parent')).toBe(true); }); it('detects paths with backslashes', () => { expect(isPathLikeToken('folder\\file.txt')).toBe(true); }); it('detects MSYS-style paths as forward slash paths', () => { expect(isPathLikeToken('/c/Users/test')).toBe(true); }); }); describe('on Unix (mocked)', () => { beforeEach(() => { Object.defineProperty(process, 'platform', { value: 'darwin' }); }); it('does not detect Windows drive letter paths', () => { expect(isPathLikeToken('C:\\Users\\test')).toBe(false); }); it('does not detect Windows-style backslash paths', () => { expect(isPathLikeToken('~\\Documents')).toBe(false); expect(isPathLikeToken('.\\local')).toBe(false); }); it('detects Unix absolute paths', () => { expect(isPathLikeToken('/home/user')).toBe(true); expect(isPathLikeToken('/c/Users/test')).toBe(true); }); }); }); describe('checkBashPathAccess', () => { const createMockContext = (accessType: PathAccessType) => ({ getPathAccessType: jest.fn().mockReturnValue(accessType), }); it('returns null for vault paths', () => { const context = createMockContext('vault'); const result = checkBashPathAccess('/vault/file.txt', 'read', context); expect(result).toBeNull(); }); it('returns null for readwrite paths', () => { const context = createMockContext('readwrite'); const result = checkBashPathAccess('/readwrite/file.txt', 'write', context); expect(result).toBeNull(); }); it('returns null for context paths', () => { const context = createMockContext('context'); const result = checkBashPathAccess('/context/file.txt', 'read', context); expect(result).toBeNull(); }); it('returns null for export paths with write access', () => { const context = createMockContext('export'); const result = checkBashPathAccess('~/Desktop/file.txt', 'write', context); expect(result).toBeNull(); }); it('returns export_path_read violation for export paths with read access', () => { const context = createMockContext('export'); const result = checkBashPathAccess('~/Desktop/file.txt', 'read', context); expect(result).toEqual({ type: 'export_path_read', path: '~/Desktop/file.txt' }); }); it('returns outside_vault violation for unknown paths', () => { const context = createMockContext('none'); const result = checkBashPathAccess('/etc/passwd', 'read', context); expect(result).toEqual({ type: 'outside_vault', path: '/etc/passwd' }); }); it('returns null for invalid path tokens', () => { const context = createMockContext('none'); const result = checkBashPathAccess('.', 'read', context); expect(result).toBeNull(); }); }); describe('findBashPathViolationInSegment', () => { it('returns null for empty segment', () => { const context = createMockPathContext({}); const result = findBashPathViolationInSegment([], context); expect(result).toBeNull(); }); it('detects violation in redirect target', () => { const context = createMockPathContext({}); const result = findBashPathViolationInSegment(['echo', 'test', '>', '/etc/passwd'], context); expect(result).toEqual({ type: 'outside_vault', path: '/etc/passwd' }); }); it('allows write to export path via redirect', () => { const context = createMockPathContext({ '~/Desktop/out.txt': 'export' }); const result = findBashPathViolationInSegment(['echo', 'test', '>', '~/Desktop/out.txt'], context); expect(result).toBeNull(); }); it('detects violation in -o output option', () => { const context = createMockPathContext({}); const result = findBashPathViolationInSegment(['curl', '-o', '/etc/passwd'], context); expect(result).toEqual({ type: 'outside_vault', path: '/etc/passwd' }); }); it('detects violation in embedded output option', () => { const context = createMockPathContext({}); const result = findBashPathViolationInSegment(['curl', '-o/etc/passwd'], context); expect(result).toEqual({ type: 'outside_vault', path: '/etc/passwd' }); }); it('detects violation in --output= option', () => { const context = createMockPathContext({}); const result = findBashPathViolationInSegment(['curl', '--output=/etc/passwd'], context); expect(result).toEqual({ type: 'outside_vault', path: '/etc/passwd' }); }); it('detects violation in embedded redirect', () => { const context = createMockPathContext({}); const result = findBashPathViolationInSegment(['echo', 'test', '>/etc/passwd'], context); expect(result).toEqual({ type: 'outside_vault', path: '/etc/passwd' }); }); it('detects violation in destination argument for cp command', () => { const context = createMockPathContext({ '/vault/file.txt': 'vault' }); const result = findBashPathViolationInSegment(['cp', '/vault/file.txt', '/etc/passwd'], context); expect(result).toEqual({ type: 'outside_vault', path: '/etc/passwd' }); }); it('detects violation in destination argument for mv command', () => { const context = createMockPathContext({ '/vault/file.txt': 'vault' }); const result = findBashPathViolationInSegment(['mv', '/vault/file.txt', '/etc/passwd'], context); expect(result).toEqual({ type: 'outside_vault', path: '/etc/passwd' }); }); it('returns null for commands with valid vault paths', () => { const context = createMockPathContext({ '/vault/src.txt': 'vault', '/vault/dest.txt': 'vault', }); const result = findBashPathViolationInSegment(['cp', '/vault/src.txt', '/vault/dest.txt'], context); expect(result).toBeNull(); }); it('allows read from vault path', () => { const context = createMockPathContext({ '/vault/file.txt': 'vault' }); const result = findBashPathViolationInSegment(['cat', '/vault/file.txt'], context); expect(result).toBeNull(); }); it('detects violation in embedded input redirect', () => { const context = createMockPathContext({}); const result = findBashPathViolationInSegment(['cat', '</etc/passwd'], context); expect(result).toEqual({ type: 'outside_vault', path: '/etc/passwd' }); }); it('allows embedded input redirect to vault path', () => { const context = createMockPathContext({ '/vault/data.txt': 'vault' }); const result = findBashPathViolationInSegment(['cat', '</vault/data.txt'], context); expect(result).toBeNull(); }); it('detects violation in --out= long option', () => { const context = createMockPathContext({}); const result = findBashPathViolationInSegment(['tool', '--out=/etc/output'], context); expect(result).toEqual({ type: 'outside_vault', path: '/etc/output' }); }); it('detects violation in --outfile= long option', () => { const context = createMockPathContext({}); const result = findBashPathViolationInSegment(['tool', '--outfile=/etc/output'], context); expect(result).toEqual({ type: 'outside_vault', path: '/etc/output' }); }); it('detects violation in --output-file= long option', () => { const context = createMockPathContext({}); const result = findBashPathViolationInSegment(['tool', '--output-file=/etc/output'], context); expect(result).toEqual({ type: 'outside_vault', path: '/etc/output' }); }); it('detects violation in KEY=VALUE with path-like value from flag', () => { const context = createMockPathContext({}); const result = findBashPathViolationInSegment(['tool', '--config=/etc/passwd'], context); expect(result).toEqual({ type: 'outside_vault', path: '/etc/passwd' }); }); it('detects path-like KEY=VALUE tokens as positional args', () => { const context = createMockPathContext({}); // HOME=/home/user contains '/' so it's path-like and treated as a positional arg read const result = findBashPathViolationInSegment(['env', 'HOME=/home/user', 'ls'], context); expect(result).toEqual({ type: 'outside_vault', path: 'HOME=/home/user' }); }); it('detects violation in 2>> append redirect', () => { const context = createMockPathContext({}); const result = findBashPathViolationInSegment(['cmd', '2>>/etc/error.log'], context); expect(result).toEqual({ type: 'outside_vault', path: '/etc/error.log' }); }); it('detects violation in &> combined redirect', () => { const context = createMockPathContext({}); const result = findBashPathViolationInSegment(['cmd', '&>/etc/all.log'], context); expect(result).toEqual({ type: 'outside_vault', path: '/etc/all.log' }); }); it('detects violation in >| clobber redirect', () => { const context = createMockPathContext({}); const result = findBashPathViolationInSegment(['cmd', '>|/etc/out.log'], context); expect(result).toEqual({ type: 'outside_vault', path: '/etc/out.log' }); }); it('skips cp --flag options before destination', () => { const context = createMockPathContext({ '/vault/src.txt': 'vault' }); const result = findBashPathViolationInSegment(['cp', '-r', '/vault/src.txt', '/etc/dest'], context); expect(result).toEqual({ type: 'outside_vault', path: '/etc/dest' }); }); it('handles rsync as a destination command', () => { const context = createMockPathContext({ '/vault/src/': 'vault' }); const result = findBashPathViolationInSegment(['rsync', '-av', '/vault/src/', '/etc/dest/'], context); expect(result).toEqual({ type: 'outside_vault', path: '/etc/dest/' }); }); it('handles -- separator for cp command', () => { const context = createMockPathContext({ '/vault/src.txt': 'vault' }); const result = findBashPathViolationInSegment(['cp', '--', '/vault/src.txt', '/etc/dest'], context); expect(result).toEqual({ type: 'outside_vault', path: '/etc/dest' }); }); it('resets expectWriteNext for non-path tokens', () => { const context = createMockPathContext({ '/vault/file.txt': 'vault' }); const result = findBashPathViolationInSegment(['echo', '>', 'non-path-word', '/vault/file.txt'], context); expect(result).toBeNull(); }); it('clears expectWriteNext on standalone input redirect operator', () => { const context = createMockPathContext({ '/vault/data.txt': 'vault', '/vault/out.txt': 'vault', }); // '>' sets expectWriteNext=true, then '<' clears it to false, // so /vault/data.txt is treated as read, not write const result = findBashPathViolationInSegment( ['cmd', '>', '/vault/out.txt', '<', '/vault/data.txt'], context ); expect(result).toBeNull(); }); it('allows embedded output redirect to vault path', () => { const context = createMockPathContext({ '/vault/output.txt': 'vault' }); const result = findBashPathViolationInSegment(['echo', 'test', '>/vault/output.txt'], context); expect(result).toBeNull(); }); it('allows --output= with vault path', () => { const context = createMockPathContext({ '/vault/file.txt': 'vault' }); const result = findBashPathViolationInSegment(['tool', '--output=/vault/file.txt'], context); expect(result).toBeNull(); }); it('allows --out= with vault path', () => { const context = createMockPathContext({ '/vault/file.txt': 'vault' }); const result = findBashPathViolationInSegment(['tool', '--out=/vault/file.txt'], context); expect(result).toBeNull(); }); it('allows --outfile= with vault path', () => { const context = createMockPathContext({ '/vault/file.txt': 'vault' }); const result = findBashPathViolationInSegment(['tool', '--outfile=/vault/file.txt'], context); expect(result).toBeNull(); }); it('allows --output-file= with vault path', () => { const context = createMockPathContext({ '/vault/file.txt': 'vault' }); const result = findBashPathViolationInSegment(['tool', '--output-file=/vault/file.txt'], context); expect(result).toBeNull(); }); it('allows -o with vault path (embedded)', () => { const context = createMockPathContext({ '/vault/output.log': 'vault' }); const result = findBashPathViolationInSegment(['curl', '-o/vault/output.log'], context); expect(result).toBeNull(); }); }); describe('findBashCommandPathViolation', () => { it('returns null for empty command', () => { const context = createMockPathContext({}); const result = findBashCommandPathViolation('', context); expect(result).toBeNull(); }); it('returns null for commands without paths', () => { const context = createMockPathContext({}); const result = findBashCommandPathViolation('echo hello', context); expect(result).toBeNull(); }); it('detects violation in chained commands', () => { const context = createMockPathContext({ '/vault/file.txt': 'vault' }); const result = findBashCommandPathViolation('cat /vault/file.txt && rm /etc/passwd', context); expect(result).toEqual({ type: 'outside_vault', path: '/etc/passwd' }); }); it('detects violation in piped commands', () => { const context = createMockPathContext({}); const result = findBashCommandPathViolation('echo hello | cat > /etc/passwd', context); expect(result).toEqual({ type: 'outside_vault', path: '/etc/passwd' }); }); it('returns first violation found', () => { const context = createMockPathContext({}); const result = findBashCommandPathViolation('cat /etc/passwd && cat /etc/shadow', context); expect(result).toEqual({ type: 'outside_vault', path: '/etc/passwd' }); }); it('returns null for valid vault-only command', () => { const context = createMockPathContext({ '/vault/file1.txt': 'vault', '/vault/file2.txt': 'vault', }); const result = findBashCommandPathViolation('cat /vault/file1.txt && cat /vault/file2.txt', context); expect(result).toBeNull(); }); it('detects export_path_read violation', () => { const context = createMockPathContext({ '~/Desktop/file.txt': 'export' }); const result = findBashCommandPathViolation('cat ~/Desktop/file.txt', context); expect(result).toEqual({ type: 'export_path_read', path: '~/Desktop/file.txt' }); }); it('allows write to export path', () => { const context = createMockPathContext({ '~/Desktop/file.txt': 'export' }); const result = findBashCommandPathViolation('echo hello > ~/Desktop/file.txt', context); expect(result).toBeNull(); }); it('should detect path violation with unmatched single quote', () => { const context = createMockPathContext({}); const result = findBashCommandPathViolation("cat '/etc/passwd", context); expect(result).toEqual({ type: 'outside_vault', path: '/etc/passwd' }); }); it('should detect path violation with unmatched double quote', () => { const context = createMockPathContext({}); const result = findBashCommandPathViolation('cat "/etc/passwd', context); expect(result).toEqual({ type: 'outside_vault', path: '/etc/passwd' }); }); it('should detect path violation inside backtick subshell', () => { const context = createMockPathContext({}); const result = findBashCommandPathViolation('echo `cat /etc/passwd`', context); expect(result).toEqual({ type: 'outside_vault', path: '/etc/passwd' }); }); it('should detect path violation inside $() subshell', () => { const context = createMockPathContext({}); const result = findBashCommandPathViolation('echo $(cat /etc/passwd)', context); expect(result).toEqual({ type: 'outside_vault', path: '/etc/passwd' }); }); it('should allow backtick subshell with vault paths', () => { const context = createMockPathContext({ '/vault/file.txt': 'vault' }); const result = findBashCommandPathViolation('echo `cat /vault/file.txt`', context); expect(result).toBeNull(); }); it('should allow $() subshell with vault paths', () => { const context = createMockPathContext({ '/vault/file.txt': 'vault' }); const result = findBashCommandPathViolation('echo $(cat /vault/file.txt)', context); expect(result).toBeNull(); }); it('should detect path violation in nested $() subshell', () => { const context = createMockPathContext({}); const result = findBashCommandPathViolation('echo $(echo $(cat /etc/passwd))', context); expect(result).toEqual({ type: 'outside_vault', path: '/etc/passwd' }); }); }); }); ================================================ FILE: tests/unit/core/security/BlocklistChecker.test.ts ================================================ import { isCommandBlocked } from '@/core/security/BlocklistChecker'; describe('BlocklistChecker', () => { describe('isCommandBlocked', () => { it('returns false when blocklist is disabled', () => { const command = 'rm -rf /'; const patterns = ['rm', 'rm.*-rf']; expect(isCommandBlocked(command, patterns, false)).toBe(false); }); it('returns false when patterns array is empty', () => { const command = 'ls -la'; expect(isCommandBlocked(command, [], true)).toBe(false); }); describe('with valid regex patterns', () => { it('blocks command matching exact pattern', () => { const command = 'rm file.txt'; const patterns = ['^rm ']; expect(isCommandBlocked(command, patterns, true)).toBe(true); }); it('blocks command matching regex pattern with wildcards', () => { const command = 'rm -rf important'; const patterns = ['rm.*-rf']; expect(isCommandBlocked(command, patterns, true)).toBe(true); }); it('is case-insensitive for regex matches', () => { const command = 'RM FILE.TXT'; const patterns = ['rm file']; expect(isCommandBlocked(command, patterns, true)).toBe(true); }); it('blocks command matching any pattern in array', () => { const command = 'git push --force'; const patterns = ['rm.*-rf', 'git.*force', 'drop database']; expect(isCommandBlocked(command, patterns, true)).toBe(true); }); it('does not block command not matching any pattern', () => { const command = 'git status'; const patterns = ['rm.*-rf', 'drop database']; expect(isCommandBlocked(command, patterns, true)).toBe(false); }); it('handles complex regex patterns', () => { const command = 'sudo apt-get install package'; const patterns = ['^(sudo |su )?(apt-get|yum|dnf) install']; expect(isCommandBlocked(command, patterns, true)).toBe(true); }); it('matches patterns anywhere in command', () => { const command = 'echo "hello" && rm file.txt'; const patterns = ['rm ']; expect(isCommandBlocked(command, patterns, true)).toBe(true); }); it('matches pattern at end of command', () => { const command = 'cat file.txt | grep pattern'; const patterns = ['pattern$']; expect(isCommandBlocked(command, patterns, true)).toBe(true); }); }); describe('with invalid regex patterns (substring fallback)', () => { it('falls back to substring match for invalid regex', () => { const command = 'rm file [invalid].txt'; const patterns = ['[invalid']; expect(isCommandBlocked(command, patterns, true)).toBe(true); }); it('is case-insensitive for substring matches', () => { const command = 'DELETE FROM table'; const patterns = ['delete from']; expect(isCommandBlocked(command, patterns, true)).toBe(true); }); it('does not block when substring not found', () => { const command = 'select * from users'; const patterns = ['delete', 'drop', 'truncate']; expect(isCommandBlocked(command, patterns, true)).toBe(false); }); it('handles mixed valid and invalid patterns', () => { const command = 'rm file'; const patterns = ['^rm ', '[invalid', 'delete']; expect(isCommandBlocked(command, patterns, true)).toBe(true); }); }); describe('edge cases', () => { it('handles empty command string', () => { const patterns = ['rm']; expect(isCommandBlocked('', patterns, true)).toBe(false); }); it('handles patterns with special regex characters', () => { const command = 'chmod 777 /etc/passwd'; const patterns = ['chmod.*777']; expect(isCommandBlocked(command, patterns, true)).toBe(true); }); it('handles unicode characters in patterns', () => { const command = 'echo "🚀 rocket"'; const patterns = ['rocket']; expect(isCommandBlocked(command, patterns, true)).toBe(true); }); it('handles very long commands', () => { const longCommand = 'echo ' + 'a'.repeat(10000); const patterns = ['echo ']; expect(isCommandBlocked(longCommand, patterns, true)).toBe(true); }); it('falls back to substring match for patterns exceeding max length', () => { const longPattern = 'rm ' + 'a'.repeat(500); const command = 'rm ' + 'a'.repeat(500); expect(isCommandBlocked(command, [longPattern], true)).toBe(true); }); it('does not match long pattern via regex', () => { const longPattern = 'rm.*' + 'a'.repeat(500); const command = 'rm something'; // Long pattern uses substring match, so regex wildcards are treated literally expect(isCommandBlocked(command, [longPattern], true)).toBe(false); }); }); }); describe('real-world scenarios', () => { it('allows safe common commands', () => { const safeCommands = [ { cmd: 'ls -la', patterns: ['rm -rf', 'drop database'] }, { cmd: 'git status', patterns: ['rm', 'format'] }, { cmd: 'cat file.txt', patterns: ['delete', 'drop'] }, { cmd: 'echo "hello"', patterns: ['rm', 'chmod'] } ]; safeCommands.forEach(({ cmd, patterns }) => { expect(isCommandBlocked(cmd, patterns, true)).toBe(false); }); }); it('blocks commands with flags matching pattern', () => { const patterns = ['git.*--force', 'npm.*--force']; expect(isCommandBlocked('git push --force origin main', patterns, true)).toBe(true); expect(isCommandBlocked('npm install --force', patterns, true)).toBe(true); expect(isCommandBlocked('git push origin main', patterns, true)).toBe(false); }); it('handles platform-specific commands', () => { const patterns = ['del.*\\\\.*', 'rm -rf']; expect(isCommandBlocked('del C:\\Windows\\System32\\file', patterns, true)).toBe(true); expect(isCommandBlocked('rm -rf /home/user/file', patterns, true)).toBe(true); }); }); }); ================================================ FILE: tests/unit/core/storage/AgentVaultStorage.test.ts ================================================ import { AgentVaultStorage } from '@/core/storage/AgentVaultStorage'; import type { VaultFileAdapter } from '@/core/storage/VaultFileAdapter'; describe('AgentVaultStorage', () => { let mockAdapter: jest.Mocked<VaultFileAdapter>; let storage: AgentVaultStorage; const validAgentMd = `--- name: code-reviewer description: Reviews code for issues model: sonnet --- You are a code reviewer.`; const validAgent2Md = `--- name: test-runner description: Runs tests tools: [Bash] --- Run the tests.`; beforeEach(() => { mockAdapter = { exists: jest.fn().mockResolvedValue(true), read: jest.fn(), write: jest.fn(), delete: jest.fn(), ensureFolder: jest.fn(), rename: jest.fn(), stat: jest.fn(), append: jest.fn(), listFiles: jest.fn(), listFolders: jest.fn(), listFilesRecursive: jest.fn(), } as unknown as jest.Mocked<VaultFileAdapter>; storage = new AgentVaultStorage(mockAdapter); }); describe('loadAll', () => { it('loads all agent files', async () => { mockAdapter.listFiles.mockResolvedValue([ '.claude/agents/code-reviewer.md', '.claude/agents/test-runner.md', ]); mockAdapter.read .mockResolvedValueOnce(validAgentMd) .mockResolvedValueOnce(validAgent2Md); const agents = await storage.loadAll(); expect(agents).toHaveLength(2); expect(agents[0].name).toBe('code-reviewer'); expect(agents[0].description).toBe('Reviews code for issues'); expect(agents[0].model).toBe('sonnet'); expect(agents[0].source).toBe('vault'); expect(agents[0].prompt).toBe('You are a code reviewer.'); expect(agents[1].name).toBe('test-runner'); expect(agents[1].tools).toEqual(['Bash']); }); it('skips non-markdown files', async () => { mockAdapter.listFiles.mockResolvedValue([ '.claude/agents/agent.md', '.claude/agents/readme.txt', '.claude/agents/config.json', ]); mockAdapter.read.mockResolvedValue(validAgentMd); const agents = await storage.loadAll(); expect(agents).toHaveLength(1); }); it('returns empty array when directory does not exist', async () => { mockAdapter.listFiles.mockRejectedValue(new Error('not found')); const agents = await storage.loadAll(); expect(agents).toHaveLength(0); }); it('skips malformed files', async () => { mockAdapter.listFiles.mockResolvedValue([ '.claude/agents/good.md', '.claude/agents/bad.md', ]); mockAdapter.read .mockResolvedValueOnce(validAgentMd) .mockResolvedValueOnce('not valid frontmatter'); const agents = await storage.loadAll(); expect(agents).toHaveLength(1); expect(agents[0].name).toBe('code-reviewer'); }); it('continues loading if one file throws', async () => { mockAdapter.listFiles.mockResolvedValue([ '.claude/agents/good.md', '.claude/agents/error.md', '.claude/agents/also-good.md', ]); mockAdapter.read .mockResolvedValueOnce(validAgentMd) .mockRejectedValueOnce(new Error('read error')) .mockResolvedValueOnce(validAgent2Md); const agents = await storage.loadAll(); expect(agents).toHaveLength(2); }); it('handles empty directory', async () => { mockAdapter.listFiles.mockResolvedValue([]); const agents = await storage.loadAll(); expect(agents).toHaveLength(0); }); it('preserves filePath from disk', async () => { mockAdapter.listFiles.mockResolvedValue([ '.claude/agents/custom-filename.md', ]); mockAdapter.read.mockResolvedValue(validAgentMd); const agents = await storage.loadAll(); expect(agents[0].filePath).toBe('.claude/agents/custom-filename.md'); }); it('parses permissionMode from frontmatter', async () => { const agentWithPermission = `--- name: strict-agent description: Strict agent permissionMode: dontAsk --- Be strict.`; mockAdapter.listFiles.mockResolvedValue(['.claude/agents/strict-agent.md']); mockAdapter.read.mockResolvedValue(agentWithPermission); const agents = await storage.loadAll(); expect(agents).toHaveLength(1); expect(agents[0].permissionMode).toBe('dontAsk'); }); }); describe('save', () => { it('writes to correct file path', async () => { await storage.save({ id: 'code-reviewer', name: 'code-reviewer', description: 'Reviews code', prompt: 'Review code.', source: 'vault', }); expect(mockAdapter.write).toHaveBeenCalledWith( '.claude/agents/code-reviewer.md', expect.stringContaining('name: code-reviewer') ); }); it('serializes agent content correctly', async () => { await storage.save({ id: 'my-agent', name: 'my-agent', description: 'My agent', prompt: 'Do stuff.', model: 'opus', tools: ['Read', 'Grep'], source: 'vault', }); const written = mockAdapter.write.mock.calls[0][1] as string; expect(written).toContain('name: my-agent'); expect(written).toContain('description: My agent'); expect(written).toContain('model: opus'); expect(written).toContain('tools:\n - Read\n - Grep'); expect(written).toContain('Do stuff.'); }); }); describe('delete', () => { it('deletes using filePath when available', async () => { await storage.delete({ id: 'code-reviewer', name: 'code-reviewer', description: 'Reviews code', prompt: 'Review.', source: 'vault', filePath: '.claude/agents/custom-filename.md', }); expect(mockAdapter.delete).toHaveBeenCalledWith('.claude/agents/custom-filename.md'); }); it('falls back to name-based path when no filePath', async () => { await storage.delete({ id: 'code-reviewer', name: 'code-reviewer', description: 'Reviews code', prompt: 'Review.', source: 'vault', }); expect(mockAdapter.delete).toHaveBeenCalledWith('.claude/agents/code-reviewer.md'); }); it('converts absolute filePath to vault-relative', async () => { await storage.delete({ id: 'code-reviewer', name: 'code-reviewer', description: 'Reviews code', prompt: 'Review.', source: 'vault', filePath: '/Users/user/vault/.claude/agents/code-reviewer.md', }); expect(mockAdapter.delete).toHaveBeenCalledWith('.claude/agents/code-reviewer.md'); }); it('converts Windows absolute filePath to vault-relative', async () => { await storage.delete({ id: 'code-reviewer', name: 'code-reviewer', description: 'Reviews code', prompt: 'Review.', source: 'vault', filePath: 'C:\\Users\\user\\vault\\.claude\\agents\\custom-filename.md', }); expect(mockAdapter.delete).toHaveBeenCalledWith('.claude/agents/custom-filename.md'); }); }); describe('load', () => { it('reads and parses a single agent file', async () => { mockAdapter.read.mockResolvedValue(validAgentMd); const result = await storage.load({ id: 'code-reviewer', name: 'code-reviewer', description: 'Reviews code', prompt: '', source: 'vault', filePath: '.claude/agents/code-reviewer.md', }); expect(mockAdapter.read).toHaveBeenCalledWith('.claude/agents/code-reviewer.md'); expect(result).not.toBeNull(); expect(result!.name).toBe('code-reviewer'); expect(result!.prompt).toBe('You are a code reviewer.'); expect(result!.model).toBe('sonnet'); }); it('returns null when file is not found', async () => { mockAdapter.read.mockRejectedValue(new Error('not found')); const result = await storage.load({ id: 'missing', name: 'missing', description: '', prompt: '', source: 'vault', }); expect(result).toBeNull(); }); it('throws when read fails with unexpected error', async () => { mockAdapter.read.mockRejectedValue(new Error('permission denied')); await expect(storage.load({ id: 'missing', name: 'missing', description: '', prompt: '', source: 'vault', })).rejects.toThrow('permission denied'); }); it('returns null when file content is malformed', async () => { mockAdapter.read.mockResolvedValue('not valid frontmatter'); const result = await storage.load({ id: 'bad', name: 'bad', description: '', prompt: '', source: 'vault', filePath: '.claude/agents/bad.md', }); expect(result).toBeNull(); }); it('resolves absolute filePath before reading', async () => { mockAdapter.read.mockResolvedValue(validAgentMd); await storage.load({ id: 'code-reviewer', name: 'code-reviewer', description: '', prompt: '', source: 'vault', filePath: '/Users/user/vault/.claude/agents/code-reviewer.md', }); expect(mockAdapter.read).toHaveBeenCalledWith('.claude/agents/code-reviewer.md'); }); it('resolves Windows absolute filePath before reading', async () => { mockAdapter.read.mockResolvedValue(validAgentMd); await storage.load({ id: 'code-reviewer', name: 'code-reviewer', description: '', prompt: '', source: 'vault', filePath: 'C:\\Users\\user\\vault\\.claude\\agents\\custom-filename.md', }); expect(mockAdapter.read).toHaveBeenCalledWith('.claude/agents/custom-filename.md'); }); }); describe('path normalization', () => { it('saves using vault-relative path when filePath is absolute', async () => { await storage.save({ id: 'my-agent', name: 'my-agent', description: 'My agent', prompt: 'Do stuff.', source: 'vault', filePath: '/Users/user/vault/.claude/agents/my-agent.md', }); expect(mockAdapter.write).toHaveBeenCalledWith( '.claude/agents/my-agent.md', expect.any(String) ); }); it('preserves vault-relative filePath as-is', async () => { await storage.save({ id: 'my-agent', name: 'my-agent', description: 'My agent', prompt: 'Do stuff.', source: 'vault', filePath: '.claude/agents/custom-name.md', }); expect(mockAdapter.write).toHaveBeenCalledWith( '.claude/agents/custom-name.md', expect.any(String) ); }); it('normalizes backslashes in vault-relative filePath', async () => { await storage.save({ id: 'my-agent', name: 'my-agent', description: 'My agent', prompt: 'Do stuff.', source: 'vault', filePath: '.claude\\agents\\custom-name.md', }); expect(mockAdapter.write).toHaveBeenCalledWith( '.claude/agents/custom-name.md', expect.any(String) ); }); it('saves using custom filename from Windows absolute path', async () => { await storage.save({ id: 'my-agent', name: 'my-agent', description: 'My agent', prompt: 'Do stuff.', source: 'vault', filePath: 'C:\\Users\\user\\vault\\.claude\\agents\\custom-name.md', }); expect(mockAdapter.write).toHaveBeenCalledWith( '.claude/agents/custom-name.md', expect.any(String) ); }); it('falls back to name-based path when absolute path has no agents marker', async () => { await storage.save({ id: 'my-agent', name: 'my-agent', description: 'My agent', prompt: 'Do stuff.', source: 'vault', filePath: '/some/other/path/agent.md', }); expect(mockAdapter.write).toHaveBeenCalledWith( '.claude/agents/my-agent.md', expect.any(String) ); }); }); }); ================================================ FILE: tests/unit/core/storage/CCSettingsStorage.test.ts ================================================ import { CC_SETTINGS_PATH,CCSettingsStorage } from '../../../../src/core/storage/CCSettingsStorage'; import type { VaultFileAdapter } from '../../../../src/core/storage/VaultFileAdapter'; import { createPermissionRule } from '../../../../src/core/types'; const mockAdapter = { exists: jest.fn(), read: jest.fn(), write: jest.fn(), } as unknown as jest.Mocked<VaultFileAdapter>; describe('CCSettingsStorage', () => { let storage: CCSettingsStorage; beforeEach(() => { jest.clearAllMocks(); storage = new CCSettingsStorage(mockAdapter); }); describe('load', () => { it('should return defaults if file does not exist', async () => { mockAdapter.exists.mockResolvedValue(false); const result = await storage.load(); expect(result.permissions).toBeDefined(); }); it('should load and parse allowed permissions', async () => { mockAdapter.exists.mockResolvedValue(true); mockAdapter.read.mockResolvedValue(JSON.stringify({ permissions: { allow: ['tool1'], deny: [], ask: [] } })); const result = await storage.load(); expect(result.permissions?.allow).toContain('tool1'); }); it('should throw on read error', async () => { mockAdapter.exists.mockResolvedValue(true); mockAdapter.read.mockRejectedValue(new Error('Read failed')); await expect(storage.load()).rejects.toThrow('Read failed'); }); }); describe('addAllowRule', () => { it('should add rule to allow list and save', async () => { // Setup initial state mockAdapter.exists.mockResolvedValue(true); mockAdapter.read.mockResolvedValue(JSON.stringify({ permissions: { allow: [], deny: [], ask: [] } })); await storage.addAllowRule(createPermissionRule('new-rule')); const writeCall = mockAdapter.write.mock.calls[0]; const writtenContent = JSON.parse(writeCall[1]); expect(writtenContent.permissions.allow).toContain('new-rule'); }); it('should not duplicate existing rule', async () => { mockAdapter.exists.mockResolvedValue(true); mockAdapter.read.mockResolvedValue(JSON.stringify({ permissions: { allow: ['existing'], deny: [], ask: [] } })); await storage.addAllowRule(createPermissionRule('existing')); expect(mockAdapter.write).not.toHaveBeenCalled(); }); }); describe('removeRule', () => { it('should remove rule from all lists', async () => { mockAdapter.exists.mockResolvedValue(true); mockAdapter.read.mockResolvedValue(JSON.stringify({ permissions: { allow: ['rule1'], deny: ['rule1'], ask: ['rule1'] } })); await storage.removeRule(createPermissionRule('rule1')); expect(mockAdapter.write).toHaveBeenCalledWith( CC_SETTINGS_PATH, expect.stringContaining('"allow": []') ); }); }); describe('addDenyRule', () => { it('should add rule to deny list and save', async () => { mockAdapter.exists.mockResolvedValue(true); mockAdapter.read.mockResolvedValue(JSON.stringify({ permissions: { allow: [], deny: [], ask: [] } })); await storage.addDenyRule(createPermissionRule('dangerous-rule')); const writeCall = mockAdapter.write.mock.calls[0]; const writtenContent = JSON.parse(writeCall[1]); expect(writtenContent.permissions.deny).toContain('dangerous-rule'); }); it('should not duplicate existing deny rule', async () => { mockAdapter.exists.mockResolvedValue(true); mockAdapter.read.mockResolvedValue(JSON.stringify({ permissions: { allow: [], deny: ['existing'], ask: [] } })); await storage.addDenyRule(createPermissionRule('existing')); expect(mockAdapter.write).not.toHaveBeenCalled(); }); }); describe('addAskRule', () => { it('should add rule to ask list and save', async () => { mockAdapter.exists.mockResolvedValue(true); mockAdapter.read.mockResolvedValue(JSON.stringify({ permissions: { allow: [], deny: [], ask: [] } })); await storage.addAskRule(createPermissionRule('ask-rule')); const writeCall = mockAdapter.write.mock.calls[0]; const writtenContent = JSON.parse(writeCall[1]); expect(writtenContent.permissions.ask).toContain('ask-rule'); }); it('should not duplicate existing ask rule', async () => { mockAdapter.exists.mockResolvedValue(true); mockAdapter.read.mockResolvedValue(JSON.stringify({ permissions: { allow: [], deny: [], ask: ['existing'] } })); await storage.addAskRule(createPermissionRule('existing')); expect(mockAdapter.write).not.toHaveBeenCalled(); }); }); describe('save', () => { it('should handle parse error on existing file', async () => { mockAdapter.exists.mockResolvedValue(true); mockAdapter.read.mockResolvedValue('invalid json{{{'); await storage.save({ permissions: { allow: [], deny: [], ask: [] } }); // Should still write successfully after parse error expect(mockAdapter.write).toHaveBeenCalled(); const writeCall = mockAdapter.write.mock.calls[0]; const writtenContent = JSON.parse(writeCall[1]); expect(writtenContent.permissions).toEqual({ allow: [], deny: [], ask: [] }); }); it('should strip claudian-only fields during migration', async () => { mockAdapter.exists.mockResolvedValue(true); mockAdapter.read.mockResolvedValue(JSON.stringify({ permissions: { allow: [], deny: [], ask: [] }, userName: 'Test', model: 'haiku', })); await storage.save({ permissions: { allow: [], deny: [], ask: [] } }, true); const writeCall = mockAdapter.write.mock.calls[0]; const writtenContent = JSON.parse(writeCall[1]); expect(writtenContent.userName).toBeUndefined(); expect(writtenContent.model).toBeUndefined(); }); it('should preserve enabledPlugins from settings argument', async () => { mockAdapter.exists.mockResolvedValue(false); await storage.save({ permissions: { allow: [], deny: [], ask: [] }, enabledPlugins: { 'my-plugin': true }, }); const writeCall = mockAdapter.write.mock.calls[0]; const writtenContent = JSON.parse(writeCall[1]); expect(writtenContent.enabledPlugins).toEqual({ 'my-plugin': true }); }); }); describe('load edge cases', () => { it('should handle legacy permissions format during load', async () => { mockAdapter.exists.mockResolvedValue(true); mockAdapter.read.mockResolvedValue(JSON.stringify({ permissions: [ { toolName: 'Bash', pattern: 'git *', approvedAt: 1000, scope: 'always' }, ], })); const result = await storage.load(); expect(result.permissions?.allow).toBeDefined(); expect(result.permissions?.allow?.length).toBeGreaterThan(0); }); it('should normalize invalid permissions to defaults', async () => { mockAdapter.exists.mockResolvedValue(true); mockAdapter.read.mockResolvedValue(JSON.stringify({ permissions: 'not-an-object', })); const result = await storage.load(); expect(result.permissions?.allow).toEqual([]); expect(result.permissions?.deny).toEqual([]); expect(result.permissions?.ask).toEqual([]); }); it('should filter non-string values from permission arrays', async () => { mockAdapter.exists.mockResolvedValue(true); mockAdapter.read.mockResolvedValue(JSON.stringify({ permissions: { allow: ['valid', 123, null, 'also-valid'], deny: [true, 'deny-rule'], ask: [], }, })); const result = await storage.load(); expect(result.permissions?.allow).toEqual(['valid', 'also-valid']); expect(result.permissions?.deny).toEqual(['deny-rule']); }); it('should preserve additionalDirectories and defaultMode', async () => { mockAdapter.exists.mockResolvedValue(true); mockAdapter.read.mockResolvedValue(JSON.stringify({ permissions: { allow: [], deny: [], ask: [], defaultMode: 'bypassPermissions', additionalDirectories: ['/extra/dir'], }, })); const result = await storage.load(); expect(result.permissions?.defaultMode).toBe('bypassPermissions'); expect(result.permissions?.additionalDirectories).toEqual(['/extra/dir']); }); }); describe('isLegacyPermissionsFormat edge cases', () => { it('should return false for null data', async () => { mockAdapter.exists.mockResolvedValue(true); mockAdapter.read.mockResolvedValue(JSON.stringify({ permissions: null, })); const result = await storage.load(); // null permissions normalized to defaults expect(result.permissions?.allow).toEqual([]); }); it('should return false for non-object permissions', async () => { mockAdapter.exists.mockResolvedValue(true); mockAdapter.read.mockResolvedValue(JSON.stringify({ permissions: 42, })); const result = await storage.load(); expect(result.permissions?.allow).toEqual([]); expect(result.permissions?.deny).toEqual([]); }); it('should return false for empty array permissions', async () => { mockAdapter.exists.mockResolvedValue(true); mockAdapter.read.mockResolvedValue(JSON.stringify({ permissions: [], })); const result = await storage.load(); // Empty array is legacy format but length === 0, so falls through expect(result.permissions?.allow).toEqual([]); }); }); describe('normalizePermissions edge cases', () => { it('should handle non-array allow/deny/ask values', async () => { mockAdapter.exists.mockResolvedValue(true); mockAdapter.read.mockResolvedValue(JSON.stringify({ permissions: { allow: 'not-an-array', deny: 123, ask: null, }, })); const result = await storage.load(); expect(result.permissions?.allow).toEqual([]); expect(result.permissions?.deny).toEqual([]); expect(result.permissions?.ask).toEqual([]); }); }); describe('save edge cases', () => { it('should use default permissions when settings.permissions is undefined', async () => { mockAdapter.exists.mockResolvedValue(false); await storage.save({} as any); const writeCall = mockAdapter.write.mock.calls[0]; const writtenContent = JSON.parse(writeCall[1]); expect(writtenContent.permissions).toEqual({ allow: [], deny: [], ask: [], }); }); }); describe('getPermissions edge cases', () => { it('should return default permissions when settings has no permissions field', async () => { mockAdapter.exists.mockResolvedValue(true); mockAdapter.read.mockResolvedValue(JSON.stringify({})); const result = await storage.getPermissions(); expect(result.allow).toEqual([]); expect(result.deny).toEqual([]); expect(result.ask).toEqual([]); }); }); describe('enabledPlugins', () => { it('should return empty object if enabledPlugins not set', async () => { mockAdapter.exists.mockResolvedValue(true); mockAdapter.read.mockResolvedValue(JSON.stringify({ permissions: { allow: [], deny: [], ask: [] } })); const result = await storage.getEnabledPlugins(); expect(result).toEqual({}); }); it('should return enabledPlugins from settings', async () => { mockAdapter.exists.mockResolvedValue(true); mockAdapter.read.mockResolvedValue(JSON.stringify({ permissions: { allow: [], deny: [], ask: [] }, enabledPlugins: { 'plugin-a': true, 'plugin-b': false } })); const result = await storage.getEnabledPlugins(); expect(result).toEqual({ 'plugin-a': true, 'plugin-b': false }); }); it('should set plugin enabled state and persist', async () => { mockAdapter.exists.mockResolvedValue(true); mockAdapter.read.mockResolvedValue(JSON.stringify({ permissions: { allow: [], deny: [], ask: [] }, enabledPlugins: { 'existing-plugin': true } })); await storage.setPluginEnabled('new-plugin@source', false); const writeCall = mockAdapter.write.mock.calls[0]; const writtenContent = JSON.parse(writeCall[1]); expect(writtenContent.enabledPlugins).toEqual({ 'existing-plugin': true, 'new-plugin@source': false }); }); it('should update existing plugin state', async () => { mockAdapter.exists.mockResolvedValue(true); mockAdapter.read.mockResolvedValue(JSON.stringify({ permissions: { allow: [], deny: [], ask: [] }, enabledPlugins: { 'plugin-a': true } })); await storage.setPluginEnabled('plugin-a', false); const writeCall = mockAdapter.write.mock.calls[0]; const writtenContent = JSON.parse(writeCall[1]); expect(writtenContent.enabledPlugins['plugin-a']).toBe(false); }); it('should preserve enabledPlugins when saving other settings', async () => { mockAdapter.exists.mockResolvedValue(true); mockAdapter.read.mockResolvedValue(JSON.stringify({ permissions: { allow: ['rule1'], deny: [], ask: [] }, enabledPlugins: { 'plugin-a': false } })); // Add a permission rule (different operation) await storage.addAllowRule(createPermissionRule('new-rule')); const writeCall = mockAdapter.write.mock.calls[0]; const writtenContent = JSON.parse(writeCall[1]); // enabledPlugins should be preserved from existing file expect(writtenContent.enabledPlugins).toEqual({ 'plugin-a': false }); }); it('should return explicitly enabled plugin IDs', async () => { mockAdapter.exists.mockResolvedValue(true); mockAdapter.read.mockResolvedValue(JSON.stringify({ permissions: { allow: [], deny: [], ask: [] }, enabledPlugins: { 'plugin-a': true, 'plugin-b': false, 'plugin-c': true } })); const ids = await storage.getExplicitlyEnabledPluginIds(); expect(ids).toEqual(['plugin-a', 'plugin-c']); }); it('should return empty array when no plugins explicitly enabled', async () => { mockAdapter.exists.mockResolvedValue(true); mockAdapter.read.mockResolvedValue(JSON.stringify({ permissions: { allow: [], deny: [], ask: [] }, enabledPlugins: { 'plugin-a': false } })); const ids = await storage.getExplicitlyEnabledPluginIds(); expect(ids).toEqual([]); }); it('should check if a plugin is explicitly disabled', async () => { mockAdapter.exists.mockResolvedValue(true); mockAdapter.read.mockResolvedValue(JSON.stringify({ permissions: { allow: [], deny: [], ask: [] }, enabledPlugins: { 'plugin-a': false, 'plugin-b': true } })); expect(await storage.isPluginDisabled('plugin-a')).toBe(true); expect(await storage.isPluginDisabled('plugin-b')).toBe(false); expect(await storage.isPluginDisabled('plugin-c')).toBe(false); }); }); }); ================================================ FILE: tests/unit/core/storage/ClaudianSettingsStorage.test.ts ================================================ import { CLAUDIAN_SETTINGS_PATH, ClaudianSettingsStorage, normalizeBlockedCommands, } from '@/core/storage/ClaudianSettingsStorage'; import type { VaultFileAdapter } from '@/core/storage/VaultFileAdapter'; import { DEFAULT_SETTINGS, getDefaultBlockedCommands } from '@/core/types'; const mockAdapter = { exists: jest.fn(), read: jest.fn(), write: jest.fn(), } as unknown as jest.Mocked<VaultFileAdapter>; describe('ClaudianSettingsStorage', () => { let storage: ClaudianSettingsStorage; beforeEach(() => { jest.clearAllMocks(); // Reset mock implementations to default resolved values mockAdapter.exists.mockResolvedValue(false); mockAdapter.read.mockResolvedValue('{}'); mockAdapter.write.mockResolvedValue(undefined); storage = new ClaudianSettingsStorage(mockAdapter); }); describe('load', () => { it('should return defaults when file does not exist', async () => { mockAdapter.exists.mockResolvedValue(false); const result = await storage.load(); expect(result.model).toBe(DEFAULT_SETTINGS.model); expect(result.thinkingBudget).toBe(DEFAULT_SETTINGS.thinkingBudget); expect(result.permissionMode).toBe(DEFAULT_SETTINGS.permissionMode); expect(mockAdapter.read).not.toHaveBeenCalled(); }); it('should parse valid JSON and merge with defaults', async () => { mockAdapter.exists.mockResolvedValue(true); mockAdapter.read.mockResolvedValue(JSON.stringify({ model: 'claude-opus-4-5', userName: 'TestUser', })); const result = await storage.load(); expect(result.model).toBe('claude-opus-4-5'); expect(result.userName).toBe('TestUser'); // Defaults should still be present for unspecified fields expect(result.thinkingBudget).toBe(DEFAULT_SETTINGS.thinkingBudget); }); it('should normalize blockedCommands from loaded data', async () => { mockAdapter.exists.mockResolvedValue(true); mockAdapter.read.mockResolvedValue(JSON.stringify({ blockedCommands: { unix: ['custom-unix-cmd'], windows: ['custom-win-cmd'], }, })); const result = await storage.load(); expect(result.blockedCommands.unix).toContain('custom-unix-cmd'); expect(result.blockedCommands.windows).toContain('custom-win-cmd'); }); it('should normalize claudeCliPathsByHost from loaded data', async () => { mockAdapter.exists.mockResolvedValue(true); mockAdapter.read.mockResolvedValue(JSON.stringify({ claudeCliPathsByHost: { 'host-a': '/custom/path-a', 'host-b': '/custom/path-b', }, })); const result = await storage.load(); expect(result.claudeCliPathsByHost['host-a']).toBe('/custom/path-a'); expect(result.claudeCliPathsByHost['host-b']).toBe('/custom/path-b'); }); it('should preserve legacy claudeCliPath field', async () => { mockAdapter.exists.mockResolvedValue(true); mockAdapter.read.mockResolvedValue(JSON.stringify({ claudeCliPath: '/legacy/path', })); const result = await storage.load(); expect(result.claudeCliPath).toBe('/legacy/path'); }); it('should remove legacy show1MModel from the stored file', async () => { mockAdapter.exists.mockResolvedValue(true); mockAdapter.read.mockResolvedValue(JSON.stringify({ model: 'sonnet', show1MModel: true, })); const result = await storage.load(); expect(result.enableSonnet1M).toBe(DEFAULT_SETTINGS.enableSonnet1M); expect(mockAdapter.write).toHaveBeenCalledWith( CLAUDIAN_SETTINGS_PATH, JSON.stringify({ model: 'sonnet' }, null, 2) ); }); it('should throw on JSON parse error', async () => { mockAdapter.exists.mockResolvedValue(true); mockAdapter.read.mockResolvedValue('invalid json'); await expect(storage.load()).rejects.toThrow(); }); it('should throw on read error', async () => { mockAdapter.exists.mockResolvedValue(true); mockAdapter.read.mockRejectedValue(new Error('Read failed')); await expect(storage.load()).rejects.toThrow('Read failed'); }); }); describe('save', () => { it('should write settings to file', async () => { const settings = { ...DEFAULT_SETTINGS, model: 'claude-opus-4-5' as const, }; // Remove slashCommands as it's stored separately const { slashCommands: _, ...storedSettings } = settings; await storage.save(storedSettings); expect(mockAdapter.write).toHaveBeenCalledWith( CLAUDIAN_SETTINGS_PATH, expect.any(String) ); const writtenContent = JSON.parse(mockAdapter.write.mock.calls[0][1]); expect(writtenContent.model).toBe('claude-opus-4-5'); }); it('should throw on write error', async () => { mockAdapter.write.mockRejectedValue(new Error('Write failed')); const settings = { ...DEFAULT_SETTINGS, }; const { slashCommands: _, ...storedSettings } = settings; await expect(storage.save(storedSettings)).rejects.toThrow('Write failed'); }); }); describe('exists', () => { it('should return true when file exists', async () => { mockAdapter.exists.mockResolvedValue(true); const result = await storage.exists(); expect(result).toBe(true); expect(mockAdapter.exists).toHaveBeenCalledWith(CLAUDIAN_SETTINGS_PATH); }); it('should return false when file does not exist', async () => { mockAdapter.exists.mockResolvedValue(false); const result = await storage.exists(); expect(result).toBe(false); }); }); describe('update', () => { it('should merge updates with existing settings', async () => { mockAdapter.exists.mockResolvedValue(true); mockAdapter.read.mockResolvedValue(JSON.stringify({ model: 'claude-haiku-4-5', userName: 'ExistingUser', })); await storage.update({ model: 'claude-opus-4-5' }); const writeCall = mockAdapter.write.mock.calls[0]; const writtenContent = JSON.parse(writeCall[1]); expect(writtenContent.model).toBe('claude-opus-4-5'); expect(writtenContent.userName).toBe('ExistingUser'); }); }); describe('legacy activeConversationId', () => { it('should read legacy activeConversationId when present', async () => { mockAdapter.exists.mockResolvedValue(true); mockAdapter.read.mockResolvedValue(JSON.stringify({ activeConversationId: 'conv-123', })); const legacyId = await storage.getLegacyActiveConversationId(); expect(legacyId).toBe('conv-123'); }); it('should return null when legacy activeConversationId is missing', async () => { mockAdapter.exists.mockResolvedValue(true); mockAdapter.read.mockResolvedValue(JSON.stringify({ model: 'claude-haiku-4-5', })); const legacyId = await storage.getLegacyActiveConversationId(); expect(legacyId).toBeNull(); }); it('should clear legacy activeConversationId from file', async () => { mockAdapter.exists.mockResolvedValue(true); mockAdapter.read.mockResolvedValue(JSON.stringify({ activeConversationId: 'conv-123', model: 'claude-haiku-4-5', })); await storage.clearLegacyActiveConversationId(); const writeCall = mockAdapter.write.mock.calls[0]; const writtenContent = JSON.parse(writeCall[1]); expect(writtenContent.activeConversationId).toBeUndefined(); expect(writtenContent.model).toBe('claude-haiku-4-5'); }); }); describe('getLegacyActiveConversationId - file missing', () => { it('should return null when file does not exist', async () => { mockAdapter.exists.mockResolvedValue(false); const result = await storage.getLegacyActiveConversationId(); expect(result).toBeNull(); expect(mockAdapter.read).not.toHaveBeenCalled(); }); }); describe('clearLegacyActiveConversationId - file missing', () => { it('should return early when file does not exist', async () => { mockAdapter.exists.mockResolvedValue(false); await storage.clearLegacyActiveConversationId(); expect(mockAdapter.read).not.toHaveBeenCalled(); expect(mockAdapter.write).not.toHaveBeenCalled(); }); }); describe('clearLegacyActiveConversationId - no key present', () => { it('should not write when activeConversationId key is absent', async () => { mockAdapter.exists.mockResolvedValue(true); mockAdapter.read.mockResolvedValue(JSON.stringify({ model: 'claude-haiku-4-5', })); await storage.clearLegacyActiveConversationId(); expect(mockAdapter.write).not.toHaveBeenCalled(); }); }); describe('setLastModel', () => { it('should update lastClaudeModel for non-custom models', async () => { mockAdapter.exists.mockResolvedValue(true); mockAdapter.read.mockResolvedValue(JSON.stringify({})); await storage.setLastModel('claude-sonnet-4-5', false); const writeCall = mockAdapter.write.mock.calls[0]; const writtenContent = JSON.parse(writeCall[1]); expect(writtenContent.lastClaudeModel).toBe('claude-sonnet-4-5'); // lastCustomModel keeps its default value (empty string) }); it('should update lastCustomModel for custom models', async () => { mockAdapter.exists.mockResolvedValue(true); mockAdapter.read.mockResolvedValue(JSON.stringify({})); await storage.setLastModel('custom-model-id', true); const writeCall = mockAdapter.write.mock.calls[0]; const writtenContent = JSON.parse(writeCall[1]); expect(writtenContent.lastCustomModel).toBe('custom-model-id'); // lastClaudeModel keeps its default value }); }); describe('setLastEnvHash', () => { it('should update environment hash', async () => { mockAdapter.exists.mockResolvedValue(true); mockAdapter.read.mockResolvedValue(JSON.stringify({})); await storage.setLastEnvHash('abc123'); const writeCall = mockAdapter.write.mock.calls[0]; const writtenContent = JSON.parse(writeCall[1]); expect(writtenContent.lastEnvHash).toBe('abc123'); }); }); }); describe('normalizeBlockedCommands', () => { const defaults = getDefaultBlockedCommands(); it('should return defaults for null input', () => { const result = normalizeBlockedCommands(null); expect(result.unix).toEqual(defaults.unix); expect(result.windows).toEqual(defaults.windows); }); it('should return defaults for undefined input', () => { const result = normalizeBlockedCommands(undefined); expect(result.unix).toEqual(defaults.unix); expect(result.windows).toEqual(defaults.windows); }); it('should migrate old string[] format to platform-keyed structure', () => { const oldFormat = ['custom-cmd-1', 'custom-cmd-2']; const result = normalizeBlockedCommands(oldFormat); expect(result.unix).toEqual(['custom-cmd-1', 'custom-cmd-2']); expect(result.windows).toEqual(defaults.windows); }); it('should normalize valid platform-keyed object', () => { const input = { unix: ['unix-cmd'], windows: ['windows-cmd'], }; const result = normalizeBlockedCommands(input); expect(result.unix).toEqual(['unix-cmd']); expect(result.windows).toEqual(['windows-cmd']); }); it('should filter out non-string entries', () => { const input = { unix: ['valid', 123, null, 'also-valid'] as unknown[], windows: [true, 'windows-cmd', {}] as unknown[], }; const result = normalizeBlockedCommands(input); expect(result.unix).toEqual(['valid', 'also-valid']); expect(result.windows).toEqual(['windows-cmd']); }); it('should trim whitespace from commands', () => { const input = { unix: [' cmd1 ', 'cmd2 '], windows: [' win-cmd '], }; const result = normalizeBlockedCommands(input); expect(result.unix).toEqual(['cmd1', 'cmd2']); expect(result.windows).toEqual(['win-cmd']); }); it('should filter out empty strings after trimming', () => { const input = { unix: ['cmd1', ' ', '', 'cmd2'], windows: ['', 'win-cmd'], }; const result = normalizeBlockedCommands(input); expect(result.unix).toEqual(['cmd1', 'cmd2']); expect(result.windows).toEqual(['win-cmd']); }); it('should use defaults for missing platform keys', () => { const input = { unix: ['custom-unix'], // windows is missing }; const result = normalizeBlockedCommands(input); expect(result.unix).toEqual(['custom-unix']); expect(result.windows).toEqual(defaults.windows); }); it('should handle non-object, non-array input', () => { expect(normalizeBlockedCommands('string')).toEqual(defaults); expect(normalizeBlockedCommands(123)).toEqual(defaults); expect(normalizeBlockedCommands(true)).toEqual(defaults); }); }); ================================================ FILE: tests/unit/core/storage/McpStorage.test.ts ================================================ import { McpStorage } from '@/core/storage'; import type { VaultFileAdapter } from '@/core/storage/VaultFileAdapter'; /** Mock adapter with exposed store for test assertions. */ type MockAdapter = VaultFileAdapter & { _store: Record<string, string> }; // Mock VaultFileAdapter with minimal implementation for McpStorage tests function createMockAdapter(files: Record<string, string> = {}): MockAdapter { const store = { ...files }; return { exists: async (path: string) => path in store, read: async (path: string) => { if (!(path in store)) throw new Error(`File not found: ${path}`); return store[path]; }, write: async (path: string, content: string) => { store[path] = content; }, delete: async (path: string) => { delete store[path]; }, // Expose store for assertions _store: store, } as unknown as MockAdapter; } describe('McpStorage', () => { describe('load', () => { it('returns empty array when file does not exist', async () => { const adapter = createMockAdapter(); const storage = new McpStorage(adapter); const servers = await storage.load(); expect(servers).toEqual([]); }); it('loads servers with disabledTools from _claudian metadata', async () => { const config = { mcpServers: { alpha: { command: 'alpha-cmd', args: ['--arg'] }, }, _claudian: { servers: { alpha: { enabled: true, contextSaving: true, disabledTools: ['tool_a', 'tool_b'], }, }, }, }; const adapter = createMockAdapter({ '.claude/mcp.json': JSON.stringify(config), }); const storage = new McpStorage(adapter); const servers = await storage.load(); expect(servers).toHaveLength(1); expect(servers[0]).toMatchObject({ name: 'alpha', config: { command: 'alpha-cmd', args: ['--arg'] }, enabled: true, contextSaving: true, disabledTools: ['tool_a', 'tool_b'], }); }); it('filters out non-string disabledTools', async () => { const config = { mcpServers: { alpha: { command: 'alpha-cmd' }, }, _claudian: { servers: { alpha: { disabledTools: ['valid', 123, null, 'also_valid'], }, }, }, }; const adapter = createMockAdapter({ '.claude/mcp.json': JSON.stringify(config), }); const storage = new McpStorage(adapter); const servers = await storage.load(); expect(servers[0].disabledTools).toEqual(['valid', 'also_valid']); }); it('returns undefined disabledTools when array is empty', async () => { const config = { mcpServers: { alpha: { command: 'alpha-cmd' }, }, _claudian: { servers: { alpha: { disabledTools: [], }, }, }, }; const adapter = createMockAdapter({ '.claude/mcp.json': JSON.stringify(config), }); const storage = new McpStorage(adapter); const servers = await storage.load(); expect(servers[0].disabledTools).toBeUndefined(); }); it('returns empty array on JSON parse error', async () => { const adapter = createMockAdapter({ '.claude/mcp.json': 'invalid json{', }); const storage = new McpStorage(adapter); const servers = await storage.load(); expect(servers).toEqual([]); }); it('returns empty array when mcpServers is missing', async () => { const adapter = createMockAdapter({ '.claude/mcp.json': JSON.stringify({}), }); const storage = new McpStorage(adapter); const servers = await storage.load(); expect(servers).toEqual([]); }); it('returns empty array when mcpServers is not an object', async () => { const adapter = createMockAdapter({ '.claude/mcp.json': JSON.stringify({ mcpServers: 'invalid' }), }); const storage = new McpStorage(adapter); const servers = await storage.load(); expect(servers).toEqual([]); }); it('skips invalid server configs', async () => { const config = { mcpServers: { valid: { command: 'valid-cmd' }, invalid: { notACommand: true }, }, }; const adapter = createMockAdapter({ '.claude/mcp.json': JSON.stringify(config), }); const storage = new McpStorage(adapter); const servers = await storage.load(); expect(servers).toHaveLength(1); expect(servers[0].name).toBe('valid'); }); it('applies defaults when no _claudian metadata exists', async () => { const config = { mcpServers: { alpha: { command: 'alpha-cmd' }, }, }; const adapter = createMockAdapter({ '.claude/mcp.json': JSON.stringify(config), }); const storage = new McpStorage(adapter); const servers = await storage.load(); expect(servers[0]).toMatchObject({ name: 'alpha', enabled: true, contextSaving: true, disabledTools: undefined, }); }); it('loads description from _claudian metadata', async () => { const config = { mcpServers: { alpha: { command: 'cmd' } }, _claudian: { servers: { alpha: { description: 'My server' }, }, }, }; const adapter = createMockAdapter({ '.claude/mcp.json': JSON.stringify(config), }); const storage = new McpStorage(adapter); const servers = await storage.load(); expect(servers[0].description).toBe('My server'); }); }); describe('save', () => { it('saves disabledTools to _claudian metadata', async () => { const adapter = createMockAdapter(); const storage = new McpStorage(adapter); await storage.save([ { name: 'alpha', config: { command: 'alpha-cmd' }, enabled: true, contextSaving: true, disabledTools: ['tool_a', 'tool_b'], }, ]); const saved = JSON.parse(adapter._store['.claude/mcp.json']); expect(saved._claudian.servers.alpha.disabledTools).toEqual(['tool_a', 'tool_b']); }); it('trims and filters blank disabledTools on save', async () => { const adapter = createMockAdapter(); const storage = new McpStorage(adapter); await storage.save([ { name: 'alpha', config: { command: 'alpha-cmd' }, enabled: true, contextSaving: true, disabledTools: [' tool_a ', '', ' ', 'tool_b'], }, ]); const saved = JSON.parse(adapter._store['.claude/mcp.json']); expect(saved._claudian.servers.alpha.disabledTools).toEqual(['tool_a', 'tool_b']); }); it('omits disabledTools from metadata when empty', async () => { const adapter = createMockAdapter(); const storage = new McpStorage(adapter); await storage.save([ { name: 'alpha', config: { command: 'alpha-cmd' }, enabled: true, // default contextSaving: true, // default disabledTools: [], }, ]); const saved = JSON.parse(adapter._store['.claude/mcp.json']); // No _claudian since all fields are default expect(saved._claudian).toBeUndefined(); }); it('preserves existing _claudian metadata when saving', async () => { const existing = { mcpServers: { alpha: { command: 'alpha-cmd' }, }, _claudian: { customField: 'should be preserved', servers: { alpha: { enabled: false }, }, }, }; const adapter = createMockAdapter({ '.claude/mcp.json': JSON.stringify(existing), }); const storage = new McpStorage(adapter); await storage.save([ { name: 'alpha', config: { command: 'alpha-cmd' }, enabled: true, contextSaving: true, disabledTools: ['tool_a'], }, ]); const saved = JSON.parse(adapter._store['.claude/mcp.json']); expect(saved._claudian.customField).toBe('should be preserved'); expect(saved._claudian.servers.alpha.disabledTools).toEqual(['tool_a']); }); it('round-trips disabledTools correctly', async () => { const adapter = createMockAdapter(); const storage = new McpStorage(adapter); const original = [ { name: 'alpha', config: { command: 'alpha-cmd' }, enabled: true, contextSaving: true, disabledTools: ['tool_a', 'tool_b'], }, { name: 'beta', config: { command: 'beta-cmd' }, enabled: false, contextSaving: false, disabledTools: undefined, }, ]; await storage.save(original); const loaded = await storage.load(); expect(loaded).toHaveLength(2); expect(loaded[0]).toMatchObject({ name: 'alpha', disabledTools: ['tool_a', 'tool_b'], }); expect(loaded[1]).toMatchObject({ name: 'beta', disabledTools: undefined, }); }); it('saves description to _claudian metadata', async () => { const adapter = createMockAdapter(); const storage = new McpStorage(adapter); await storage.save([ { name: 'alpha', config: { command: 'cmd' }, enabled: true, contextSaving: true, description: 'A test server', }, ]); const saved = JSON.parse(adapter._store['.claude/mcp.json']); expect(saved._claudian.servers.alpha.description).toBe('A test server'); }); it('stores enabled=false in _claudian when different from default', async () => { const adapter = createMockAdapter(); const storage = new McpStorage(adapter); await storage.save([ { name: 'alpha', config: { command: 'cmd' }, enabled: false, contextSaving: true, }, ]); const saved = JSON.parse(adapter._store['.claude/mcp.json']); expect(saved._claudian.servers.alpha.enabled).toBe(false); }); it('stores contextSaving=false in _claudian when different from default', async () => { const adapter = createMockAdapter(); const storage = new McpStorage(adapter); await storage.save([ { name: 'alpha', config: { command: 'cmd' }, enabled: true, contextSaving: false, }, ]); const saved = JSON.parse(adapter._store['.claude/mcp.json']); expect(saved._claudian.servers.alpha.contextSaving).toBe(false); }); it('removes _claudian.servers when all metadata is default', async () => { const existing = { mcpServers: { alpha: { command: 'cmd' } }, _claudian: { servers: { alpha: { enabled: false } } }, }; const adapter = createMockAdapter({ '.claude/mcp.json': JSON.stringify(existing), }); const storage = new McpStorage(adapter); await storage.save([ { name: 'alpha', config: { command: 'cmd' }, enabled: true, contextSaving: true, }, ]); const saved = JSON.parse(adapter._store['.claude/mcp.json']); expect(saved._claudian).toBeUndefined(); }); it('preserves non-servers _claudian fields when removing servers', async () => { const existing = { mcpServers: { alpha: { command: 'cmd' } }, _claudian: { customField: 'keep', servers: { alpha: { enabled: false } }, }, }; const adapter = createMockAdapter({ '.claude/mcp.json': JSON.stringify(existing), }); const storage = new McpStorage(adapter); await storage.save([ { name: 'alpha', config: { command: 'cmd' }, enabled: true, contextSaving: true, }, ]); const saved = JSON.parse(adapter._store['.claude/mcp.json']); expect(saved._claudian).toEqual({ customField: 'keep' }); }); it('handles corrupted existing file gracefully', async () => { const adapter = createMockAdapter({ '.claude/mcp.json': 'not json', }); const storage = new McpStorage(adapter); await storage.save([ { name: 'alpha', config: { command: 'cmd' }, enabled: true, contextSaving: true, }, ]); const saved = JSON.parse(adapter._store['.claude/mcp.json']); expect(saved.mcpServers.alpha).toEqual({ command: 'cmd' }); }); it('preserves extra top-level fields in existing file', async () => { const existing = { mcpServers: { old: { command: 'old-cmd' } }, someExtraField: 'preserved', }; const adapter = createMockAdapter({ '.claude/mcp.json': JSON.stringify(existing), }); const storage = new McpStorage(adapter); await storage.save([ { name: 'new', config: { command: 'new-cmd' }, enabled: true, contextSaving: true, }, ]); const saved = JSON.parse(adapter._store['.claude/mcp.json']); expect(saved.someExtraField).toBe('preserved'); expect(saved.mcpServers).toEqual({ new: { command: 'new-cmd' } }); }); }); describe('exists', () => { it('returns false when mcp.json does not exist', async () => { const adapter = createMockAdapter(); const storage = new McpStorage(adapter); expect(await storage.exists()).toBe(false); }); it('returns true when mcp.json exists', async () => { const adapter = createMockAdapter({ '.claude/mcp.json': '{}', }); const storage = new McpStorage(adapter); expect(await storage.exists()).toBe(true); }); }); describe('parseClipboardConfig', () => { it('parses full Claude Code format (mcpServers wrapper)', () => { const json = JSON.stringify({ mcpServers: { 'my-server': { command: 'node', args: ['server.js'] }, }, }); const result = McpStorage.parseClipboardConfig(json); expect(result.needsName).toBe(false); expect(result.servers).toHaveLength(1); expect(result.servers[0].name).toBe('my-server'); expect(result.servers[0].config).toEqual({ command: 'node', args: ['server.js'] }); }); it('parses multiple servers in mcpServers format', () => { const json = JSON.stringify({ mcpServers: { alpha: { command: 'alpha-cmd' }, beta: { type: 'sse', url: 'http://localhost:3000' }, }, }); const result = McpStorage.parseClipboardConfig(json); expect(result.servers).toHaveLength(2); expect(result.needsName).toBe(false); }); it('parses single server config without name (command-based)', () => { const json = JSON.stringify({ command: 'node', args: ['server.js'] }); const result = McpStorage.parseClipboardConfig(json); expect(result.needsName).toBe(true); expect(result.servers).toHaveLength(1); expect(result.servers[0].name).toBe(''); expect((result.servers[0].config as { command: string }).command).toBe('node'); }); it('parses single server config without name (url-based)', () => { const json = JSON.stringify({ type: 'sse', url: 'http://example.com' }); const result = McpStorage.parseClipboardConfig(json); expect(result.needsName).toBe(true); expect(result.servers[0].config).toEqual({ type: 'sse', url: 'http://example.com' }); }); it('parses single named server', () => { const json = JSON.stringify({ 'my-server': { command: 'node', args: ['server.js'] }, }); const result = McpStorage.parseClipboardConfig(json); expect(result.needsName).toBe(false); expect(result.servers).toHaveLength(1); expect(result.servers[0].name).toBe('my-server'); }); it('parses multiple named servers without mcpServers wrapper', () => { const json = JSON.stringify({ server1: { command: 'cmd1' }, server2: { command: 'cmd2' }, }); const result = McpStorage.parseClipboardConfig(json); expect(result.needsName).toBe(false); expect(result.servers).toHaveLength(2); }); it('throws for invalid JSON', () => { expect(() => McpStorage.parseClipboardConfig('not json')) .toThrow('Invalid JSON'); }); it('throws for non-object JSON', () => { expect(() => McpStorage.parseClipboardConfig('"string"')) .toThrow('Invalid JSON object'); }); it('throws when mcpServers contains no valid configs', () => { const json = JSON.stringify({ mcpServers: { invalid: { notACommand: true }, }, }); expect(() => McpStorage.parseClipboardConfig(json)) .toThrow('No valid server configs found'); }); it('throws for unrecognized format', () => { const json = JSON.stringify({ someRandomField: 123 }); expect(() => McpStorage.parseClipboardConfig(json)) .toThrow('Invalid MCP configuration format'); }); it('skips invalid entries in mcpServers but includes valid ones', () => { const json = JSON.stringify({ mcpServers: { valid: { command: 'cmd' }, invalid: { notACommand: true }, }, }); const result = McpStorage.parseClipboardConfig(json); expect(result.servers).toHaveLength(1); expect(result.servers[0].name).toBe('valid'); }); }); describe('tryParseClipboardConfig', () => { it('returns parsed config for valid JSON', () => { const text = JSON.stringify({ command: 'node', args: ['server.js'] }); const result = McpStorage.tryParseClipboardConfig(text); expect(result).not.toBeNull(); expect(result!.needsName).toBe(true); }); it('returns null for non-JSON text', () => { expect(McpStorage.tryParseClipboardConfig('hello world')).toBeNull(); }); it('returns null for text not starting with {', () => { expect(McpStorage.tryParseClipboardConfig('[1, 2, 3]')).toBeNull(); }); it('returns null for invalid MCP config that is valid JSON', () => { expect(McpStorage.tryParseClipboardConfig('{ "random": 42 }')).toBeNull(); }); it('trims whitespace before checking', () => { const text = ' \n ' + JSON.stringify({ command: 'node' }) + ' \n'; const result = McpStorage.tryParseClipboardConfig(text); expect(result).not.toBeNull(); }); }); }); ================================================ FILE: tests/unit/core/storage/SessionStorage.test.ts ================================================ import { SESSIONS_PATH,SessionStorage } from '@/core/storage/SessionStorage'; import type { VaultFileAdapter } from '@/core/storage/VaultFileAdapter'; import type { Conversation, SessionMetadata, UsageInfo } from '@/core/types'; describe('SessionStorage', () => { let mockAdapter: jest.Mocked<VaultFileAdapter>; let storage: SessionStorage; beforeEach(() => { mockAdapter = { exists: jest.fn(), read: jest.fn(), write: jest.fn(), delete: jest.fn(), listFiles: jest.fn(), } as unknown as jest.Mocked<VaultFileAdapter>; storage = new SessionStorage(mockAdapter); }); describe('SESSIONS_PATH', () => { it('should be .claude/sessions', () => { expect(SESSIONS_PATH).toBe('.claude/sessions'); }); }); describe('getFilePath', () => { it('returns correct file path for conversation id', () => { const path = storage.getFilePath('conv-123'); expect(path).toBe('.claude/sessions/conv-123.jsonl'); }); }); describe('loadConversation', () => { it('returns null if file does not exist', async () => { mockAdapter.exists.mockResolvedValue(false); const result = await storage.loadConversation('conv-123'); expect(result).toBeNull(); expect(mockAdapter.exists).toHaveBeenCalledWith('.claude/sessions/conv-123.jsonl'); }); it('loads and parses conversation from JSONL file', async () => { const jsonlContent = [ '{"type":"meta","id":"conv-123","title":"Test Chat","createdAt":1700000000,"updatedAt":1700001000,"sessionId":"sdk-session"}', '{"type":"message","message":{"id":"msg-1","role":"user","content":"Hello","timestamp":1700000100}}', '{"type":"message","message":{"id":"msg-2","role":"assistant","content":"Hi!","timestamp":1700000200}}', ].join('\n'); mockAdapter.exists.mockResolvedValue(true); mockAdapter.read.mockResolvedValue(jsonlContent); const result = await storage.loadConversation('conv-123'); expect(result).toEqual({ id: 'conv-123', title: 'Test Chat', createdAt: 1700000000, updatedAt: 1700001000, lastResponseAt: undefined, sessionId: 'sdk-session', messages: [ { id: 'msg-1', role: 'user', content: 'Hello', timestamp: 1700000100 }, { id: 'msg-2', role: 'assistant', content: 'Hi!', timestamp: 1700000200 }, ], currentNote: undefined, usage: undefined, titleGenerationStatus: undefined, }); }); it('handles CRLF line endings', async () => { const jsonlContent = [ '{"type":"meta","id":"conv-123","title":"Test","createdAt":1700000000,"updatedAt":1700001000,"sessionId":null}', '{"type":"message","message":{"id":"msg-1","role":"user","content":"Hello","timestamp":1700000100}}', ].join('\r\n'); mockAdapter.exists.mockResolvedValue(true); mockAdapter.read.mockResolvedValue(jsonlContent); const result = await storage.loadConversation('conv-123'); expect(result?.messages).toHaveLength(1); }); it('returns null for empty file', async () => { mockAdapter.exists.mockResolvedValue(true); mockAdapter.read.mockResolvedValue(''); const result = await storage.loadConversation('conv-123'); expect(result).toBeNull(); }); it('returns null if no meta record found', async () => { const jsonlContent = '{"type":"message","message":{"id":"msg-1","role":"user","content":"Hello","timestamp":1700000100}}'; mockAdapter.exists.mockResolvedValue(true); mockAdapter.read.mockResolvedValue(jsonlContent); const result = await storage.loadConversation('conv-123'); expect(result).toBeNull(); }); it('handles read errors gracefully', async () => { mockAdapter.exists.mockResolvedValue(true); mockAdapter.read.mockRejectedValue(new Error('Read error')); const result = await storage.loadConversation('conv-123'); expect(result).toBeNull(); }); it('skips invalid JSON lines and continues parsing', async () => { const jsonlContent = [ '{"type":"meta","id":"conv-123","title":"Test","createdAt":1700000000,"updatedAt":1700001000,"sessionId":null}', 'invalid json line', '{"type":"message","message":{"id":"msg-1","role":"user","content":"Hello","timestamp":1700000100}}', ].join('\n'); mockAdapter.exists.mockResolvedValue(true); mockAdapter.read.mockResolvedValue(jsonlContent); const result = await storage.loadConversation('conv-123'); expect(result?.messages).toHaveLength(1); }); it('preserves all conversation metadata', async () => { const usage: UsageInfo = { model: 'claude-sonnet-4-5', inputTokens: 1000, cacheCreationInputTokens: 500, cacheReadInputTokens: 200, contextWindow: 200000, contextTokens: 1700, percentage: 1, }; const jsonlContent = JSON.stringify({ type: 'meta', id: 'conv-123', title: 'Full Test', createdAt: 1700000000, updatedAt: 1700001000, lastResponseAt: 1700000900, sessionId: 'sdk-session', currentNote: 'notes/test.md', usage, titleGenerationStatus: 'success', }); mockAdapter.exists.mockResolvedValue(true); mockAdapter.read.mockResolvedValue(jsonlContent); const result = await storage.loadConversation('conv-123'); expect(result?.currentNote).toBe('notes/test.md'); expect(result?.usage).toEqual(usage); expect(result?.titleGenerationStatus).toBe('success'); expect(result?.lastResponseAt).toBe(1700000900); }); }); describe('saveConversation', () => { it('serializes conversation to JSONL and writes to file', async () => { const conversation: Conversation = { id: 'conv-456', title: 'Save Test', createdAt: 1700000000, updatedAt: 1700001000, sessionId: 'sdk-session', messages: [ { id: 'msg-1', role: 'user', content: 'Hello', timestamp: 1700000100 }, { id: 'msg-2', role: 'assistant', content: 'Hi!', timestamp: 1700000200 }, ], }; await storage.saveConversation(conversation); expect(mockAdapter.write).toHaveBeenCalledWith( '.claude/sessions/conv-456.jsonl', expect.any(String) ); const writtenContent = mockAdapter.write.mock.calls[0][1]; const lines = writtenContent.split('\n'); expect(lines).toHaveLength(3); const meta = JSON.parse(lines[0]); expect(meta.type).toBe('meta'); expect(meta.id).toBe('conv-456'); expect(meta.title).toBe('Save Test'); const msg1 = JSON.parse(lines[1]); expect(msg1.type).toBe('message'); expect(msg1.message.role).toBe('user'); const msg2 = JSON.parse(lines[2]); expect(msg2.type).toBe('message'); expect(msg2.message.role).toBe('assistant'); }); it('preserves base64 image data when saving', async () => { const conversation: Conversation = { id: 'conv-img', title: 'Image Test', createdAt: 1700000000, updatedAt: 1700001000, sessionId: null, messages: [ { id: 'msg-1', role: 'user', content: 'Check this image', timestamp: 1700000100, images: [ { id: 'img-1', name: 'test.png', data: 'base64encodeddata...', mediaType: 'image/png', size: 1024, source: 'paste', }, ], }, ], }; await storage.saveConversation(conversation); const writtenContent = mockAdapter.write.mock.calls[0][1]; const lines = writtenContent.split('\n'); const msgRecord = JSON.parse(lines[1]); // Image data is preserved as single source of truth expect(msgRecord.message.images[0].data).toBe('base64encodeddata...'); expect(msgRecord.message.images[0].mediaType).toBe('image/png'); }); it('handles messages without images', async () => { const conversation: Conversation = { id: 'conv-no-img', title: 'No Image Test', createdAt: 1700000000, updatedAt: 1700001000, sessionId: null, messages: [ { id: 'msg-1', role: 'user', content: 'Just text', timestamp: 1700000100 }, ], }; await storage.saveConversation(conversation); const writtenContent = mockAdapter.write.mock.calls[0][1]; const lines = writtenContent.split('\n'); const msgRecord = JSON.parse(lines[1]); expect(msgRecord.message).toEqual({ id: 'msg-1', role: 'user', content: 'Just text', timestamp: 1700000100, }); }); it('preserves all metadata fields in serialization', async () => { const usage: UsageInfo = { model: 'claude-opus-4-5', inputTokens: 5000, cacheCreationInputTokens: 1000, cacheReadInputTokens: 500, contextWindow: 200000, contextTokens: 6500, percentage: 3, }; const conversation: Conversation = { id: 'conv-meta', title: 'Meta Test', createdAt: 1700000000, updatedAt: 1700001000, lastResponseAt: 1700000900, sessionId: 'sdk-session-abc', currentNote: 'projects/notes.md', usage, titleGenerationStatus: 'pending', messages: [], }; await storage.saveConversation(conversation); const writtenContent = mockAdapter.write.mock.calls[0][1]; const meta = JSON.parse(writtenContent); expect(meta.lastResponseAt).toBe(1700000900); expect(meta.currentNote).toBe('projects/notes.md'); expect(meta.usage).toEqual(usage); expect(meta.titleGenerationStatus).toBe('pending'); }); }); describe('deleteConversation', () => { it('deletes the JSONL file', async () => { await storage.deleteConversation('conv-del'); expect(mockAdapter.delete).toHaveBeenCalledWith('.claude/sessions/conv-del.jsonl'); }); }); describe('listConversations', () => { it('returns metadata for all JSONL files', async () => { mockAdapter.listFiles.mockResolvedValue([ '.claude/sessions/conv-1.jsonl', '.claude/sessions/conv-2.jsonl', '.claude/sessions/readme.txt', // Should be skipped ]); mockAdapter.read.mockImplementation((path: string) => { if (path.includes('conv-1')) { return Promise.resolve([ '{"type":"meta","id":"conv-1","title":"First","createdAt":1700000000,"updatedAt":1700002000,"sessionId":null}', '{"type":"message","message":{"id":"msg-1","role":"user","content":"First message content here","timestamp":1700000100}}', ].join('\n')); } if (path.includes('conv-2')) { return Promise.resolve([ '{"type":"meta","id":"conv-2","title":"Second","createdAt":1700000000,"updatedAt":1700001000,"sessionId":"sdk-2"}', '{"type":"message","message":{"id":"msg-1","role":"assistant","content":"Assistant first","timestamp":1700000100}}', '{"type":"message","message":{"id":"msg-2","role":"user","content":"User message","timestamp":1700000200}}', ].join('\n')); } return Promise.resolve(''); }); const metas = await storage.listConversations(); expect(metas).toHaveLength(2); // Should be sorted by updatedAt descending expect(metas[0].id).toBe('conv-1'); expect(metas[0].title).toBe('First'); expect(metas[0].messageCount).toBe(1); expect(metas[0].preview).toBe('First message content here'); expect(metas[1].id).toBe('conv-2'); expect(metas[1].title).toBe('Second'); expect(metas[1].messageCount).toBe(2); expect(metas[1].preview).toBe('User message'); // First user message }); it('handles empty sessions directory', async () => { mockAdapter.listFiles.mockResolvedValue([]); const metas = await storage.listConversations(); expect(metas).toEqual([]); }); it('handles listFiles error gracefully', async () => { mockAdapter.listFiles.mockRejectedValue(new Error('List error')); const metas = await storage.listConversations(); expect(metas).toEqual([]); }); it('skips files that fail to load', async () => { mockAdapter.listFiles.mockResolvedValue([ '.claude/sessions/good.jsonl', '.claude/sessions/bad.jsonl', ]); mockAdapter.read.mockImplementation((path: string) => { if (path.includes('good')) { return Promise.resolve( '{"type":"meta","id":"good","title":"Good","createdAt":1700000000,"updatedAt":1700001000,"sessionId":null}' ); } return Promise.reject(new Error('Read error')); }); const metas = await storage.listConversations(); expect(metas).toHaveLength(1); expect(metas[0].id).toBe('good'); }); it('truncates long previews', async () => { mockAdapter.listFiles.mockResolvedValue(['.claude/sessions/conv-long.jsonl']); const longContent = 'A'.repeat(100); mockAdapter.read.mockResolvedValue([ '{"type":"meta","id":"conv-long","title":"Long","createdAt":1700000000,"updatedAt":1700001000,"sessionId":null}', `{"type":"message","message":{"id":"msg-1","role":"user","content":"${longContent}","timestamp":1700000100}}`, ].join('\n')); const metas = await storage.listConversations(); expect(metas[0].preview).toBe('A'.repeat(50) + '...'); }); it('uses default preview for conversations without user messages', async () => { mockAdapter.listFiles.mockResolvedValue(['.claude/sessions/conv-no-user.jsonl']); mockAdapter.read.mockResolvedValue([ '{"type":"meta","id":"conv-no-user","title":"No User","createdAt":1700000000,"updatedAt":1700001000,"sessionId":null}', '{"type":"message","message":{"id":"msg-1","role":"assistant","content":"Only assistant","timestamp":1700000100}}', ].join('\n')); const metas = await storage.listConversations(); expect(metas[0].preview).toBe('New conversation'); }); it('preserves titleGenerationStatus in meta', async () => { mockAdapter.listFiles.mockResolvedValue(['.claude/sessions/conv-status.jsonl']); mockAdapter.read.mockResolvedValue( '{"type":"meta","id":"conv-status","title":"Status Test","createdAt":1700000000,"updatedAt":1700001000,"sessionId":null,"titleGenerationStatus":"failed"}' ); const metas = await storage.listConversations(); expect(metas[0].titleGenerationStatus).toBe('failed'); }); }); describe('loadAllConversations', () => { it('loads full conversation data for all JSONL files', async () => { mockAdapter.listFiles.mockResolvedValue([ '.claude/sessions/conv-a.jsonl', '.claude/sessions/conv-b.jsonl', ]); mockAdapter.read.mockImplementation((path: string) => { if (path.includes('conv-a')) { return Promise.resolve([ '{"type":"meta","id":"conv-a","title":"Conv A","createdAt":1700000000,"updatedAt":1700002000,"sessionId":"a"}', '{"type":"message","message":{"id":"msg-1","role":"user","content":"Hello A","timestamp":1700000100}}', ].join('\n')); } if (path.includes('conv-b')) { return Promise.resolve([ '{"type":"meta","id":"conv-b","title":"Conv B","createdAt":1700000000,"updatedAt":1700001000,"sessionId":"b"}', '{"type":"message","message":{"id":"msg-1","role":"user","content":"Hello B","timestamp":1700000100}}', ].join('\n')); } return Promise.resolve(''); }); const { conversations } = await storage.loadAllConversations(); expect(conversations).toHaveLength(2); // Sorted by updatedAt descending expect(conversations[0].id).toBe('conv-a'); expect(conversations[0].messages).toHaveLength(1); expect(conversations[1].id).toBe('conv-b'); expect(conversations[1].messages).toHaveLength(1); }); it('skips non-JSONL files', async () => { mockAdapter.listFiles.mockResolvedValue([ '.claude/sessions/conv.jsonl', '.claude/sessions/notes.md', '.claude/sessions/.DS_Store', ]); mockAdapter.read.mockResolvedValue( '{"type":"meta","id":"conv","title":"Conv","createdAt":1700000000,"updatedAt":1700001000,"sessionId":null}' ); const { conversations } = await storage.loadAllConversations(); expect(conversations).toHaveLength(1); expect(mockAdapter.read).toHaveBeenCalledTimes(1); }); it('handles errors gracefully', async () => { mockAdapter.listFiles.mockRejectedValue(new Error('List error')); const { conversations } = await storage.loadAllConversations(); expect(conversations).toEqual([]); }); it('continues loading after individual file errors', async () => { mockAdapter.listFiles.mockResolvedValue([ '.claude/sessions/good.jsonl', '.claude/sessions/bad.jsonl', ]); mockAdapter.read.mockImplementation((path: string) => { if (path.includes('good')) { return Promise.resolve( '{"type":"meta","id":"good","title":"Good","createdAt":1700000000,"updatedAt":1700001000,"sessionId":null}' ); } return Promise.reject(new Error('Read error')); }); const { conversations, failedCount } = await storage.loadAllConversations(); expect(conversations).toHaveLength(1); expect(conversations[0].id).toBe('good'); expect(failedCount).toBe(1); }); }); describe('hasSessions', () => { it('returns true if JSONL files exist', async () => { mockAdapter.listFiles.mockResolvedValue([ '.claude/sessions/conv-1.jsonl', '.claude/sessions/conv-2.jsonl', ]); const result = await storage.hasSessions(); expect(result).toBe(true); }); it('returns false if no JSONL files exist', async () => { mockAdapter.listFiles.mockResolvedValue([ '.claude/sessions/readme.txt', '.claude/sessions/.gitkeep', ]); const result = await storage.hasSessions(); expect(result).toBe(false); }); it('returns false if directory is empty', async () => { mockAdapter.listFiles.mockResolvedValue([]); const result = await storage.hasSessions(); expect(result).toBe(false); }); }); // ============================================ // SDK-Native Session Metadata Tests // ============================================ describe('isNativeSession', () => { it('returns false if legacy JSONL exists', async () => { mockAdapter.exists.mockImplementation((path: string) => Promise.resolve(path.endsWith('.jsonl')) ); const result = await storage.isNativeSession('conv-123'); expect(result).toBe(false); }); it('returns true if only meta.json exists', async () => { mockAdapter.exists.mockImplementation((path: string) => Promise.resolve(path.endsWith('.meta.json')) ); const result = await storage.isNativeSession('conv-123'); expect(result).toBe(true); }); it('returns true if neither file exists (new native session)', async () => { mockAdapter.exists.mockResolvedValue(false); const result = await storage.isNativeSession('conv-new'); expect(result).toBe(true); }); it('returns false if both JSONL and meta.json exist (legacy takes precedence)', async () => { mockAdapter.exists.mockResolvedValue(true); const result = await storage.isNativeSession('conv-both'); expect(result).toBe(false); }); }); describe('getMetadataPath', () => { it('returns correct file path for session id', () => { const path = storage.getMetadataPath('session-abc'); expect(path).toBe('.claude/sessions/session-abc.meta.json'); }); }); describe('saveMetadata', () => { it('serializes metadata to JSON and writes to file', async () => { const metadata: SessionMetadata = { id: 'session-456', title: 'Test Session', createdAt: 1700000000, updatedAt: 1700001000, lastResponseAt: 1700000900, currentNote: 'notes/test.md', titleGenerationStatus: 'success', }; await storage.saveMetadata(metadata); expect(mockAdapter.write).toHaveBeenCalledWith( '.claude/sessions/session-456.meta.json', expect.any(String) ); const writtenContent = mockAdapter.write.mock.calls[0][1]; const parsed = JSON.parse(writtenContent); expect(parsed.id).toBe('session-456'); expect(parsed.title).toBe('Test Session'); expect(parsed.lastResponseAt).toBe(1700000900); expect(parsed.titleGenerationStatus).toBe('success'); }); it('preserves all optional fields', async () => { const usage: UsageInfo = { model: 'claude-sonnet-4-5', inputTokens: 1000, cacheCreationInputTokens: 500, cacheReadInputTokens: 200, contextWindow: 200000, contextTokens: 1700, percentage: 1, }; const metadata: SessionMetadata = { id: 'session-full', title: 'Full Test', createdAt: 1700000000, updatedAt: 1700001000, externalContextPaths: ['/path/to/external'], enabledMcpServers: ['server1', 'server2'], usage, }; await storage.saveMetadata(metadata); const writtenContent = mockAdapter.write.mock.calls[0][1]; const parsed = JSON.parse(writtenContent); expect(parsed.externalContextPaths).toEqual(['/path/to/external']); expect(parsed.enabledMcpServers).toEqual(['server1', 'server2']); expect(parsed.usage).toEqual(usage); }); }); describe('loadMetadata', () => { it('returns null if file does not exist', async () => { mockAdapter.exists.mockResolvedValue(false); const result = await storage.loadMetadata('session-123'); expect(result).toBeNull(); }); it('loads and parses metadata from JSON file', async () => { const metadata = { id: 'session-abc', title: 'Loaded Session', createdAt: 1700000000, updatedAt: 1700001000, titleGenerationStatus: 'pending', }; mockAdapter.exists.mockResolvedValue(true); mockAdapter.read.mockResolvedValue(JSON.stringify(metadata)); const result = await storage.loadMetadata('session-abc'); expect(result).toEqual(metadata); }); it('returns null on parse error', async () => { mockAdapter.exists.mockResolvedValue(true); mockAdapter.read.mockResolvedValue('invalid json'); const result = await storage.loadMetadata('session-bad'); expect(result).toBeNull(); }); it('returns null on read error', async () => { mockAdapter.exists.mockResolvedValue(true); mockAdapter.read.mockRejectedValue(new Error('Read error')); const result = await storage.loadMetadata('session-error'); expect(result).toBeNull(); }); }); describe('deleteMetadata', () => { it('deletes the meta.json file', async () => { await storage.deleteMetadata('session-del'); expect(mockAdapter.delete).toHaveBeenCalledWith('.claude/sessions/session-del.meta.json'); }); }); describe('listNativeMetadata', () => { it('returns metadata for .meta.json files without .jsonl counterparts', async () => { mockAdapter.listFiles.mockResolvedValue([ '.claude/sessions/native-1.meta.json', '.claude/sessions/native-2.meta.json', '.claude/sessions/legacy.jsonl', '.claude/sessions/legacy.meta.json', // Has JSONL counterpart, skip ]); // native-1.meta.json and native-2.meta.json have no .jsonl // legacy.meta.json has .jsonl counterpart mockAdapter.exists.mockImplementation((path: string) => { if (path === '.claude/sessions/native-1.jsonl') return Promise.resolve(false); if (path === '.claude/sessions/native-2.jsonl') return Promise.resolve(false); if (path === '.claude/sessions/legacy.jsonl') return Promise.resolve(true); return Promise.resolve(false); }); mockAdapter.read.mockImplementation((path: string) => { if (path.includes('native-1')) { return Promise.resolve(JSON.stringify({ id: 'native-1', title: 'Native One', createdAt: 1700000000, updatedAt: 1700002000, })); } if (path.includes('native-2')) { return Promise.resolve(JSON.stringify({ id: 'native-2', title: 'Native Two', createdAt: 1700000000, updatedAt: 1700001000, })); } return Promise.resolve('{}'); }); const metas = await storage.listNativeMetadata(); expect(metas).toHaveLength(2); expect(metas.map(m => m.id)).toContain('native-1'); expect(metas.map(m => m.id)).toContain('native-2'); }); it('handles empty sessions directory', async () => { mockAdapter.listFiles.mockResolvedValue([]); const metas = await storage.listNativeMetadata(); expect(metas).toEqual([]); }); it('handles listFiles error gracefully', async () => { mockAdapter.listFiles.mockRejectedValue(new Error('List error')); const metas = await storage.listNativeMetadata(); expect(metas).toEqual([]); }); it('skips files that fail to load', async () => { mockAdapter.listFiles.mockResolvedValue([ '.claude/sessions/good.meta.json', '.claude/sessions/bad.meta.json', ]); mockAdapter.exists.mockResolvedValue(false); // No JSONL files mockAdapter.read.mockImplementation((path: string) => { if (path.includes('good')) { return Promise.resolve(JSON.stringify({ id: 'good', title: 'Good', createdAt: 1700000000, updatedAt: 1700001000, })); } return Promise.reject(new Error('Read error')); }); const metas = await storage.listNativeMetadata(); expect(metas).toHaveLength(1); expect(metas[0].id).toBe('good'); }); }); describe('listAllConversations', () => { it('merges legacy and native conversations', async () => { // Set up legacy JSONL files mockAdapter.listFiles.mockResolvedValue([ '.claude/sessions/legacy-1.jsonl', '.claude/sessions/native-1.meta.json', ]); mockAdapter.exists.mockImplementation((path: string) => { // native-1 has no .jsonl counterpart if (path === '.claude/sessions/native-1.jsonl') return Promise.resolve(false); return Promise.resolve(true); }); mockAdapter.read.mockImplementation((path: string) => { if (path.includes('legacy-1.jsonl')) { return Promise.resolve([ '{"type":"meta","id":"legacy-1","title":"Legacy","createdAt":1700000000,"updatedAt":1700001000,"sessionId":null}', '{"type":"message","message":{"id":"msg-1","role":"user","content":"Hello","timestamp":1700000100}}', ].join('\n')); } if (path.includes('native-1.meta.json')) { return Promise.resolve(JSON.stringify({ id: 'native-1', title: 'Native', createdAt: 1700000000, updatedAt: 1700002000, lastResponseAt: 1700001500, })); } return Promise.resolve(''); }); const metas = await storage.listAllConversations(); expect(metas).toHaveLength(2); // Should be sorted by lastResponseAt/updatedAt descending expect(metas[0].id).toBe('native-1'); // updatedAt: 1700002000 expect(metas[0].isNative).toBe(true); expect(metas[1].id).toBe('legacy-1'); // updatedAt: 1700001000 expect(metas[1].isNative).toBeUndefined(); }); it('legacy takes precedence over native with same ID', async () => { mockAdapter.listFiles.mockResolvedValue([ '.claude/sessions/conv-1.jsonl', '.claude/sessions/conv-1.meta.json', // Same ID, should be skipped ]); mockAdapter.exists.mockResolvedValue(true); // .jsonl exists for conv-1 mockAdapter.read.mockImplementation((path: string) => { if (path.includes('.jsonl')) { return Promise.resolve( '{"type":"meta","id":"conv-1","title":"Legacy Version","createdAt":1700000000,"updatedAt":1700001000,"sessionId":null}' ); } return Promise.resolve(JSON.stringify({ id: 'conv-1', title: 'Native Version', createdAt: 1700000000, updatedAt: 1700002000, })); }); const metas = await storage.listAllConversations(); expect(metas).toHaveLength(1); expect(metas[0].title).toBe('Legacy Version'); expect(metas[0].isNative).toBeUndefined(); }); it('native sessions have isNative flag and default preview', async () => { mockAdapter.listFiles.mockResolvedValue([ '.claude/sessions/native-only.meta.json', ]); mockAdapter.exists.mockResolvedValue(false); // No .jsonl mockAdapter.read.mockResolvedValue(JSON.stringify({ id: 'native-only', title: 'Native Only', createdAt: 1700000000, updatedAt: 1700001000, })); const metas = await storage.listAllConversations(); expect(metas).toHaveLength(1); expect(metas[0].isNative).toBe(true); expect(metas[0].preview).toBe('SDK session'); expect(metas[0].messageCount).toBe(0); }); }); describe('loadAllConversations - failedCount for unparseable JSONL', () => { it('increments failedCount when parseJSONL returns null', async () => { mockAdapter.listFiles.mockResolvedValue([ '.claude/sessions/good.jsonl', '.claude/sessions/empty.jsonl', ]); mockAdapter.read.mockImplementation((path: string) => { if (path.includes('good')) { return Promise.resolve([ '{"type":"meta","id":"good","title":"Good","createdAt":1700000000,"updatedAt":1700001000,"sessionId":null}', '{"type":"message","message":{"id":"msg-1","role":"user","content":"Hello","timestamp":1700000100}}', ].join('\n')); } // Empty content → parseJSONL returns null return Promise.resolve(''); }); const { conversations, failedCount } = await storage.loadAllConversations(); expect(conversations).toHaveLength(1); expect(failedCount).toBe(1); }); }); describe('listConversations - loadMetaOnly edge cases', () => { it('handles corrupted message lines in loadMetaOnly', async () => { mockAdapter.listFiles.mockResolvedValue(['.claude/sessions/corrupted.jsonl']); mockAdapter.read.mockResolvedValue([ '{"type":"meta","id":"corrupted","title":"Corrupted","createdAt":1700000000,"updatedAt":1700001000,"sessionId":null}', 'not valid json at all{{{', '{"type":"message","message":{"id":"msg-1","role":"user","content":"Hello","timestamp":1700000100}}', ].join('\n')); const metas = await storage.listConversations(); expect(metas).toHaveLength(1); expect(metas[0].id).toBe('corrupted'); // Should find the user message after skipping corrupted line expect(metas[0].preview).toBe('Hello'); }); it('returns empty when first line (meta) parse fails', async () => { mockAdapter.listFiles.mockResolvedValue(['.claude/sessions/bad-meta.jsonl']); mockAdapter.read.mockResolvedValue('not valid json at all'); const metas = await storage.listConversations(); expect(metas).toEqual([]); }); }); describe('toSessionMetadata - extractSubagentData', () => { it('extracts subagent data from Task toolCalls', () => { const conversation: Conversation = { id: 'conv-subagent', title: 'Subagent Test', createdAt: 1700000000, updatedAt: 1700001000, sessionId: 'sdk-session', messages: [ { id: 'msg-1', role: 'user', content: 'Hello', timestamp: 1700000100 }, { id: 'msg-2', role: 'assistant', content: 'Working...', timestamp: 1700000200, toolCalls: [ { id: 'task-1', name: 'Task', input: { description: 'Test subagent' }, status: 'completed', result: 'Done', subagent: { id: 'task-1', description: 'Test subagent', isExpanded: false, status: 'completed' as const, toolCalls: [], result: 'Done', }, }, ], }, ], }; const metadata = storage.toSessionMetadata(conversation); expect(metadata.subagentData).toBeDefined(); expect(metadata.subagentData!['task-1']).toEqual(expect.objectContaining({ id: 'task-1', description: 'Test subagent', status: 'completed', })); }); it('returns undefined subagentData when no subagents present', () => { const conversation: Conversation = { id: 'conv-no-subagent', title: 'No Subagent', createdAt: 1700000000, updatedAt: 1700001000, sessionId: null, messages: [ { id: 'msg-1', role: 'user', content: 'Hello', timestamp: 1700000100 }, { id: 'msg-2', role: 'assistant', content: 'Hi!', timestamp: 1700000200 }, ], }; const metadata = storage.toSessionMetadata(conversation); expect(metadata.subagentData).toBeUndefined(); }); it('ignores Task toolCalls without linked subagent', () => { const conversation: Conversation = { id: 'conv-task-subagent', title: 'Task Subagent Test', createdAt: 1700000000, updatedAt: 1700001000, sessionId: 'sdk-session', messages: [ { id: 'msg-1', role: 'assistant', content: '', timestamp: 1700000200, toolCalls: [ { id: 'task-1', name: 'Task', input: { description: 'Background task', run_in_background: true }, status: 'completed', result: 'Task running', } as any, ], }, ], }; const metadata = storage.toSessionMetadata(conversation); expect(metadata.subagentData).toBeUndefined(); }); }); describe('toSessionMetadata - resumeSessionAt', () => { it('includes resumeSessionAt when set', () => { const conversation: Conversation = { id: 'conv-rewind', title: 'Rewind Test', createdAt: 1700000000, updatedAt: 1700001000, sessionId: 'sdk-session', messages: [], resumeSessionAt: 'assistant-uuid-123', }; const metadata = storage.toSessionMetadata(conversation); expect(metadata.resumeSessionAt).toBe('assistant-uuid-123'); }); it('omits resumeSessionAt when not set', () => { const conversation: Conversation = { id: 'conv-no-rewind', title: 'No Rewind', createdAt: 1700000000, updatedAt: 1700001000, sessionId: null, messages: [], }; const metadata = storage.toSessionMetadata(conversation); expect(metadata.resumeSessionAt).toBeUndefined(); }); }); describe('toSessionMetadata', () => { it('converts Conversation to SessionMetadata', () => { const usage: UsageInfo = { model: 'claude-opus-4-5', inputTokens: 5000, cacheCreationInputTokens: 1000, cacheReadInputTokens: 500, contextWindow: 200000, contextTokens: 6500, percentage: 3, }; const conversation: Conversation = { id: 'conv-convert', title: 'Convert Test', createdAt: 1700000000, updatedAt: 1700001000, lastResponseAt: 1700000900, sessionId: 'sdk-session', sdkSessionId: 'current-sdk-session', messages: [ { id: 'msg-1', role: 'user', content: 'Hello', timestamp: 1700000100 }, ], currentNote: 'notes/test.md', externalContextPaths: ['/external/path'], enabledMcpServers: ['mcp-server'], usage, titleGenerationStatus: 'success', legacyCutoffAt: 1700000050, }; const metadata = storage.toSessionMetadata(conversation); expect(metadata.id).toBe('conv-convert'); expect(metadata.title).toBe('Convert Test'); expect(metadata.createdAt).toBe(1700000000); expect(metadata.updatedAt).toBe(1700001000); expect(metadata.lastResponseAt).toBe(1700000900); expect(metadata.sessionId).toBe('sdk-session'); expect(metadata.sdkSessionId).toBe('current-sdk-session'); expect(metadata.legacyCutoffAt).toBe(1700000050); expect(metadata.currentNote).toBe('notes/test.md'); expect(metadata.externalContextPaths).toEqual(['/external/path']); expect(metadata.enabledMcpServers).toEqual(['mcp-server']); expect(metadata.usage).toEqual(usage); expect(metadata.titleGenerationStatus).toBe('success'); // Should not include messages expect(metadata).not.toHaveProperty('messages'); }); it('includes forkSource when set', () => { const conversation: Conversation = { id: 'conv-fork', title: 'Fork Test', createdAt: 1700000000, updatedAt: 1700001000, sessionId: null, messages: [], forkSource: { sessionId: 'source-session-abc', resumeAt: 'asst-uuid-xyz' }, }; const metadata = storage.toSessionMetadata(conversation); expect(metadata.forkSource).toEqual({ sessionId: 'source-session-abc', resumeAt: 'asst-uuid-xyz', }); }); it('omits forkSource when not set', () => { const conversation: Conversation = { id: 'conv-no-fork', title: 'No Fork', createdAt: 1700000000, updatedAt: 1700001000, sessionId: 'sdk-session', messages: [], }; const metadata = storage.toSessionMetadata(conversation); expect(metadata.forkSource).toBeUndefined(); }); }); }); ================================================ FILE: tests/unit/core/storage/SkillStorage.test.ts ================================================ import { SKILLS_PATH,SkillStorage } from '@/core/storage/SkillStorage'; import type { VaultFileAdapter } from '@/core/storage/VaultFileAdapter'; function createMockAdapter(files: Record<string, string> = {}): VaultFileAdapter { const mockAdapter = { exists: jest.fn(async (path: string) => path in files || Object.keys(files).some(k => k.startsWith(path + '/'))), read: jest.fn(async (path: string) => { if (!(path in files)) throw new Error(`File not found: ${path}`); return files[path]; }), write: jest.fn(), delete: jest.fn(), listFolders: jest.fn(async (folder: string) => { const prefix = folder.endsWith('/') ? folder : folder + '/'; const folders = new Set<string>(); for (const path of Object.keys(files)) { if (path.startsWith(prefix)) { const rest = path.slice(prefix.length); const firstSlash = rest.indexOf('/'); if (firstSlash >= 0) { folders.add(prefix + rest.slice(0, firstSlash)); } } } return Array.from(folders); }), listFiles: jest.fn(), listFilesRecursive: jest.fn(), ensureFolder: jest.fn(), rename: jest.fn(), append: jest.fn(), stat: jest.fn(), deleteFolder: jest.fn(), } as unknown as VaultFileAdapter; return mockAdapter; } describe('SkillStorage', () => { it('exports SKILLS_PATH', () => { expect(SKILLS_PATH).toBe('.claude/skills'); }); describe('loadAll', () => { it('loads skills from subdirectories with SKILL.md', async () => { const adapter = createMockAdapter({ '.claude/skills/my-skill/SKILL.md': `--- description: A helpful skill userInvocable: true --- Do the thing`, }); const storage = new SkillStorage(adapter); const skills = await storage.loadAll(); expect(skills).toHaveLength(1); expect(skills[0].id).toBe('skill-my-skill'); expect(skills[0].name).toBe('my-skill'); expect(skills[0].description).toBe('A helpful skill'); expect(skills[0].userInvocable).toBe(true); expect(skills[0].content).toBe('Do the thing'); expect(skills[0].source).toBe('user'); }); it('loads multiple skills', async () => { const adapter = createMockAdapter({ '.claude/skills/skill-a/SKILL.md': `--- description: Skill A --- Prompt A`, '.claude/skills/skill-b/SKILL.md': `--- description: Skill B disableModelInvocation: true --- Prompt B`, }); const storage = new SkillStorage(adapter); const skills = await storage.loadAll(); expect(skills).toHaveLength(2); expect(skills.map(s => s.name).sort()).toEqual(['skill-a', 'skill-b']); }); it('skips folders without SKILL.md', async () => { const adapter = createMockAdapter({ '.claude/skills/has-skill/SKILL.md': `--- description: Valid --- Prompt`, '.claude/skills/no-skill/README.md': 'Just a readme', }); const storage = new SkillStorage(adapter); const skills = await storage.loadAll(); expect(skills).toHaveLength(1); expect(skills[0].name).toBe('has-skill'); }); it('returns empty array when skills directory does not exist', async () => { const adapter = createMockAdapter({}); (adapter.exists as jest.Mock).mockResolvedValue(false); const storage = new SkillStorage(adapter); const skills = await storage.loadAll(); expect(skills).toEqual([]); }); it('returns empty array when listFolders throws an error', async () => { const adapter = createMockAdapter({}); (adapter.listFolders as jest.Mock).mockRejectedValue(new Error('Permission denied')); const storage = new SkillStorage(adapter); const skills = await storage.loadAll(); expect(skills).toEqual([]); }); it('skips malformed skill and continues loading valid ones', async () => { const adapter = createMockAdapter({ '.claude/skills/good/SKILL.md': `--- description: Valid --- Prompt`, '.claude/skills/bad/SKILL.md': 'content', }); const originalRead = adapter.read as jest.Mock; const originalImpl = originalRead.getMockImplementation()!; originalRead.mockImplementation(async (p: string) => { if (p.includes('bad')) throw new Error('Corrupt file'); return originalImpl(p); }); const storage = new SkillStorage(adapter); const skills = await storage.loadAll(); expect(skills).toHaveLength(1); expect(skills[0].name).toBe('good'); }); it('parses all skill frontmatter fields', async () => { const adapter = createMockAdapter({ '.claude/skills/full/SKILL.md': `--- description: Full skill disableModelInvocation: true userInvocable: true context: fork agent: code-reviewer model: sonnet allowed-tools: - Read - Grep --- Full prompt`, }); const storage = new SkillStorage(adapter); const skills = await storage.loadAll(); expect(skills).toHaveLength(1); const skill = skills[0]; expect(skill.description).toBe('Full skill'); expect(skill.disableModelInvocation).toBe(true); expect(skill.userInvocable).toBe(true); expect(skill.context).toBe('fork'); expect(skill.agent).toBe('code-reviewer'); expect(skill.model).toBe('sonnet'); expect(skill.allowedTools).toEqual(['Read', 'Grep']); expect(skill.content).toBe('Full prompt'); }); it('loads skills without frontmatter as content-only', async () => { const adapter = createMockAdapter({ '.claude/skills/valid/SKILL.md': `--- description: Valid --- Prompt`, '.claude/skills/invalid/SKILL.md': 'No frontmatter at all', }); const storage = new SkillStorage(adapter); const skills = await storage.loadAll(); // Invalid skill has no frontmatter but still loads (content only) expect(skills).toHaveLength(2); }); }); describe('save', () => { it('writes skill to correct path', async () => { const adapter = createMockAdapter({}); const storage = new SkillStorage(adapter); await storage.save({ id: 'skill-my-skill', name: 'my-skill', description: 'A skill', content: 'Do the thing', }); expect(adapter.ensureFolder).toHaveBeenCalledWith('.claude/skills/my-skill'); expect(adapter.write).toHaveBeenCalledWith( '.claude/skills/my-skill/SKILL.md', expect.stringContaining('description: A skill') ); }); it('serializes hooks field', async () => { const adapter = createMockAdapter({}); const storage = new SkillStorage(adapter); const hooks = { PreToolUse: [{ matcher: 'Bash' }] }; await storage.save({ id: 'skill-hooked', name: 'hooked', content: 'prompt', hooks, }); const written = (adapter.write as jest.Mock).mock.calls[0][1] as string; expect(written).toContain('hooks: '); expect(written).toContain(JSON.stringify(hooks)); }); it('serializes skill fields in kebab-case', async () => { const adapter = createMockAdapter({}); const storage = new SkillStorage(adapter); await storage.save({ id: 'skill-kebab', name: 'kebab', description: 'Kebab test', content: 'prompt', disableModelInvocation: true, userInvocable: false, context: 'fork', agent: 'code-reviewer', }); const written = (adapter.write as jest.Mock).mock.calls[0][1] as string; expect(written).toContain('disable-model-invocation: true'); expect(written).toContain('user-invocable: false'); expect(written).toContain('context: fork'); expect(written).toContain('agent: code-reviewer'); // Should NOT contain camelCase variants expect(written).not.toContain('disableModelInvocation'); expect(written).not.toContain('userInvocable'); }); it('omits hooks when undefined', async () => { const adapter = createMockAdapter({}); const storage = new SkillStorage(adapter); await storage.save({ id: 'skill-no-hooks', name: 'no-hooks', content: 'prompt', }); const written = (adapter.write as jest.Mock).mock.calls[0][1] as string; expect(written).not.toContain('hooks:'); }); }); describe('delete', () => { it('deletes skill file and cleans up directory', async () => { const adapter = createMockAdapter({ '.claude/skills/target/SKILL.md': `--- description: Target --- Prompt`, }); const storage = new SkillStorage(adapter); await storage.delete('skill-target'); expect(adapter.delete).toHaveBeenCalledWith('.claude/skills/target/SKILL.md'); expect(adapter.deleteFolder).toHaveBeenCalledWith('.claude/skills/target'); }); }); }); ================================================ FILE: tests/unit/core/storage/SlashCommandStorage.test.ts ================================================ import { SlashCommandStorage } from '@/core/storage/SlashCommandStorage'; import type { VaultFileAdapter } from '@/core/storage/VaultFileAdapter'; import type { SlashCommand } from '@/core/types'; describe('SlashCommandStorage', () => { let mockAdapter: jest.Mocked<VaultFileAdapter>; let storage: SlashCommandStorage; const mockCommand1: SlashCommand = { id: 'cmd-review-code', name: 'review-code', description: 'Review code for issues', argumentHint: '[file] [focus]', allowedTools: ['Read', 'Grep'], model: 'claude-sonnet-4-5', content: 'Please review $ARGUMENTS for any issues.', }; const mockCommand2: SlashCommand = { id: 'cmd-test--coverage', name: 'test/coverage', description: 'Run test coverage', argumentHint: '[path]', allowedTools: ['Bash'], content: 'Run tests for $ARGUMENTS.', }; const validMarkdown = `--- description: Review code for issues argument-hint: "[file] [focus]" allowed-tools: - Read - Grep model: claude-sonnet-4-5 --- Please review $ARGUMENTS for any issues.`; const nestedMarkdown = `--- description: Run test coverage argument-hint: "[path]" allowed-tools: - Bash --- Run tests for $ARGUMENTS.`; beforeEach(() => { mockAdapter = { exists: jest.fn().mockResolvedValue(true), read: jest.fn(), write: jest.fn(), delete: jest.fn(), ensureFolder: jest.fn(), rename: jest.fn(), stat: jest.fn(), append: jest.fn(), listFiles: jest.fn(), listFolders: jest.fn(), listFilesRecursive: jest.fn(), } as unknown as jest.Mocked<VaultFileAdapter>; storage = new SlashCommandStorage(mockAdapter); }); describe('loadAll', () => { it('loads all commands from vault', async () => { mockAdapter.listFilesRecursive.mockResolvedValue([ '.claude/commands/review-code.md', '.claude/commands/test/coverage.md', '.claude/commands/deploy.sh', ]); mockAdapter.read .mockResolvedValueOnce(validMarkdown) .mockResolvedValueOnce(nestedMarkdown); const commands = await storage.loadAll(); expect(commands).toHaveLength(2); expect(commands[0].name).toBe('review-code'); expect(commands[1].name).toBe('test/coverage'); }); it('handles empty command folder', async () => { mockAdapter.listFilesRecursive.mockResolvedValue([]); const commands = await storage.loadAll(); expect(commands).toHaveLength(0); }); it('handles files that are not markdown', async () => { mockAdapter.listFilesRecursive.mockResolvedValue([ '.claude/commands/review-code.md', '.claude/commands/deploy.sh', '.claude/commands/config.json', ]); mockAdapter.read.mockResolvedValue(validMarkdown); const commands = await storage.loadAll(); expect(commands).toHaveLength(1); }); it('continues loading if one file fails', async () => { mockAdapter.listFilesRecursive.mockResolvedValue([ '.claude/commands/good.md', '.claude/commands/bad.md', '.claude/commands/good2.md', ]); mockAdapter.read .mockResolvedValueOnce(validMarkdown) .mockRejectedValueOnce(new Error('Read error')) .mockResolvedValueOnce(validMarkdown); const commands = await storage.loadAll(); expect(commands).toHaveLength(2); expect(mockAdapter.listFilesRecursive).toHaveBeenCalledTimes(1); }); it('handles listFilesRecursive error gracefully', async () => { mockAdapter.listFilesRecursive.mockRejectedValue(new Error('List error')); const commands = await storage.loadAll(); expect(commands).toHaveLength(0); }); it('handles deeply nested commands', async () => { mockAdapter.listFilesRecursive.mockResolvedValue([ '.claude/commands/level1/level2/level3/deep.md', ]); mockAdapter.read.mockResolvedValue(validMarkdown); const commands = await storage.loadAll(); expect(commands).toHaveLength(1); expect(commands[0].name).toBe('level1/level2/level3/deep'); }); }); describe('loading single files (tested through loadAll)', () => { it('loads a command from file path', async () => { mockAdapter.listFilesRecursive.mockResolvedValue(['.claude/commands/review-code.md']); mockAdapter.read.mockResolvedValue(validMarkdown); const commands = await storage.loadAll(); expect(commands).toHaveLength(1); expect(commands[0].name).toBe('review-code'); expect(commands[0].id).toBe('cmd-review-_code'); expect(commands[0].description).toBe('Review code for issues'); }); it('loads nested command correctly', async () => { mockAdapter.listFilesRecursive.mockResolvedValue(['.claude/commands/test/coverage.md']); mockAdapter.read.mockResolvedValue(nestedMarkdown); const commands = await storage.loadAll(); expect(commands).toHaveLength(1); expect(commands[0].name).toBe('test/coverage'); expect(commands[0].id).toBe('cmd-test--coverage'); }); it('handles command without optional fields', async () => { const simpleMarkdown = `--- description: Simple command --- Just a simple prompt with $ARGUMENTS.`; mockAdapter.listFilesRecursive.mockResolvedValue(['.claude/commands/simple.md']); mockAdapter.read.mockResolvedValue(simpleMarkdown); const commands = await storage.loadAll(); expect(commands).toHaveLength(1); expect(commands[0].description).toBe('Simple command'); expect(commands[0].allowedTools).toBeUndefined(); expect(commands[0].model).toBeUndefined(); }); it('passes through skill fields from frontmatter', async () => { const skillMarkdown = `--- description: A skill-like command disable-model-invocation: true user-invocable: false context: fork agent: code-reviewer --- Do the thing`; mockAdapter.listFilesRecursive.mockResolvedValue(['.claude/commands/skill-cmd.md']); mockAdapter.read.mockResolvedValue(skillMarkdown); const commands = await storage.loadAll(); expect(commands).toHaveLength(1); expect(commands[0].disableModelInvocation).toBe(true); expect(commands[0].userInvocable).toBe(false); expect(commands[0].context).toBe('fork'); expect(commands[0].agent).toBe('code-reviewer'); }); }); describe('save', () => { it('saves command to correct file path', async () => { await storage.save(mockCommand1); const expectedPath = '.claude/commands/review-code.md'; expect(mockAdapter.write).toHaveBeenCalledWith(expectedPath, expect.stringContaining('description: Review code for issues')); }); it('saves nested command to correct nested path', async () => { await storage.save(mockCommand2); const expectedPath = '.claude/commands/test/coverage.md'; expect(mockAdapter.write).toHaveBeenCalledWith(expectedPath, expect.stringContaining('description: Run test coverage')); }); it('serializes command with all fields', async () => { await storage.save(mockCommand1); expect(mockAdapter.write).toHaveBeenCalledWith( '.claude/commands/review-code.md', expect.stringContaining('---') ); expect(mockAdapter.write).toHaveBeenCalledWith( '.claude/commands/review-code.md', expect.stringContaining('description: Review code for issues') ); expect(mockAdapter.write).toHaveBeenCalledWith( '.claude/commands/review-code.md', expect.stringContaining('argument-hint: "[file] [focus]"') ); expect(mockAdapter.write).toHaveBeenCalledWith( '.claude/commands/review-code.md', expect.stringContaining('allowed-tools:') ); expect(mockAdapter.write).toHaveBeenCalledWith( '.claude/commands/review-code.md', expect.stringContaining('model: claude-sonnet-4-5') ); expect(mockAdapter.write).toHaveBeenCalledWith( '.claude/commands/review-code.md', expect.stringContaining('Please review $ARGUMENTS') ); }); it('handles special characters in description', async () => { const commandWithSpecial: SlashCommand = { ...mockCommand1, description: 'Test: value with # and spaces', }; await storage.save(commandWithSpecial); expect(mockAdapter.write).toHaveBeenCalledWith( expect.anything(), expect.stringContaining('description: "Test: value with # and spaces"') ); }); it('handles multiline descriptions', async () => { const commandWithMultiline: SlashCommand = { ...mockCommand1, description: 'Line1\nLine2\nLine3', }; await storage.save(commandWithMultiline); expect(mockAdapter.write).toHaveBeenCalledWith( expect.anything(), expect.stringContaining('description: "Line1\nLine2\nLine3"') ); }); it('sanitizes command name when generating path', async () => { const commandWithInvalidName: SlashCommand = { ...mockCommand1, name: 'test command!@#', }; await storage.save(commandWithInvalidName); expect(mockAdapter.write).toHaveBeenCalledWith( '.claude/commands/test-command---.md', expect.anything() ); }); it('preserves slashes for deeply nested command path', async () => { const command: SlashCommand = { ...mockCommand1, name: 'level1/level2/level3', }; await storage.save(command); expect(mockAdapter.write).toHaveBeenCalledWith( '.claude/commands/level1/level2/level3.md', expect.anything() ); }); it('serializes skill fields in kebab-case', async () => { const command: SlashCommand = { ...mockCommand1, disableModelInvocation: true, userInvocable: false, context: 'fork', agent: 'code-reviewer', }; await storage.save(command); const written = mockAdapter.write.mock.calls[0][1] as string; expect(written).toContain('disable-model-invocation: true'); expect(written).toContain('user-invocable: false'); expect(written).toContain('context: fork'); expect(written).toContain('agent: code-reviewer'); // Should NOT contain camelCase variants expect(written).not.toContain('disableModelInvocation'); expect(written).not.toContain('userInvocable'); }); it('omits skill fields when undefined', async () => { await storage.save(mockCommand1); const written = mockAdapter.write.mock.calls[0][1] as string; expect(written).not.toContain('disable-model-invocation'); expect(written).not.toContain('user-invocable'); expect(written).not.toContain('context'); expect(written).not.toContain('agent'); expect(written).not.toContain('hooks'); }); it('serializes hooks as JSON', async () => { const hooks = { PreToolUse: [{ matcher: 'Bash' }] }; const command: SlashCommand = { ...mockCommand1, hooks, }; await storage.save(command); const written = mockAdapter.write.mock.calls[0][1] as string; expect(written).toContain(`hooks: ${JSON.stringify(hooks)}`); }); it('round-trips skill fields through save and load', async () => { const command: SlashCommand = { id: 'cmd-roundtrip', name: 'roundtrip', description: 'Round trip test', content: 'Do the thing', disableModelInvocation: true, userInvocable: false, context: 'fork', agent: 'code-reviewer', }; await storage.save(command); const written = mockAdapter.write.mock.calls[0][1] as string; mockAdapter.read.mockResolvedValue(written); mockAdapter.listFilesRecursive.mockResolvedValue(['.claude/commands/roundtrip.md']); const loaded = await storage.loadAll(); expect(loaded).toHaveLength(1); expect(loaded[0].disableModelInvocation).toBe(true); expect(loaded[0].userInvocable).toBe(false); expect(loaded[0].context).toBe('fork'); expect(loaded[0].agent).toBe('code-reviewer'); expect(loaded[0].content).toBe('Do the thing'); }); }); describe('delete', () => { it('deletes command by ID', async () => { mockAdapter.listFilesRecursive.mockResolvedValue([ '.claude/commands/review-code.md', '.claude/commands/test/coverage.md', ]); await storage.delete('cmd-review-_code'); expect(mockAdapter.delete).toHaveBeenCalledWith('.claude/commands/review-code.md'); }); it('deletes nested command by ID', async () => { mockAdapter.listFilesRecursive.mockResolvedValue([ '.claude/commands/test/coverage.md', ]); await storage.delete('cmd-test--coverage'); expect(mockAdapter.delete).toHaveBeenCalledWith('.claude/commands/test/coverage.md'); }); it('handles command with dashes in name', async () => { mockAdapter.listFilesRecursive.mockResolvedValue([ '.claude/commands/test-command.md', ]); await storage.delete('cmd-test-_command'); expect(mockAdapter.delete).toHaveBeenCalledWith('.claude/commands/test-command.md'); }); it('does nothing if command ID not found', async () => { mockAdapter.listFilesRecursive.mockResolvedValue([ '.claude/commands/review-code.md', ]); await storage.delete('cmd-nonexistent'); expect(mockAdapter.delete).not.toHaveBeenCalled(); }); it('handles list error gracefully', async () => { mockAdapter.listFilesRecursive.mockRejectedValue(new Error('List error')); await expect(storage.delete('cmd-test')).rejects.toThrow('List error'); expect(mockAdapter.delete).not.toHaveBeenCalled(); }); it('handles non-markdown files', async () => { mockAdapter.listFilesRecursive.mockResolvedValue([ '.claude/commands/config.json', ]); await storage.delete('cmd-config'); expect(mockAdapter.delete).not.toHaveBeenCalled(); }); }); describe('filePathToId (private method tested through loadAll)', () => { it('encodes simple path correctly', async () => { mockAdapter.listFilesRecursive.mockResolvedValue(['.claude/commands/test.md']); mockAdapter.read.mockResolvedValue(validMarkdown); const commands = await storage.loadAll(); expect(commands[0].id).toBe('cmd-test'); }); it('encodes path with slashes correctly', async () => { mockAdapter.listFilesRecursive.mockResolvedValue(['.claude/commands/a/b.md']); mockAdapter.read.mockResolvedValue(validMarkdown); const commands = await storage.loadAll(); expect(commands[0].id).toBe('cmd-a--b'); }); it('encodes path with dashes correctly', async () => { mockAdapter.listFilesRecursive.mockResolvedValue(['.claude/commands/a-b.md']); mockAdapter.read.mockResolvedValue(validMarkdown); const commands = await storage.loadAll(); expect(commands[0].id).toBe('cmd-a-_b'); }); it('encodes path with both slashes and dashes correctly', async () => { mockAdapter.listFilesRecursive.mockResolvedValue(['.claude/commands/a/b-c.md']); mockAdapter.read.mockResolvedValue(validMarkdown); const commands = await storage.loadAll(); expect(commands[0].id).toBe('cmd-a--b-_c'); }); it('encodes path with double dashes correctly', async () => { mockAdapter.listFilesRecursive.mockResolvedValue(['.claude/commands/a--b.md']); mockAdapter.read.mockResolvedValue(validMarkdown); const commands = await storage.loadAll(); expect(commands[0].id).toBe('cmd-a-_-_b'); }); }); describe('filePathToName (private method tested through loadAll)', () => { it('extracts name from simple path', async () => { mockAdapter.listFilesRecursive.mockResolvedValue(['.claude/commands/test.md']); mockAdapter.read.mockResolvedValue(validMarkdown); const commands = await storage.loadAll(); expect(commands[0].name).toBe('test'); }); it('extracts name from nested path', async () => { mockAdapter.listFilesRecursive.mockResolvedValue(['.claude/commands/a/b/c.md']); mockAdapter.read.mockResolvedValue(validMarkdown); const commands = await storage.loadAll(); expect(commands[0].name).toBe('a/b/c'); }); }); describe('yamlString (private method tested through save)', () => { it('quotes strings with colons', async () => { const command: SlashCommand = { ...mockCommand1, description: 'key:value', }; await storage.save(command); expect(mockAdapter.write).toHaveBeenCalledWith( expect.anything(), expect.stringContaining('description: "key:value"') ); }); it('quotes strings starting with space', async () => { const command: SlashCommand = { ...mockCommand1, description: ' spaced', }; await storage.save(command); expect(mockAdapter.write).toHaveBeenCalledWith( expect.anything(), expect.stringContaining('description: " spaced"') ); }); it('quotes strings ending with space', async () => { const command: SlashCommand = { ...mockCommand1, description: 'spaced ', }; await storage.save(command); expect(mockAdapter.write).toHaveBeenCalledWith( expect.anything(), expect.stringContaining('description: "spaced "') ); }); it('handles strings with quotes', async () => { const command: SlashCommand = { ...mockCommand1, description: 'text with "quotes"', }; await storage.save(command); expect(mockAdapter.write).toHaveBeenCalledWith( expect.anything(), expect.stringContaining('description: text with "quotes"') ); }); it('does not quote simple strings', async () => { await storage.save(mockCommand1); expect(mockAdapter.write).toHaveBeenCalledWith( expect.anything(), expect.stringContaining('description: Review code for issues') ); }); }); describe('empty metadata handling', () => { it('adds blank line in frontmatter when no metadata exists', async () => { const commandNoMetadata: SlashCommand = { id: 'cmd-simple', name: 'simple', content: 'Just a prompt', }; await storage.save(commandNoMetadata); const writeCall = mockAdapter.write.mock.calls[0]; const writtenContent = writeCall[1] as string; expect(writtenContent).toBe('---\nname: simple\n---\nJust a prompt'); }); it('produces parseable frontmatter even with no metadata', async () => { const commandNoMetadata: SlashCommand = { id: 'cmd-simple', name: 'simple', content: 'Just a prompt', }; await storage.save(commandNoMetadata); const writeCall = mockAdapter.write.mock.calls[0]; const writtenContent = writeCall[1] as string; // Simulate loading it back - should parse correctly mockAdapter.read.mockResolvedValue(writtenContent); mockAdapter.listFilesRecursive.mockResolvedValue(['.claude/commands/simple.md']); const loaded = await storage.loadAll(); expect(loaded).toHaveLength(1); expect(loaded[0].content).toBe('Just a prompt'); }); it('does not add extra blank line when metadata exists', async () => { await storage.save(mockCommand1); const writeCall = mockAdapter.write.mock.calls[0]; const writtenContent = writeCall[1] as string; // Should not have double newlines between description and --- expect(writtenContent).not.toMatch(/description: .*\n\n---/); }); }); }); ================================================ FILE: tests/unit/core/storage/VaultFileAdapter.test.ts ================================================ import type { App } from 'obsidian'; import { VaultFileAdapter } from '@/core/storage/VaultFileAdapter'; describe('VaultFileAdapter', () => { let mockAdapter: jest.Mocked<any>; let vaultAdapter: VaultFileAdapter; const mockApp: Partial<App> = { vault: {} as any, }; beforeEach(() => { mockAdapter = { exists: jest.fn(), read: jest.fn(), write: jest.fn(), remove: jest.fn(), rename: jest.fn(), list: jest.fn(), mkdir: jest.fn(), stat: jest.fn(), }; mockApp.vault = { adapter: mockAdapter } as any; vaultAdapter = new VaultFileAdapter(mockApp as App); }); describe('exists', () => { it('delegates to vault adapter', async () => { mockAdapter.exists.mockResolvedValue(true); const result = await vaultAdapter.exists('test/path.md'); expect(result).toBe(true); expect(mockAdapter.exists).toHaveBeenCalledWith('test/path.md'); }); it('delegates to vault adapter with false', async () => { mockAdapter.exists.mockResolvedValue(false); const result = await vaultAdapter.exists('test/path.md'); expect(result).toBe(false); }); }); describe('read', () => { it('delegates to vault adapter', async () => { mockAdapter.read.mockResolvedValue('file content'); const result = await vaultAdapter.read('test/path.md'); expect(result).toBe('file content'); expect(mockAdapter.read).toHaveBeenCalledWith('test/path.md'); }); }); describe('write', () => { it('writes file when folder exists', async () => { mockAdapter.exists.mockResolvedValue(true); mockAdapter.write.mockResolvedValue(); await vaultAdapter.write('folder/file.md', 'content'); expect(mockAdapter.exists).toHaveBeenCalledWith('folder'); expect(mockAdapter.write).toHaveBeenCalledWith('folder/file.md', 'content'); expect(mockAdapter.mkdir).not.toHaveBeenCalled(); }); it('creates parent folder when it does not exist', async () => { mockAdapter.exists.mockImplementation((path: string) => Promise.resolve(path !== 'folder')); mockAdapter.mkdir.mockResolvedValue(); mockAdapter.write.mockResolvedValue(); await vaultAdapter.write('folder/file.md', 'content'); expect(mockAdapter.mkdir).toHaveBeenCalledWith('folder'); expect(mockAdapter.write).toHaveBeenCalledWith('folder/file.md', 'content'); }); it('handles file in root (no folder)', async () => { mockAdapter.write.mockResolvedValue(); await vaultAdapter.write('file.md', 'content'); expect(mockAdapter.exists).not.toHaveBeenCalled(); expect(mockAdapter.mkdir).not.toHaveBeenCalled(); expect(mockAdapter.write).toHaveBeenCalledWith('file.md', 'content'); }); it('handles deeply nested paths', async () => { mockAdapter.exists.mockResolvedValue(false); mockAdapter.mkdir.mockResolvedValue(); mockAdapter.write.mockResolvedValue(); await vaultAdapter.write('level1/level2/level3/file.md', 'content'); expect(mockAdapter.mkdir).toHaveBeenCalledWith('level1'); expect(mockAdapter.mkdir).toHaveBeenCalledWith('level1/level2'); expect(mockAdapter.mkdir).toHaveBeenCalledWith('level1/level2/level3'); expect(mockAdapter.write).toHaveBeenCalledWith('level1/level2/level3/file.md', 'content'); }); }); describe('append', () => { it('creates new file if it does not exist', async () => { // All existence checks return false: folder doesn't exist, file doesn't exist mockAdapter.exists.mockResolvedValue(false); mockAdapter.mkdir.mockResolvedValue(); mockAdapter.write.mockResolvedValue(); await vaultAdapter.append('folder/file.md', 'new content'); expect(mockAdapter.mkdir).toHaveBeenCalled(); expect(mockAdapter.write).toHaveBeenCalledWith('folder/file.md', 'new content'); expect(mockAdapter.read).not.toHaveBeenCalled(); }); it('appends to existing file', async () => { mockAdapter.exists.mockResolvedValue(true); mockAdapter.read.mockResolvedValue('existing content'); mockAdapter.write.mockResolvedValue(); await vaultAdapter.append('file.md', '\nmore content'); expect(mockAdapter.read).toHaveBeenCalledWith('file.md'); expect(mockAdapter.write).toHaveBeenCalledWith('file.md', 'existing content\nmore content'); expect(mockAdapter.mkdir).not.toHaveBeenCalled(); }); it('creates parent folder for new file', async () => { mockAdapter.exists.mockResolvedValueOnce(false).mockResolvedValueOnce(false); mockAdapter.mkdir.mockResolvedValue(); mockAdapter.write.mockResolvedValue(); await vaultAdapter.append('folder/file.md', 'content'); expect(mockAdapter.mkdir).toHaveBeenCalledWith('folder'); }); it('handles file in root', async () => { mockAdapter.write.mockResolvedValue(); await vaultAdapter.append('file.md', 'content'); expect(mockAdapter.mkdir).not.toHaveBeenCalled(); expect(mockAdapter.write).toHaveBeenCalledWith('file.md', 'content'); }); it('appends empty string', async () => { mockAdapter.exists.mockResolvedValue(true); mockAdapter.read.mockResolvedValue('existing'); mockAdapter.write.mockResolvedValue(); await vaultAdapter.append('file.md', ''); expect(mockAdapter.write).toHaveBeenCalledWith('file.md', 'existing'); }); }); describe('delete', () => { it('deletes file when it exists', async () => { mockAdapter.exists.mockResolvedValue(true); mockAdapter.remove.mockResolvedValue(); await vaultAdapter.delete('file.md'); expect(mockAdapter.exists).toHaveBeenCalledWith('file.md'); expect(mockAdapter.remove).toHaveBeenCalledWith('file.md'); }); it('does nothing when file does not exist', async () => { mockAdapter.exists.mockResolvedValue(false); await vaultAdapter.delete('file.md'); expect(mockAdapter.exists).toHaveBeenCalledWith('file.md'); expect(mockAdapter.remove).not.toHaveBeenCalled(); }); it('deletes nested file', async () => { mockAdapter.exists.mockResolvedValue(true); mockAdapter.remove.mockResolvedValue(); await vaultAdapter.delete('folder/subfolder/file.md'); expect(mockAdapter.remove).toHaveBeenCalledWith('folder/subfolder/file.md'); }); }); describe('deleteFolder', () => { it('deletes folder when it exists', async () => { mockAdapter.exists.mockResolvedValue(true); mockAdapter.rmdir = jest.fn().mockResolvedValue(undefined); await vaultAdapter.deleteFolder('empty-folder'); expect(mockAdapter.exists).toHaveBeenCalledWith('empty-folder'); expect(mockAdapter.rmdir).toHaveBeenCalledWith('empty-folder', false); }); it('does nothing when folder does not exist', async () => { mockAdapter.exists.mockResolvedValue(false); mockAdapter.rmdir = jest.fn(); await vaultAdapter.deleteFolder('nonexistent-folder'); expect(mockAdapter.rmdir).not.toHaveBeenCalled(); }); it('silently handles rmdir error (non-empty folder)', async () => { mockAdapter.exists.mockResolvedValue(true); mockAdapter.rmdir = jest.fn().mockRejectedValue(new Error('Directory not empty')); await expect(vaultAdapter.deleteFolder('non-empty-folder')).resolves.toBeUndefined(); }); }); describe('listFiles', () => { it('lists files in existing folder', async () => { mockAdapter.exists.mockResolvedValue(true); mockAdapter.list.mockResolvedValue({ files: ['file1.md', 'file2.md'], folders: ['subfolder'], }); const result = await vaultAdapter.listFiles('folder'); expect(result).toEqual(['file1.md', 'file2.md']); expect(mockAdapter.list).toHaveBeenCalledWith('folder'); }); it('returns empty array when folder does not exist', async () => { mockAdapter.exists.mockResolvedValue(false); const result = await vaultAdapter.listFiles('folder'); expect(result).toEqual([]); expect(mockAdapter.list).not.toHaveBeenCalled(); }); it('returns empty array when no files exist', async () => { mockAdapter.exists.mockResolvedValue(true); mockAdapter.list.mockResolvedValue({ files: [], folders: [], }); const result = await vaultAdapter.listFiles('folder'); expect(result).toEqual([]); }); it('handles folder with only subfolders', async () => { mockAdapter.exists.mockResolvedValue(true); mockAdapter.list.mockResolvedValue({ files: [], folders: ['sub1', 'sub2'], }); const result = await vaultAdapter.listFiles('folder'); expect(result).toEqual([]); }); }); describe('listFolders', () => { it('lists folders in existing directory', async () => { mockAdapter.exists.mockResolvedValue(true); mockAdapter.list.mockResolvedValue({ files: ['file.md'], folders: ['folder1', 'folder2'], }); const result = await vaultAdapter.listFolders('folder'); expect(result).toEqual(['folder1', 'folder2']); }); it('returns empty array when folder does not exist', async () => { mockAdapter.exists.mockResolvedValue(false); const result = await vaultAdapter.listFolders('folder'); expect(result).toEqual([]); expect(mockAdapter.list).not.toHaveBeenCalled(); }); it('returns empty array when no folders exist', async () => { mockAdapter.exists.mockResolvedValue(true); mockAdapter.list.mockResolvedValue({ files: ['file.md'], folders: [], }); const result = await vaultAdapter.listFolders('folder'); expect(result).toEqual([]); }); }); describe('listFilesRecursive', () => { it('lists all files in nested structure', async () => { const mockList = jest.fn(); mockList .mockResolvedValueOnce({ files: ['root.md'], folders: ['folder1', 'folder2'] }) .mockResolvedValueOnce({ files: ['folder1/f1.md'], folders: ['folder1/sub'] }) .mockResolvedValueOnce({ files: ['folder1/sub/f2.md'], folders: [] }) .mockResolvedValueOnce({ files: ['folder2/f3.md'], folders: [] }); mockAdapter.exists.mockResolvedValue(true); mockAdapter.list.mockImplementation((path: string) => mockList(path)); const result = await vaultAdapter.listFilesRecursive('root'); expect(result).toEqual([ 'root.md', 'folder1/f1.md', 'folder1/sub/f2.md', 'folder2/f3.md', ]); }); it('returns empty array for non-existent folder', async () => { mockAdapter.exists.mockResolvedValue(false); const result = await vaultAdapter.listFilesRecursive('nonexistent'); expect(result).toEqual([]); }); it('handles empty folder', async () => { mockAdapter.exists.mockResolvedValue(true); mockAdapter.list.mockResolvedValue({ files: [], folders: [] }); const result = await vaultAdapter.listFilesRecursive('empty'); expect(result).toEqual([]); }); it('handles folder with only subfolders and no files', async () => { mockAdapter.exists.mockResolvedValue(true); const mockList = jest.fn(); mockList .mockResolvedValueOnce({ files: [], folders: ['sub'] }) .mockResolvedValueOnce({ files: [], folders: [] }); mockAdapter.list.mockImplementation((path: string) => mockList(path)); const result = await vaultAdapter.listFilesRecursive('root'); expect(result).toEqual([]); }); it('handles deeply nested structure', async () => { mockAdapter.exists.mockResolvedValue(true); const mockList = jest.fn(); mockList .mockResolvedValueOnce({ files: ['a.txt'], folders: ['b'] }) .mockResolvedValueOnce({ files: ['b/b.txt'], folders: ['b/c'] }) .mockResolvedValueOnce({ files: ['b/c/c.txt'], folders: ['b/c/d'] }) .mockResolvedValueOnce({ files: ['b/c/d/d.txt'], folders: [] }); mockAdapter.list.mockImplementation((path: string) => mockList(path)); const result = await vaultAdapter.listFilesRecursive('root'); expect(result).toHaveLength(4); expect(result).toContain('a.txt'); expect(result).toContain('b/b.txt'); expect(result).toContain('b/c/c.txt'); expect(result).toContain('b/c/d/d.txt'); }); it('handles multiple subfolders at same level', async () => { mockAdapter.exists.mockResolvedValue(true); const mockList = jest.fn(); mockList .mockResolvedValueOnce({ files: ['root.md'], folders: ['a', 'b', 'c'] }) .mockResolvedValueOnce({ files: ['a/a.txt'], folders: [] }) .mockResolvedValueOnce({ files: ['b/b.txt'], folders: [] }) .mockResolvedValueOnce({ files: ['c/c.txt'], folders: [] }); mockAdapter.list.mockImplementation((path: string) => mockList(path)); const result = await vaultAdapter.listFilesRecursive('root'); expect(result).toHaveLength(4); }); }); describe('ensureFolder', () => { it('returns early when folder exists', async () => { mockAdapter.exists.mockResolvedValue(true); await vaultAdapter.ensureFolder('existing/folder'); expect(mockAdapter.exists).toHaveBeenCalledWith('existing/folder'); expect(mockAdapter.mkdir).not.toHaveBeenCalled(); }); it('creates folder when it does not exist', async () => { mockAdapter.exists.mockResolvedValueOnce(false); mockAdapter.mkdir.mockResolvedValue(); await vaultAdapter.ensureFolder('new/folder'); expect(mockAdapter.exists).toHaveBeenCalledWith('new/folder'); expect(mockAdapter.mkdir).toHaveBeenCalledWith('new/folder'); }); it('creates nested folders', async () => { mockAdapter.exists.mockResolvedValue(false); mockAdapter.mkdir.mockResolvedValue(); await vaultAdapter.ensureFolder('a/b/c'); expect(mockAdapter.mkdir).toHaveBeenCalledTimes(3); expect(mockAdapter.mkdir).toHaveBeenCalledWith('a'); expect(mockAdapter.mkdir).toHaveBeenCalledWith('a/b'); expect(mockAdapter.mkdir).toHaveBeenCalledWith('a/b/c'); }); it('handles folder with trailing slash', async () => { mockAdapter.exists.mockResolvedValueOnce(false); mockAdapter.mkdir.mockResolvedValue(); await vaultAdapter.ensureFolder('folder/'); expect(mockAdapter.mkdir).toHaveBeenCalledWith('folder'); }); it('handles root folder', async () => { mockAdapter.exists.mockResolvedValueOnce(false); mockAdapter.mkdir.mockResolvedValue(); await vaultAdapter.ensureFolder('folder'); expect(mockAdapter.mkdir).toHaveBeenCalledWith('folder'); }); it('skips creating intermediate folders that exist', async () => { mockAdapter.exists.mockImplementation((path: string) => Promise.resolve( path !== 'existing/intermediate/new' )); mockAdapter.mkdir.mockResolvedValue(); await vaultAdapter.ensureFolder('existing/intermediate/new'); expect(mockAdapter.mkdir).toHaveBeenCalledTimes(1); expect(mockAdapter.mkdir).toHaveBeenCalledWith('existing/intermediate/new'); }); it('handles folder with empty segments', async () => { mockAdapter.exists.mockResolvedValueOnce(false); mockAdapter.mkdir.mockResolvedValue(); await vaultAdapter.ensureFolder('folder//nested'); expect(mockAdapter.mkdir).toHaveBeenCalledTimes(2); expect(mockAdapter.mkdir).toHaveBeenCalledWith('folder'); expect(mockAdapter.mkdir).toHaveBeenCalledWith('folder/nested'); }); }); describe('rename', () => { it('delegates to vault adapter rename', async () => { mockAdapter.rename.mockResolvedValue(); await vaultAdapter.rename('old.md', 'new.md'); expect(mockAdapter.rename).toHaveBeenCalledWith('old.md', 'new.md'); }); it('renames nested file', async () => { mockAdapter.rename.mockResolvedValue(); await vaultAdapter.rename('folder/old.md', 'folder/new.md'); expect(mockAdapter.rename).toHaveBeenCalledWith('folder/old.md', 'folder/new.md'); }); it('moves file across folders', async () => { mockAdapter.rename.mockResolvedValue(); await vaultAdapter.rename('folder1/file.md', 'folder2/file.md'); expect(mockAdapter.rename).toHaveBeenCalledWith('folder1/file.md', 'folder2/file.md'); }); }); describe('stat', () => { it('returns file stats for existing file', async () => { mockAdapter.stat.mockResolvedValue({ mtime: 1234567890, size: 1024 }); const result = await vaultAdapter.stat('file.md'); expect(result).toEqual({ mtime: 1234567890, size: 1024 }); expect(mockAdapter.stat).toHaveBeenCalledWith('file.md'); }); it('returns null when stat returns null', async () => { mockAdapter.stat.mockResolvedValue(null); const result = await vaultAdapter.stat('file.md'); expect(result).toBeNull(); }); it('returns null on stat error', async () => { mockAdapter.stat.mockRejectedValue(new Error('Stat error')); const result = await vaultAdapter.stat('file.md'); expect(result).toBeNull(); }); it('handles nested file path', async () => { mockAdapter.stat.mockResolvedValue({ mtime: 9876543210, size: 2048 }); const result = await vaultAdapter.stat('folder/subfolder/file.md'); expect(result).toEqual({ mtime: 9876543210, size: 2048 }); }); it('handles zero-sized file', async () => { mockAdapter.stat.mockResolvedValue({ mtime: 1234567890, size: 0 }); const result = await vaultAdapter.stat('empty.md'); expect(result).toEqual({ mtime: 1234567890, size: 0 }); }); }); }); ================================================ FILE: tests/unit/core/storage/migrationConstants.test.ts ================================================ import { CLAUDIAN_ONLY_FIELDS, convertEnvObjectToString, DEPRECATED_FIELDS, mergeEnvironmentVariables, MIGRATABLE_CLAUDIAN_FIELDS, } from '@/core/storage/migrationConstants'; describe('migrationConstants', () => { describe('CLAUDIAN_ONLY_FIELDS', () => { it('contains all expected user preference fields', () => { expect(CLAUDIAN_ONLY_FIELDS.has('userName')).toBe(true); }); it('contains all expected security settings', () => { expect(CLAUDIAN_ONLY_FIELDS.has('enableBlocklist')).toBe(true); expect(CLAUDIAN_ONLY_FIELDS.has('allowExternalAccess')).toBe(true); expect(CLAUDIAN_ONLY_FIELDS.has('blockedCommands')).toBe(true); expect(CLAUDIAN_ONLY_FIELDS.has('permissionMode')).toBe(true); expect(CLAUDIAN_ONLY_FIELDS.has('lastNonPlanPermissionMode')).toBe(true); }); it('contains all expected model & thinking fields', () => { expect(CLAUDIAN_ONLY_FIELDS.has('model')).toBe(true); expect(CLAUDIAN_ONLY_FIELDS.has('thinkingBudget')).toBe(true); expect(CLAUDIAN_ONLY_FIELDS.has('enableAutoTitleGeneration')).toBe(true); expect(CLAUDIAN_ONLY_FIELDS.has('titleGenerationModel')).toBe(true); }); it('contains all expected content settings', () => { expect(CLAUDIAN_ONLY_FIELDS.has('excludedTags')).toBe(true); expect(CLAUDIAN_ONLY_FIELDS.has('mediaFolder')).toBe(true); expect(CLAUDIAN_ONLY_FIELDS.has('systemPrompt')).toBe(true); expect(CLAUDIAN_ONLY_FIELDS.has('allowedExportPaths')).toBe(true); expect(CLAUDIAN_ONLY_FIELDS.has('persistentExternalContextPaths')).toBe(true); }); it('contains all expected environment fields', () => { expect(CLAUDIAN_ONLY_FIELDS.has('environmentVariables')).toBe(true); expect(CLAUDIAN_ONLY_FIELDS.has('envSnippets')).toBe(true); }); it('contains all expected UI settings', () => { expect(CLAUDIAN_ONLY_FIELDS.has('keyboardNavigation')).toBe(true); }); it('contains all expected CLI path fields', () => { expect(CLAUDIAN_ONLY_FIELDS.has('claudeCliPath')).toBe(true); expect(CLAUDIAN_ONLY_FIELDS.has('claudeCliPaths')).toBe(true); expect(CLAUDIAN_ONLY_FIELDS.has('loadUserClaudeSettings')).toBe(true); }); it('contains deprecated fields', () => { expect(CLAUDIAN_ONLY_FIELDS.has('allowedContextPaths')).toBe(true); expect(CLAUDIAN_ONLY_FIELDS.has('showToolUse')).toBe(true); expect(CLAUDIAN_ONLY_FIELDS.has('toolCallExpandedByDefault')).toBe(true); }); }); describe('MIGRATABLE_CLAUDIAN_FIELDS', () => { it('contains user preferences', () => { expect(MIGRATABLE_CLAUDIAN_FIELDS.has('userName')).toBe(true); }); it('contains security settings', () => { expect(MIGRATABLE_CLAUDIAN_FIELDS.has('enableBlocklist')).toBe(true); expect(MIGRATABLE_CLAUDIAN_FIELDS.has('allowExternalAccess')).toBe(true); expect(MIGRATABLE_CLAUDIAN_FIELDS.has('blockedCommands')).toBe(true); expect(MIGRATABLE_CLAUDIAN_FIELDS.has('permissionMode')).toBe(true); }); it('contains model settings', () => { expect(MIGRATABLE_CLAUDIAN_FIELDS.has('model')).toBe(true); expect(MIGRATABLE_CLAUDIAN_FIELDS.has('thinkingBudget')).toBe(true); }); it('contains environment fields including legacy env', () => { expect(MIGRATABLE_CLAUDIAN_FIELDS.has('environmentVariables')).toBe(true); expect(MIGRATABLE_CLAUDIAN_FIELDS.has('env')).toBe(true); }); it('excludes deprecated fields', () => { expect(MIGRATABLE_CLAUDIAN_FIELDS.has('allowedContextPaths')).toBe(false); expect(MIGRATABLE_CLAUDIAN_FIELDS.has('showToolUse')).toBe(false); }); }); describe('DEPRECATED_FIELDS', () => { it('contains all deprecated fields', () => { expect(DEPRECATED_FIELDS.has('allowedContextPaths')).toBe(true); expect(DEPRECATED_FIELDS.has('showToolUse')).toBe(true); expect(DEPRECATED_FIELDS.has('toolCallExpandedByDefault')).toBe(true); }); }); describe('convertEnvObjectToString', () => { it('converts simple env object to string', () => { const env = { API_KEY: 'secret123', MY_VAR: 'value' }; const result = convertEnvObjectToString(env); expect(result).toBe('API_KEY=secret123\nMY_VAR=value'); }); it('handles empty object', () => { expect(convertEnvObjectToString({})).toBe(''); }); it('handles undefined input', () => { expect(convertEnvObjectToString(undefined)).toBe(''); }); it('handles null input', () => { expect(convertEnvObjectToString(null as any)).toBe(''); }); it('handles non-object input', () => { expect(convertEnvObjectToString('not an object' as any)).toBe(''); expect(convertEnvObjectToString(123 as any)).toBe(''); expect(convertEnvObjectToString(true as any)).toBe(''); }); it('handles numeric keys (converted to strings by JS)', () => { const env = { 123: 'value', valid: 'value' } as any; const result = convertEnvObjectToString(env); expect(result).toContain('123=value'); expect(result).toContain('valid=value'); }); it('filters out non-string values', () => { const env = { string: 'value', number: 123, boolean: true, object: {} } as any; const result = convertEnvObjectToString(env); expect(result).toBe('string=value'); }); it('handles values with special characters', () => { const env = { KEY: 'value with spaces', KEY2: 'value=with=equals' }; const result = convertEnvObjectToString(env); expect(result).toBe('KEY=value with spaces\nKEY2=value=with=equals'); }); it('handles values with newlines', () => { const env = { KEY: 'line1\nline2' }; const result = convertEnvObjectToString(env); expect(result).toBe('KEY=line1\nline2'); }); it('handles unicode characters', () => { const env = { KEY: '🚀', KEY2: 'café' }; const result = convertEnvObjectToString(env); expect(result).toBe('KEY=🚀\nKEY2=café'); }); it('handles very long values', () => { const longValue = 'a'.repeat(10000); const env = { LONG: longValue }; const result = convertEnvObjectToString(env); expect(result).toBe(`LONG=${longValue}`); }); it('handles multiple variables correctly', () => { const env = { ANTHROPIC_API_KEY: 'sk-ant-xxx', MODEL: 'claude-sonnet-4-5', THINKING_BUDGET: '20000', }; const result = convertEnvObjectToString(env); expect(result).toContain('ANTHROPIC_API_KEY=sk-ant-xxx'); expect(result).toContain('MODEL=claude-sonnet-4-5'); expect(result).toContain('THINKING_BUDGET=20000'); }); it('maintains insertion order', () => { const env = { ZZZ: 'last', AAA: 'first', MMM: 'middle' }; const result = convertEnvObjectToString(env); // ES2015+ guarantees string key insertion order expect(result).toBe('ZZZ=last\nAAA=first\nMMM=middle'); }); }); describe('mergeEnvironmentVariables', () => { it('merges two env strings', () => { const existing = 'API_KEY=abc'; const additional = 'MODEL=claude'; const result = mergeEnvironmentVariables(existing, additional); expect(result).toContain('API_KEY=abc'); expect(result).toContain('MODEL=claude'); }); it('handles empty existing string', () => { const existing = ''; const additional = 'KEY=value'; const result = mergeEnvironmentVariables(existing, additional); expect(result).toBe('KEY=value'); }); it('handles empty additional string', () => { const existing = 'KEY=value'; const additional = ''; const result = mergeEnvironmentVariables(existing, additional); expect(result).toBe('KEY=value'); }); it('handles both empty strings', () => { expect(mergeEnvironmentVariables('', '')).toBe(''); }); it('allows additional to override existing values', () => { const existing = 'API_KEY=old\nMODEL=claude-3'; const additional = 'API_KEY=new'; const result = mergeEnvironmentVariables(existing, additional); expect(result).toContain('API_KEY=new'); expect(result).toContain('MODEL=claude-3'); expect(result).not.toContain('API_KEY=old'); }); it('handles multiple same keys in same string', () => { const existing = 'KEY=first\nKEY=second'; const additional = ''; const result = mergeEnvironmentVariables(existing, additional); const keyValues = result.split('\n').filter(line => line.startsWith('KEY=')); expect(keyValues).toHaveLength(1); expect(keyValues[0]).toBe('KEY=second'); }); it('handles comments (lines starting with #)', () => { const existing = '# Comment\nAPI_KEY=value'; const additional = 'MODEL=claude'; const result = mergeEnvironmentVariables(existing, additional); expect(result).not.toContain('# Comment'); expect(result).toContain('API_KEY=value'); expect(result).toContain('MODEL=claude'); }); it('handles whitespace-only lines', () => { const existing = 'API_KEY=value\n \nMODEL=claude'; const additional = ''; const result = mergeEnvironmentVariables(existing, additional); const lines = result.split('\n'); expect(lines.every(line => line.trim() !== '')).toBe(true); }); it('handles leading/trailing whitespace in lines', () => { const existing = ' API_KEY=value \n MODEL=claude '; const additional = ''; const result = mergeEnvironmentVariables(existing, additional); expect(result).toContain('API_KEY=value'); expect(result).toContain('MODEL=claude'); const lines = result.split('\n'); expect(lines.every(line => line === line.trim())).toBe(true); }); it('handles empty values', () => { const existing = 'KEY1=\nKEY2=value2'; const additional = ''; const result = mergeEnvironmentVariables(existing, additional); expect(result).toContain('KEY1='); expect(result).toContain('KEY2=value2'); }); it('handles values with equals signs', () => { const existing = 'KEY=value=with=equals'; const additional = ''; const result = mergeEnvironmentVariables(existing, additional); expect(result).toBe('KEY=value=with=equals'); }); it('handles keys without values (empty after =)', () => { const existing = 'KEY='; const additional = ''; const result = mergeEnvironmentVariables(existing, additional); expect(result).toBe('KEY='); }); it('handles lines without equals sign', () => { const existing = 'INVALID_LINE\nAPI_KEY=value'; const additional = ''; const result = mergeEnvironmentVariables(existing, additional); expect(result).not.toContain('INVALID_LINE'); expect(result).toContain('API_KEY=value'); }); it('handles equals at position 0', () => { const existing = '=invalid\nAPI_KEY=value'; const additional = ''; const result = mergeEnvironmentVariables(existing, additional); expect(result).not.toContain('=invalid'); expect(result).toContain('API_KEY=value'); }); it('handles multiline strings', () => { const existing = 'KEY1=value1\nKEY2=value2\nKEY3=value3'; const additional = 'KEY4=value4\nKEY5=value5'; const result = mergeEnvironmentVariables(existing, additional); expect(result).toContain('KEY1=value1'); expect(result).toContain('KEY2=value2'); expect(result).toContain('KEY3=value3'); expect(result).toContain('KEY4=value4'); expect(result).toContain('KEY5=value5'); }); it('handles unicode in keys and values', () => { const existing = 'KÉY=vàlué1'; const additional = 'KÉY=vàlué2'; const result = mergeEnvironmentVariables(existing, additional); expect(result).toContain('KÉY=vàlué2'); expect(result).not.toContain('KÉY=vàlué1'); }); it('handles very long strings', () => { const longValue = 'a'.repeat(10000); const existing = `KEY1=${longValue}`; const additional = `KEY2=${longValue}`; const result = mergeEnvironmentVariables(existing, additional); expect(result).toContain(`KEY1=${longValue}`); expect(result).toContain(`KEY2=${longValue}`); }); it('complex merge scenario', () => { const existing = `# API Configuration ANTHROPIC_API_KEY=sk-ant-old MODEL=claude-sonnet-3-5 # Feature flags ENABLE_FEATURE=false`; const additional = `ANTHROPIC_API_KEY=sk-ant-new ENABLE_FEATURE=true NEW_FEATURE=true`; const result = mergeEnvironmentVariables(existing, additional); expect(result).toContain('ANTHROPIC_API_KEY=sk-ant-new'); expect(result).toContain('MODEL=claude-sonnet-3-5'); expect(result).toContain('ENABLE_FEATURE=true'); expect(result).toContain('NEW_FEATURE=true'); expect(result).not.toContain('sk-ant-old'); expect(result).not.toContain('# API Configuration'); }); it('handles overlapping keys in both strings', () => { const existing = 'KEY=value1\nKEY2=value2'; const additional = 'KEY=value3\nKEY2=value4'; const result = mergeEnvironmentVariables(existing, additional); expect(result).toContain('KEY=value3'); expect(result).toContain('KEY2=value4'); expect(result).not.toContain('KEY=value1'); expect(result).not.toContain('KEY2=value2'); }); it('handles duplicate keys in existing', () => { const existing = 'KEY=first\nKEY=second\nKEY=third'; const additional = ''; const result = mergeEnvironmentVariables(existing, additional); const keyCount = result.split('\n').filter(line => line.startsWith('KEY=')).length; expect(keyCount).toBe(1); expect(result).toContain('KEY=third'); }); it('handles duplicate keys in additional', () => { const existing = ''; const additional = 'KEY=first\nKEY=second\nKEY=third'; const result = mergeEnvironmentVariables(existing, additional); const keyCount = result.split('\n').filter(line => line.startsWith('KEY=')).length; expect(keyCount).toBe(1); expect(result).toContain('KEY=third'); }); }); describe('integration scenarios', () => { it('converts and merges env objects', () => { const existingEnv = { API_KEY: 'old' }; const additionalEnv = { API_KEY: 'new', MODEL: 'claude' }; const existingStr = convertEnvObjectToString(existingEnv); const additionalStr = convertEnvObjectToString(additionalEnv); const merged = mergeEnvironmentVariables(existingStr, additionalStr); expect(merged).toContain('API_KEY=new'); expect(merged).toContain('MODEL=claude'); expect(merged).not.toContain('API_KEY=old'); }); it('handles real-world Claude Code environment migration', () => { const ccEnv = { ANTHROPIC_API_KEY: 'sk-ant-api-key', DEFAULT_MODEL: 'claude-sonnet-4-5', THINKING_BUDGET: '20000', }; const claudianEnv = 'CLAUDE_CLI_PATH=/usr/local/bin/claude\nENABLE_FEATURE=true'; const ccEnvStr = convertEnvObjectToString(ccEnv); const merged = mergeEnvironmentVariables(ccEnvStr, claudianEnv); expect(merged).toContain('ANTHROPIC_API_KEY=sk-ant-api-key'); expect(merged).toContain('DEFAULT_MODEL=claude-sonnet-4-5'); expect(merged).toContain('THINKING_BUDGET=20000'); expect(merged).toContain('CLAUDE_CLI_PATH=/usr/local/bin/claude'); expect(merged).toContain('ENABLE_FEATURE=true'); }); }); }); ================================================ FILE: tests/unit/core/storage/storage.test.ts ================================================ import type { ChatMessage, Conversation } from '@/core/types'; import { parseSlashCommandContent } from '@/utils/slashCommand'; // ============================================================================ // SessionStorage Tests (JSONL format) // ============================================================================ describe('SessionStorage JSONL format', () => { describe('parseJSONL', () => { it('should parse valid JSONL with meta and messages', () => { const jsonl = [ '{"type":"meta","id":"conv-123","title":"Test","createdAt":1000,"updatedAt":2000,"sessionId":"sess-1"}', '{"type":"message","message":{"id":"msg-1","role":"user","content":"Hello","timestamp":1001}}', '{"type":"message","message":{"id":"msg-2","role":"assistant","content":"Hi","timestamp":1002}}', ].join('\n'); const conversation = parseJSONLHelper(jsonl); expect(conversation).not.toBeNull(); expect(conversation!.id).toBe('conv-123'); expect(conversation!.title).toBe('Test'); expect(conversation!.createdAt).toBe(1000); expect(conversation!.updatedAt).toBe(2000); expect(conversation!.sessionId).toBe('sess-1'); expect(conversation!.messages).toHaveLength(2); expect(conversation!.messages[0].role).toBe('user'); expect(conversation!.messages[0].content).toBe('Hello'); expect(conversation!.messages[1].role).toBe('assistant'); }); it('should handle empty content', () => { const conversation = parseJSONLHelper(''); expect(conversation).toBeNull(); }); it('should handle content with only whitespace lines', () => { const conversation = parseJSONLHelper('\n \n \n'); expect(conversation).toBeNull(); }); it('should skip malformed lines gracefully', () => { const jsonl = [ '{"type":"meta","id":"conv-123","title":"Test","createdAt":1000,"updatedAt":2000,"sessionId":null}', 'not valid json', '{"type":"message","message":{"id":"msg-1","role":"user","content":"Hello","timestamp":1001}}', ].join('\n'); const conversation = parseJSONLHelper(jsonl); expect(conversation).not.toBeNull(); expect(conversation!.messages).toHaveLength(1); }); it('should return null if no meta record found', () => { const jsonl = '{"type":"message","message":{"id":"msg-1","role":"user","content":"Hello","timestamp":1001}}'; const conversation = parseJSONLHelper(jsonl); expect(conversation).toBeNull(); }); it('should parse lastResponseAt when present', () => { const jsonl = '{"type":"meta","id":"conv-123","title":"Test","createdAt":1000,"updatedAt":2000,"lastResponseAt":1500,"sessionId":null}'; const conversation = parseJSONLHelper(jsonl); expect(conversation!.lastResponseAt).toBe(1500); }); it('should parse currentNote when present', () => { const jsonl = '{"type":"meta","id":"conv-123","title":"Test","createdAt":1000,"updatedAt":2000,"sessionId":null,"currentNote":"file1.md"}'; const conversation = parseJSONLHelper(jsonl); expect(conversation!.currentNote).toBe('file1.md'); }); }); describe('serializeToJSONL', () => { it('should serialize conversation to valid JSONL', () => { const conversation: Conversation = { id: 'conv-456', title: 'My Chat', createdAt: 5000, updatedAt: 6000, sessionId: 'sess-abc', messages: [ { id: 'msg-1', role: 'user', content: 'Question', timestamp: 5001 }, { id: 'msg-2', role: 'assistant', content: 'Answer', timestamp: 5002 }, ], }; const jsonl = serializeToJSONLHelper(conversation); const lines = jsonl.split('\n'); expect(lines).toHaveLength(3); const meta = JSON.parse(lines[0]); expect(meta.type).toBe('meta'); expect(meta.id).toBe('conv-456'); expect(meta.title).toBe('My Chat'); expect(meta.sessionId).toBe('sess-abc'); const msg1 = JSON.parse(lines[1]); expect(msg1.type).toBe('message'); expect(msg1.message.role).toBe('user'); const msg2 = JSON.parse(lines[2]); expect(msg2.type).toBe('message'); expect(msg2.message.role).toBe('assistant'); }); it('should preserve image data when serializing', () => { const conversation: Conversation = { id: 'conv-img', title: 'Image Chat', createdAt: 1000, updatedAt: 2000, sessionId: null, messages: [ { id: 'msg-1', role: 'user', content: 'See image', timestamp: 1001, images: [ { id: 'img-1', name: 'test.png', mediaType: 'image/png', data: 'base64-image-data', size: 1024, source: 'paste', }, ], }, ], }; const jsonl = serializeToJSONLHelper(conversation); const lines = jsonl.split('\n'); const msgRecord = JSON.parse(lines[1]); expect(msgRecord.message.images).toHaveLength(1); expect(msgRecord.message.images[0].name).toBe('test.png'); // Image data is preserved as single source of truth expect(msgRecord.message.images[0].data).toBe('base64-image-data'); }); it('should preserve lastResponseAt in serialization', () => { const conversation: Conversation = { id: 'conv-lr', title: 'Test', createdAt: 1000, updatedAt: 2000, lastResponseAt: 1500, sessionId: null, messages: [], }; const jsonl = serializeToJSONLHelper(conversation); const meta = JSON.parse(jsonl.split('\n')[0]); expect(meta.lastResponseAt).toBe(1500); }); it('should round-trip conversation correctly', () => { const original: Conversation = { id: 'conv-rt', title: 'Round Trip', createdAt: 1000, updatedAt: 2000, lastResponseAt: 1500, sessionId: 'sess-rt', currentNote: 'a.md', messages: [ { id: 'msg-1', role: 'user', content: 'Hello', timestamp: 1001 }, { id: 'msg-2', role: 'assistant', content: 'World', timestamp: 1002 }, ], }; const jsonl = serializeToJSONLHelper(original); const parsed = parseJSONLHelper(jsonl); expect(parsed).not.toBeNull(); expect(parsed!.id).toBe(original.id); expect(parsed!.title).toBe(original.title); expect(parsed!.createdAt).toBe(original.createdAt); expect(parsed!.updatedAt).toBe(original.updatedAt); expect(parsed!.lastResponseAt).toBe(original.lastResponseAt); expect(parsed!.sessionId).toBe(original.sessionId); expect(parsed!.currentNote).toBe(original.currentNote); expect(parsed!.messages).toHaveLength(2); }); }); }); // ============================================================================ // SlashCommandStorage Tests // ============================================================================ describe('SlashCommandStorage', () => { describe('parseFile', () => { it('should parse command with full frontmatter', () => { const content = `--- description: Review code for issues argument-hint: "[file] [focus]" allowed-tools: - Read - Grep model: claude-sonnet-4-5 --- Review this code: $ARGUMENTS`; const parsed = parseSlashCommandContent(content); expect(parsed.description).toBe('Review code for issues'); expect(parsed.argumentHint).toBe('[file] [focus]'); expect(parsed.allowedTools).toEqual(['Read', 'Grep']); expect(parsed.model).toBe('claude-sonnet-4-5'); expect(parsed.promptContent).toBe('Review this code: $ARGUMENTS'); }); it('should parse command with minimal frontmatter', () => { const content = `--- description: Simple command --- Do something`; const parsed = parseSlashCommandContent(content); expect(parsed.description).toBe('Simple command'); expect(parsed.argumentHint).toBeUndefined(); expect(parsed.allowedTools).toBeUndefined(); expect(parsed.model).toBeUndefined(); expect(parsed.promptContent).toBe('Do something'); }); it('should handle content without frontmatter', () => { const content = 'Just a prompt without frontmatter'; const parsed = parseSlashCommandContent(content); expect(parsed.description).toBeUndefined(); expect(parsed.promptContent).toBe('Just a prompt without frontmatter'); }); it('should handle inline array syntax for allowed-tools', () => { const content = `--- allowed-tools: [Read, Write, Bash] --- Prompt`; const parsed = parseSlashCommandContent(content); expect(parsed.allowedTools).toEqual(['Read', 'Write', 'Bash']); }); it('should handle quoted values', () => { const content = `--- description: "Value with: colon" argument-hint: 'Single quoted' --- Prompt`; const parsed = parseSlashCommandContent(content); expect(parsed.description).toBe('Value with: colon'); expect(parsed.argumentHint).toBe('Single quoted'); }); // Block scalar tests moved to tests/unit/utils/slashCommand.test.ts }); }); // ============================================================================ // Helper Functions (mimic internal logic for testing) // ============================================================================ interface SessionMetaRecord { type: 'meta'; id: string; title: string; createdAt: number; updatedAt: number; lastResponseAt?: number; sessionId: string | null; currentNote?: string; } interface SessionMessageRecord { type: 'message'; message: ChatMessage; } type SessionRecord = SessionMetaRecord | SessionMessageRecord; function parseJSONLHelper(content: string): Conversation | null { const lines = content.split('\n').filter(l => l.trim()); if (lines.length === 0) return null; let meta: SessionMetaRecord | null = null; const messages: ChatMessage[] = []; for (const line of lines) { try { const record = JSON.parse(line) as SessionRecord; if (record.type === 'meta') { meta = record; } else if (record.type === 'message') { messages.push(record.message); } } catch { // Skip malformed lines } } if (!meta) return null; return { id: meta.id, title: meta.title, createdAt: meta.createdAt, updatedAt: meta.updatedAt, lastResponseAt: meta.lastResponseAt, sessionId: meta.sessionId, messages, currentNote: meta.currentNote, }; } function serializeToJSONLHelper(conversation: Conversation): string { const lines: string[] = []; const meta: SessionMetaRecord = { type: 'meta', id: conversation.id, title: conversation.title, createdAt: conversation.createdAt, updatedAt: conversation.updatedAt, lastResponseAt: conversation.lastResponseAt, sessionId: conversation.sessionId, currentNote: conversation.currentNote, }; lines.push(JSON.stringify(meta)); for (const message of conversation.messages) { // Image data is preserved as single source of truth const record: SessionMessageRecord = { type: 'message', message, }; lines.push(JSON.stringify(record)); } return lines.join('\n'); } ================================================ FILE: tests/unit/core/storage/storageService.convenience.test.ts ================================================ import type { Plugin } from 'obsidian'; import { StorageService } from '@/core/storage'; import { createPermissionRule } from '@/core/types'; function createMockAdapter(initialFiles: Record<string, string> = {}) { const files = new Map<string, string>(Object.entries(initialFiles)); const folders = new Set<string>(); return { adapter: { exists: jest.fn(async (path: string) => files.has(path) || folders.has(path)), read: jest.fn(async (path: string) => { const content = files.get(path); if (content === undefined) throw new Error(`Missing file: ${path}`); return content; }), write: jest.fn(async (path: string, content: string) => { files.set(path, content); }), remove: jest.fn(async (path: string) => { files.delete(path); }), mkdir: jest.fn(async (path: string) => { folders.add(path); }), list: jest.fn(async (path: string) => { const prefix = `${path}/`; const filesInFolder = Array.from(files.keys()).filter(fp => fp.startsWith(prefix)); const filesAtLevel = filesInFolder.filter(fp => !fp.slice(prefix.length).includes('/')); const folderSet = new Set<string>(); for (const fp of filesInFolder) { const parts = fp.slice(prefix.length).split('/'); if (parts.length > 1) folderSet.add(`${path}/${parts[0]}`); } return { files: filesAtLevel, folders: Array.from(folderSet) }; }), rename: jest.fn(), stat: jest.fn(async (path: string) => { if (!files.has(path)) return null; return { mtime: 1, size: files.get(path)!.length }; }), }, files, folders, }; } function createMockPlugin(options: { dataJson?: unknown; initialFiles?: Record<string, string>; }) { const { adapter, files, folders } = createMockAdapter(options.initialFiles); const plugin = { app: { vault: { adapter } }, loadData: jest.fn().mockResolvedValue(options.dataJson ?? null), saveData: jest.fn().mockResolvedValue(undefined), }; return { plugin: plugin as unknown as Plugin, adapter, files, folders }; } describe('StorageService convenience methods', () => { const ccSettingsJson = JSON.stringify({ $schema: 'https://json.schemastore.org/claude-code-settings.json', permissions: { allow: ['Bash(git *)'], deny: ['Bash(rm -rf)'], ask: [], }, }); const claudianSettingsJson = JSON.stringify({ userName: 'Test', model: 'haiku', permissionMode: 'yolo', }); describe('getPermissions', () => { it('delegates to ccSettings.getPermissions', async () => { const { plugin } = createMockPlugin({ initialFiles: { '.claude/settings.json': ccSettingsJson, }, }); const storage = new StorageService(plugin); await storage.initialize(); const perms = await storage.getPermissions(); expect(perms.allow).toContainEqual('Bash(git *)'); expect(perms.deny).toContainEqual('Bash(rm -rf)'); }); }); describe('updatePermissions', () => { it('saves updated permissions via ccSettings', async () => { const { plugin, files } = createMockPlugin({ initialFiles: { '.claude/settings.json': ccSettingsJson, }, }); const storage = new StorageService(plugin); await storage.initialize(); await storage.updatePermissions({ allow: [createPermissionRule('Read')], deny: [], ask: [], }); const saved = JSON.parse(files.get('.claude/settings.json')!) as Record<string, unknown>; expect((saved.permissions as { allow: string[] }).allow).toContainEqual('Read'); }); }); describe('addAllowRule', () => { it('adds a new allow rule', async () => { const { plugin, files } = createMockPlugin({ initialFiles: { '.claude/settings.json': ccSettingsJson, }, }); const storage = new StorageService(plugin); await storage.initialize(); await storage.addAllowRule('Read(/vault/*)'); const saved = JSON.parse(files.get('.claude/settings.json')!) as Record<string, unknown>; expect((saved.permissions as { allow: string[] }).allow).toContainEqual('Read(/vault/*)'); }); }); describe('addDenyRule', () => { it('adds a new deny rule', async () => { const { plugin, files } = createMockPlugin({ initialFiles: { '.claude/settings.json': ccSettingsJson, }, }); const storage = new StorageService(plugin); await storage.initialize(); await storage.addDenyRule('Write(/etc/*)'); const saved = JSON.parse(files.get('.claude/settings.json')!) as Record<string, unknown>; expect((saved.permissions as { deny: string[] }).deny).toContainEqual('Write(/etc/*)'); }); }); describe('removePermissionRule', () => { it('removes a rule from all permission lists', async () => { const settings = JSON.stringify({ permissions: { allow: ['Bash(git *)'], deny: ['Bash(git *)'], ask: ['Bash(git *)'], }, }); const { plugin, files } = createMockPlugin({ initialFiles: { '.claude/settings.json': settings }, }); const storage = new StorageService(plugin); await storage.initialize(); await storage.removePermissionRule('Bash(git *)'); const saved = JSON.parse(files.get('.claude/settings.json')!) as Record<string, unknown>; const perms = saved.permissions as { allow: string[]; deny: string[]; ask: string[] }; expect(perms.allow).not.toContainEqual('Bash(git *)'); expect(perms.deny).not.toContainEqual('Bash(git *)'); expect(perms.ask).not.toContainEqual('Bash(git *)'); }); }); describe('updateClaudianSettings', () => { it('updates partial claudian settings', async () => { const { plugin, files } = createMockPlugin({ initialFiles: { '.claude/claudian-settings.json': claudianSettingsJson, }, }); const storage = new StorageService(plugin); await storage.initialize(); await storage.updateClaudianSettings({ userName: 'NewUser' }); const saved = JSON.parse(files.get('.claude/claudian-settings.json')!) as Record<string, unknown>; expect(saved.userName).toBe('NewUser'); }); }); describe('saveClaudianSettings', () => { it('saves full claudian settings', async () => { const { plugin, files } = createMockPlugin({ initialFiles: { '.claude/claudian-settings.json': claudianSettingsJson, }, }); const storage = new StorageService(plugin); await storage.initialize(); const existing = await storage.loadClaudianSettings(); existing.userName = 'FullSave'; await storage.saveClaudianSettings(existing); const saved = JSON.parse(files.get('.claude/claudian-settings.json')!) as Record<string, unknown>; expect(saved.userName).toBe('FullSave'); }); }); describe('loadClaudianSettings', () => { it('loads claudian settings', async () => { const { plugin } = createMockPlugin({ initialFiles: { '.claude/claudian-settings.json': claudianSettingsJson, }, }); const storage = new StorageService(plugin); await storage.initialize(); const settings = await storage.loadClaudianSettings(); expect(settings.userName).toBe('Test'); expect(settings.model).toBe('haiku'); }); }); describe('loadAllSlashCommands', () => { it('returns commands from both commands and skills directories', async () => { const commandContent = [ '---', 'description: Review code', '---', 'Review this code', ].join('\n'); const skillContent = [ '---', 'description: A skill', '---', 'Do the skill', ].join('\n'); const { plugin } = createMockPlugin({ initialFiles: { '.claude/commands/review.md': commandContent, '.claude/skills/my-skill/SKILL.md': skillContent, }, }); const storage = new StorageService(plugin); await storage.initialize(); const commands = await storage.loadAllSlashCommands(); expect(commands.length).toBeGreaterThanOrEqual(1); expect(commands.some(c => c.name === 'review')).toBe(true); }); it('returns empty array when no commands exist', async () => { const { plugin } = createMockPlugin({}); const storage = new StorageService(plugin); await storage.initialize(); const commands = await storage.loadAllSlashCommands(); expect(commands).toEqual([]); }); }); describe('getAdapter', () => { it('returns the VaultFileAdapter instance', () => { const { plugin } = createMockPlugin({}); const storage = new StorageService(plugin); const adapter = storage.getAdapter(); expect(adapter).toBeDefined(); expect(typeof adapter.exists).toBe('function'); }); }); describe('getLegacyActiveConversationId', () => { it('returns id from claudian settings', async () => { const settings = JSON.stringify({ userName: 'Test', activeConversationId: 'conv-from-settings', }); const { plugin } = createMockPlugin({ initialFiles: { '.claude/claudian-settings.json': settings, }, }); const storage = new StorageService(plugin); await storage.initialize(); const id = await storage.getLegacyActiveConversationId(); expect(id).toBe('conv-from-settings'); }); it('falls back to data.json when not in claudian settings', async () => { const { plugin } = createMockPlugin({ dataJson: { activeConversationId: 'conv-from-data' }, initialFiles: { '.claude/claudian-settings.json': JSON.stringify({ userName: 'Test' }), }, }); const storage = new StorageService(plugin); await storage.initialize(); const id = await storage.getLegacyActiveConversationId(); expect(id).toBe('conv-from-data'); }); it('returns null when not found in either source', async () => { const { plugin } = createMockPlugin({ dataJson: {}, initialFiles: { '.claude/claudian-settings.json': JSON.stringify({ userName: 'Test' }), }, }); const storage = new StorageService(plugin); await storage.initialize(); const id = await storage.getLegacyActiveConversationId(); expect(id).toBeNull(); }); }); describe('clearLegacyActiveConversationId', () => { it('clears from data.json', async () => { const { plugin } = createMockPlugin({ dataJson: { activeConversationId: 'conv-1', otherField: 'keep' }, initialFiles: { '.claude/claudian-settings.json': JSON.stringify({ userName: 'Test' }), }, }); const storage = new StorageService(plugin); await storage.initialize(); await storage.clearLegacyActiveConversationId(); expect(plugin.saveData).toHaveBeenCalledWith( expect.objectContaining({ otherField: 'keep' }), ); const savedData = (plugin.saveData as jest.Mock).mock.calls.find( (call: unknown[]) => { const arg = call[0] as Record<string, unknown>; return !('activeConversationId' in arg); }, ); expect(savedData).toBeDefined(); }); it('no-ops when data.json has no activeConversationId', async () => { const { plugin } = createMockPlugin({ dataJson: { otherField: 'keep' }, initialFiles: { '.claude/claudian-settings.json': JSON.stringify({ userName: 'Test' }), }, }); const storage = new StorageService(plugin); await storage.initialize(); // Reset mock calls from initialize (plugin.saveData as jest.Mock).mockClear(); await storage.clearLegacyActiveConversationId(); // saveData should not have been called for data.json cleanup expect(plugin.saveData).not.toHaveBeenCalled(); }); }); describe('getTabManagerState', () => { it('returns validated state from data.json', async () => { const state = { openTabs: [{ tabId: 'tab-1', conversationId: 'conv-1' }], activeTabId: 'tab-1', }; const { plugin } = createMockPlugin({ dataJson: { tabManagerState: state }, }); const storage = new StorageService(plugin); const result = await storage.getTabManagerState(); expect(result).toEqual(state); }); it('returns null when no state exists', async () => { const { plugin } = createMockPlugin({ dataJson: {} }); const storage = new StorageService(plugin); const result = await storage.getTabManagerState(); expect(result).toBeNull(); }); it('returns null when data.json is null', async () => { const { plugin } = createMockPlugin({ dataJson: null }); const storage = new StorageService(plugin); const result = await storage.getTabManagerState(); expect(result).toBeNull(); }); it('returns null for invalid state (not an object)', async () => { const { plugin } = createMockPlugin({ dataJson: { tabManagerState: 'invalid' }, }); const storage = new StorageService(plugin); const result = await storage.getTabManagerState(); expect(result).toBeNull(); }); it('returns null when openTabs is not an array', async () => { const { plugin } = createMockPlugin({ dataJson: { tabManagerState: { openTabs: 'not-array' } }, }); const storage = new StorageService(plugin); const result = await storage.getTabManagerState(); expect(result).toBeNull(); }); it('skips invalid tab entries', async () => { const state = { openTabs: [ { tabId: 'tab-1', conversationId: 'conv-1' }, null, { tabId: 123 }, 'invalid', { tabId: 'tab-2', conversationId: null }, ], activeTabId: 'tab-1', }; const { plugin } = createMockPlugin({ dataJson: { tabManagerState: state }, }); const storage = new StorageService(plugin); const result = await storage.getTabManagerState(); expect(result).not.toBeNull(); expect(result!.openTabs).toHaveLength(2); expect(result!.openTabs[0].tabId).toBe('tab-1'); expect(result!.openTabs[1].tabId).toBe('tab-2'); expect(result!.openTabs[1].conversationId).toBeNull(); }); it('normalizes non-string conversationId to null', async () => { const state = { openTabs: [{ tabId: 'tab-1', conversationId: 123 }], activeTabId: 'tab-1', }; const { plugin } = createMockPlugin({ dataJson: { tabManagerState: state }, }); const storage = new StorageService(plugin); const result = await storage.getTabManagerState(); expect(result!.openTabs[0].conversationId).toBeNull(); }); it('normalizes non-string activeTabId to null', async () => { const state = { openTabs: [{ tabId: 'tab-1', conversationId: 'conv-1' }], activeTabId: 123, }; const { plugin } = createMockPlugin({ dataJson: { tabManagerState: state }, }); const storage = new StorageService(plugin); const result = await storage.getTabManagerState(); expect(result!.activeTabId).toBeNull(); }); it('returns null when loadData throws', async () => { const { plugin } = createMockPlugin({}); (plugin.loadData as jest.Mock).mockRejectedValue(new Error('Read error')); const storage = new StorageService(plugin); const result = await storage.getTabManagerState(); expect(result).toBeNull(); }); }); describe('setTabManagerState', () => { it('persists state to data.json', async () => { const { plugin } = createMockPlugin({ dataJson: {} }); const storage = new StorageService(plugin); const state = { openTabs: [{ tabId: 'tab-1', conversationId: 'conv-1' }], activeTabId: 'tab-1', }; await storage.setTabManagerState(state); expect(plugin.saveData).toHaveBeenCalledWith( expect.objectContaining({ tabManagerState: state }), ); }); it('merges with existing data.json content', async () => { const { plugin } = createMockPlugin({ dataJson: { existingKey: 'keep' }, }); const storage = new StorageService(plugin); await storage.setTabManagerState({ openTabs: [], activeTabId: null, }); expect(plugin.saveData).toHaveBeenCalledWith( expect.objectContaining({ existingKey: 'keep', tabManagerState: { openTabs: [], activeTabId: null }, }), ); }); it('silently handles save errors', async () => { const { plugin } = createMockPlugin({}); (plugin.loadData as jest.Mock).mockRejectedValue(new Error('Read error')); const storage = new StorageService(plugin); // Should not throw await expect( storage.setTabManagerState({ openTabs: [], activeTabId: null }), ).resolves.toBeUndefined(); }); }); }); ================================================ FILE: tests/unit/core/storage/storageService.migration.test.ts ================================================ import type { Plugin } from 'obsidian'; import { StorageService } from '@/core/storage'; import { DEFAULT_SETTINGS, type SlashCommand } from '@/core/types'; type AdapterOptions = { shouldFailWrite?: (path: string) => boolean; }; function createMockAdapter( initialFiles: Record<string, string> = {}, options: AdapterOptions = {} ) { const files = new Map<string, string>(Object.entries(initialFiles)); const folders = new Set<string>(); const shouldFailWrite = options.shouldFailWrite ?? (() => false); const adapter = { exists: jest.fn(async (path: string) => files.has(path) || folders.has(path)), read: jest.fn(async (path: string) => { const content = files.get(path); if (content === undefined) { throw new Error(`Missing file: ${path}`); } return content; }), write: jest.fn(async (path: string, content: string) => { if (shouldFailWrite(path)) { throw new Error(`Write failed: ${path}`); } files.set(path, content); }), remove: jest.fn(async (path: string) => { files.delete(path); }), mkdir: jest.fn(async (path: string) => { folders.add(path); }), list: jest.fn(async (path: string) => { const prefix = `${path}/`; const filesInFolder = Array.from(files.keys()).filter((filePath) => filePath.startsWith(prefix)); const filesAtLevel = filesInFolder.filter((filePath) => { const rest = filePath.slice(prefix.length); return !rest.includes('/'); }); const folderSet = new Set<string>(); for (const filePath of filesInFolder) { const rest = filePath.slice(prefix.length); const parts = rest.split('/'); if (parts.length > 1) { folderSet.add(`${path}/${parts[0]}`); } } return { files: filesAtLevel, folders: Array.from(folderSet) }; }), rename: jest.fn(async (oldPath: string, newPath: string) => { const content = files.get(oldPath); if (content !== undefined) { files.delete(oldPath); files.set(newPath, content); } }), stat: jest.fn(async (path: string) => { if (!files.has(path)) { return null; } return { mtime: 1, size: files.get(path)!.length }; }), }; return { adapter, files, folders }; } function createMockPlugin(options: { dataJson?: unknown; initialFiles?: Record<string, string>; shouldFailWrite?: (path: string) => boolean; }) { const { adapter, files } = createMockAdapter(options.initialFiles, { shouldFailWrite: options.shouldFailWrite, }); const plugin = { app: { vault: { adapter } }, loadData: jest.fn().mockResolvedValue(options.dataJson ?? null), saveData: jest.fn().mockResolvedValue(undefined), }; return { plugin: plugin as unknown as Plugin, adapter, files }; } describe('StorageService migration', () => { it('clears data.json after successful legacy content migration', async () => { const command: SlashCommand = { id: 'cmd-review', name: 'review', content: 'Review the file.', }; const { plugin, files } = createMockPlugin({ dataJson: { slashCommands: [command] }, }); const storage = new StorageService(plugin); await storage.initialize(); expect(files.has('.claude/commands/review.md')).toBe(true); expect(plugin.saveData).toHaveBeenCalledWith({}); }); it('does not clear data.json when legacy content migration fails', async () => { const command: SlashCommand = { id: 'cmd-review', name: 'review', content: 'Review the file.', }; const { plugin } = createMockPlugin({ dataJson: { slashCommands: [command] }, shouldFailWrite: (path) => path.startsWith('.claude/commands/'), }); const storage = new StorageService(plugin); await storage.initialize(); expect(plugin.saveData).not.toHaveBeenCalled(); }); it('normalizes legacy blockedCommands during settings migration', async () => { const legacySettings = { userName: 'Test User', blockedCommands: ['rm -rf', ' '], permissions: [], }; const { plugin, files } = createMockPlugin({ dataJson: null, initialFiles: { '.claude/settings.json': JSON.stringify(legacySettings), }, }); const storage = new StorageService(plugin); await storage.initialize(); const saved = JSON.parse(files.get('.claude/claudian-settings.json') || '{}') as Record<string, unknown>; const blocked = saved.blockedCommands as { unix: string[]; windows: string[] }; expect(blocked.unix).toEqual(['rm -rf']); expect(blocked.windows).toEqual(DEFAULT_SETTINGS.blockedCommands.windows); }); it('does not migrate legacy activeConversationId from data.json', async () => { const { plugin, files } = createMockPlugin({ dataJson: { activeConversationId: 'conv-1' }, }); const storage = new StorageService(plugin); await storage.initialize(); const rawSettings = files.get('.claude/claudian-settings.json'); // If settings file was created, it should NOT contain the legacy activeConversationId const containsLegacyField = rawSettings ? 'activeConversationId' in (JSON.parse(rawSettings) as Record<string, unknown>) : false; expect(containsLegacyField).toBe(false); expect(plugin.saveData).not.toHaveBeenCalled(); }); it('preserves tabManagerState when clearing legacy data.json state', async () => { const tabManagerState = { openTabs: [{ tabId: 'tab-1', conversationId: 'conv-1' }], activeTabId: 'tab-1', }; const { plugin } = createMockPlugin({ dataJson: { lastEnvHash: 'hash', tabManagerState, }, }); const storage = new StorageService(plugin); await storage.initialize(); expect(plugin.saveData).toHaveBeenCalledWith({ tabManagerState, }); }); it('initializes persistentExternalContextPaths to empty array when migrating old settings', async () => { // Legacy settings without persistentExternalContextPaths const legacySettings = { userName: 'Test User', permissions: [], allowedExportPaths: ['~/Desktop'], // Note: no persistentExternalContextPaths }; const { plugin, files } = createMockPlugin({ dataJson: null, initialFiles: { '.claude/settings.json': JSON.stringify(legacySettings), }, }); const storage = new StorageService(plugin); await storage.initialize(); const saved = JSON.parse(files.get('.claude/claudian-settings.json') || '{}') as Record<string, unknown>; expect(saved.persistentExternalContextPaths).toEqual([]); }); it('merges env object from CC format into environmentVariables during migration', async () => { const legacySettings = { userName: 'Test User', permissions: [], environmentVariables: 'FOO=bar', env: { BAZ: 'qux' }, }; const { plugin, files } = createMockPlugin({ dataJson: null, initialFiles: { '.claude/settings.json': JSON.stringify(legacySettings), }, }); const storage = new StorageService(plugin); await storage.initialize(); const saved = JSON.parse(files.get('.claude/claudian-settings.json') || '{}') as Record<string, unknown>; const envVars = saved.environmentVariables as string; expect(envVars).toContain('FOO=bar'); expect(envVars).toContain('BAZ=qux'); }); it('preserves CC-format permissions during migration', async () => { const legacySettings = { userName: 'Test User', permissions: { allow: [{ toolName: 'Read', ruleContent: '/vault/*' }], deny: [], ask: [], defaultMode: 'default', additionalDirectories: ['/external'], }, }; const { plugin, files } = createMockPlugin({ dataJson: null, initialFiles: { '.claude/settings.json': JSON.stringify(legacySettings), }, }); const storage = new StorageService(plugin); await storage.initialize(); const ccSettings = JSON.parse(files.get('.claude/settings.json') || '{}') as Record<string, any>; expect(ccSettings.permissions.allow).toEqual([{ toolName: 'Read', ruleContent: '/vault/*' }]); expect(ccSettings.permissions.defaultMode).toBe('default'); expect(ccSettings.permissions.additionalDirectories).toEqual(['/external']); }); it('migrates data.json state fields to claudian-settings when empty', async () => { // Migration only writes when the target field is falsy. // Default lastClaudeModel='haiku' (truthy) → won't overwrite // Default lastCustomModel='' (falsy) → will overwrite // Default lastEnvHash='' (falsy) → will overwrite const { plugin, files } = createMockPlugin({ dataJson: { lastEnvHash: 'abc123', lastClaudeModel: 'claude-3-sonnet', lastCustomModel: 'custom-model', }, }); const storage = new StorageService(plugin); await storage.initialize(); const saved = JSON.parse(files.get('.claude/claudian-settings.json') || '{}') as Record<string, unknown>; expect(saved.lastEnvHash).toBe('abc123'); // lastClaudeModel defaults to 'haiku' (truthy), so migration doesn't overwrite it expect(saved.lastClaudeModel).toBe('haiku'); expect(saved.lastCustomModel).toBe('custom-model'); }); it('does not overwrite existing claudian-settings fields from data.json', async () => { const { plugin, files } = createMockPlugin({ dataJson: { lastEnvHash: 'old-hash', lastClaudeModel: 'old-model', }, initialFiles: { '.claude/claudian-settings.json': JSON.stringify({ userName: 'Test User', lastEnvHash: 'existing-hash', lastClaudeModel: 'existing-model', }), }, }); const storage = new StorageService(plugin); await storage.initialize(); const saved = JSON.parse(files.get('.claude/claudian-settings.json') || '{}') as Record<string, unknown>; expect(saved.lastEnvHash).toBe('existing-hash'); expect(saved.lastClaudeModel).toBe('existing-model'); }); it('skips existing slash commands during migration', async () => { const command: SlashCommand = { id: 'cmd-review', name: 'review', content: 'Review the file.', }; const { plugin, files } = createMockPlugin({ dataJson: { slashCommands: [command] }, initialFiles: { '.claude/commands/review.md': 'Existing content', }, }); const storage = new StorageService(plugin); await storage.initialize(); // Should keep existing file, not overwrite expect(files.get('.claude/commands/review.md')).toBe('Existing content'); }); it('migrates conversations from data.json', async () => { const conversation = { id: 'conv-1', title: 'Test Conversation', createdAt: 1000, updatedAt: 2000, sessionId: null, messages: [{ id: 'msg-1', role: 'user', content: 'Hello', timestamp: 1000 }], }; const { plugin, files } = createMockPlugin({ dataJson: { conversations: [conversation] }, }); const storage = new StorageService(plugin); await storage.initialize(); expect(files.has('.claude/sessions/conv-1.jsonl')).toBe(true); }); it('skips existing conversations during migration', async () => { const conversation = { id: 'conv-1', title: 'Test Conversation', createdAt: 1000, updatedAt: 2000, sessionId: null, messages: [], }; const { plugin, files } = createMockPlugin({ dataJson: { conversations: [conversation] }, initialFiles: { '.claude/sessions/conv-1.jsonl': '{"existing": true}', }, }); const storage = new StorageService(plugin); await storage.initialize(); // Should keep existing file expect(files.get('.claude/sessions/conv-1.jsonl')).toBe('{"existing": true}'); }); it('handles conversation migration errors gracefully', async () => { const conversation = { id: 'conv-fail', title: 'Failing Conversation', createdAt: 1000, updatedAt: 2000, sessionId: null, messages: [], }; const { plugin } = createMockPlugin({ dataJson: { conversations: [conversation] }, shouldFailWrite: (path) => path.includes('conv-fail'), }); const storage = new StorageService(plugin); // Should not throw await storage.initialize(); // data.json should NOT be cleared due to error expect(plugin.saveData).not.toHaveBeenCalled(); }); it('handles loadDataJson error gracefully', async () => { const plugin = { app: { vault: { adapter: createMockAdapter().adapter } }, loadData: jest.fn().mockRejectedValue(new Error('data.json read error')), saveData: jest.fn().mockResolvedValue(undefined), } as unknown as Plugin; const storage = new StorageService(plugin); // Should not throw - loadDataJson returns null on error await expect(storage.initialize()).resolves.toBeDefined(); }); it('converts legacy permissions array format during migration', async () => { const legacySettings = { userName: 'Test User', permissions: [ { type: 'allow', tool: 'Read', rule: '/vault/*' }, { type: 'deny', tool: 'Bash', rule: 'rm *' }, ], }; const { plugin, files } = createMockPlugin({ dataJson: null, initialFiles: { '.claude/settings.json': JSON.stringify(legacySettings), }, }); const storage = new StorageService(plugin); await storage.initialize(); const ccSettings = JSON.parse(files.get('.claude/settings.json') || '{}') as Record<string, any>; // Legacy format should be converted to CC format with allow/deny/ask arrays expect(ccSettings.permissions).toHaveProperty('allow'); expect(ccSettings.permissions).toHaveProperty('deny'); }); it('converts legacy permissions with toolName/pattern format during settings migration', async () => { const legacySettings = { userName: 'Test User', permissions: [ { toolName: 'Bash', pattern: 'git *', approvedAt: 1000, scope: 'always' }, { toolName: 'Read', pattern: '/vault/*', approvedAt: 2000, scope: 'always' }, { toolName: 'Write', pattern: '/tmp/*', approvedAt: 3000, scope: 'session' }, ], }; const { plugin, files } = createMockPlugin({ dataJson: null, initialFiles: { '.claude/settings.json': JSON.stringify(legacySettings), }, }); const storage = new StorageService(plugin); await storage.initialize(); const ccSettings = JSON.parse(files.get('.claude/settings.json') || '{}') as Record<string, any>; // Legacy format should be converted via legacyPermissionsToCCPermissions // Only 'always' scope permissions are converted expect(ccSettings.permissions.allow).toContain('Bash(git *)'); expect(ccSettings.permissions.allow).toContain('Read(/vault/*)'); // Session scope should be excluded expect(ccSettings.permissions.allow).not.toContain('Write(/tmp/*)'); expect(ccSettings.permissions.deny).toEqual([]); expect(ccSettings.permissions.ask).toEqual([]); }); it('migrates lastClaudeModel from data.json when claudian-settings has falsy value', async () => { const { plugin, files } = createMockPlugin({ dataJson: { lastClaudeModel: 'claude-3-sonnet', }, initialFiles: { '.claude/settings.json': JSON.stringify({ permissions: { allow: [], deny: [], ask: [] }, }), '.claude/claudian-settings.json': JSON.stringify({ userName: 'Test User', lastClaudeModel: '', }), }, }); const storage = new StorageService(plugin); await storage.initialize(); const saved = JSON.parse(files.get('.claude/claudian-settings.json') || '{}') as Record<string, unknown>; expect(saved.lastClaudeModel).toBe('claude-3-sonnet'); }); it('preserves persistentExternalContextPaths from existing settings', async () => { const existingSettings = { userName: 'Test User', permissions: [], persistentExternalContextPaths: ['/path/a', '/path/b'], }; const { plugin, files } = createMockPlugin({ dataJson: null, initialFiles: { '.claude/claudian-settings.json': JSON.stringify(existingSettings), }, }); const storage = new StorageService(plugin); await storage.initialize(); const saved = JSON.parse(files.get('.claude/claudian-settings.json') || '{}') as Record<string, unknown>; expect(saved.persistentExternalContextPaths).toEqual(['/path/a', '/path/b']); }); }); ================================================ FILE: tests/unit/core/tools/todo.test.ts ================================================ import { extractLastTodosFromMessages, parseTodoInput } from '@/core/tools/todo'; import { TOOL_TODO_WRITE } from '@/core/tools/toolNames'; describe('parseTodoInput', () => { it('should parse valid todo items', () => { const input = { todos: [ { content: 'Run tests', status: 'pending', activeForm: 'Running tests' }, { content: 'Fix bug', status: 'in_progress', activeForm: 'Fixing bug' }, { content: 'Deploy', status: 'completed', activeForm: 'Deploying' }, ], }; const result = parseTodoInput(input); expect(result).toHaveLength(3); expect(result![0]).toEqual({ content: 'Run tests', status: 'pending', activeForm: 'Running tests' }); expect(result![1].status).toBe('in_progress'); expect(result![2].status).toBe('completed'); }); it('should return null when todos key is missing', () => { expect(parseTodoInput({})).toBeNull(); }); it('should return null when todos is not an array', () => { expect(parseTodoInput({ todos: 'not an array' })).toBeNull(); expect(parseTodoInput({ todos: 42 })).toBeNull(); expect(parseTodoInput({ todos: null })).toBeNull(); }); it('should filter out invalid items', () => { const input = { todos: [ { content: 'Valid', status: 'pending', activeForm: 'Working' }, { content: '', status: 'pending', activeForm: 'Working' }, // empty content { content: 'No status', activeForm: 'Working' }, // missing status { content: 'Bad status', status: 'unknown', activeForm: 'Working' }, // invalid status null, 42, 'string', ], }; const result = parseTodoInput(input); expect(result).toHaveLength(1); expect(result![0].content).toBe('Valid'); }); it('should return null when all items are invalid', () => { const input = { todos: [ { content: '', status: 'pending', activeForm: 'Working' }, null, { status: 'pending' }, // missing content and activeForm ], }; expect(parseTodoInput(input)).toBeNull(); }); it('should return null for empty todos array', () => { expect(parseTodoInput({ todos: [] })).toBeNull(); }); it('should reject items with missing activeForm', () => { const input = { todos: [ { content: 'Task', status: 'pending' }, // no activeForm ], }; expect(parseTodoInput(input)).toBeNull(); }); it('should reject items with empty activeForm', () => { const input = { todos: [ { content: 'Task', status: 'pending', activeForm: '' }, ], }; expect(parseTodoInput(input)).toBeNull(); }); it('should reject non-object items', () => { const input = { todos: [undefined, false, 0], }; expect(parseTodoInput(input)).toBeNull(); }); }); describe('extractLastTodosFromMessages', () => { it('should extract todos from the last TodoWrite tool call', () => { const messages = [ { role: 'user', content: 'Do something', }, { role: 'assistant', toolCalls: [ { name: TOOL_TODO_WRITE, input: { todos: [ { content: 'First', status: 'completed' as const, activeForm: 'First-ing' }, ], }, }, ], }, { role: 'assistant', toolCalls: [ { name: TOOL_TODO_WRITE, input: { todos: [ { content: 'Second', status: 'pending' as const, activeForm: 'Second-ing' }, ], }, }, ], }, ]; const result = extractLastTodosFromMessages(messages); expect(result).toHaveLength(1); expect(result![0].content).toBe('Second'); }); it('should return null when no messages exist', () => { expect(extractLastTodosFromMessages([])).toBeNull(); }); it('should return null when no assistant messages have tool calls', () => { const messages = [ { role: 'user' }, { role: 'assistant' }, ]; expect(extractLastTodosFromMessages(messages)).toBeNull(); }); it('should return null when no TodoWrite tool calls exist', () => { const messages = [ { role: 'assistant', toolCalls: [ { name: 'Read', input: { file_path: '/test.txt' } }, ], }, ]; expect(extractLastTodosFromMessages(messages)).toBeNull(); }); it('should skip user messages', () => { const messages = [ { role: 'user', toolCalls: [ { name: TOOL_TODO_WRITE, input: { todos: [{ content: 'Should not find', status: 'pending', activeForm: 'Nope' }], }, }, ], }, ]; expect(extractLastTodosFromMessages(messages)).toBeNull(); }); it('should pick the last TodoWrite within a message with multiple tool calls', () => { const messages = [ { role: 'assistant', toolCalls: [ { name: TOOL_TODO_WRITE, input: { todos: [{ content: 'Earlier', status: 'pending' as const, activeForm: 'Earlier-ing' }], }, }, { name: 'Read', input: { file_path: '/test.txt' }, }, { name: TOOL_TODO_WRITE, input: { todos: [{ content: 'Later', status: 'in_progress' as const, activeForm: 'Later-ing' }], }, }, ], }, ]; const result = extractLastTodosFromMessages(messages); expect(result).toHaveLength(1); expect(result![0].content).toBe('Later'); }); it('should return null when TodoWrite has invalid input', () => { const messages = [ { role: 'assistant', toolCalls: [ { name: TOOL_TODO_WRITE, input: { todos: 'not-an-array' }, }, ], }, ]; expect(extractLastTodosFromMessages(messages)).toBeNull(); }); }); ================================================ FILE: tests/unit/core/tools/toolIcons.test.ts ================================================ import { getToolIcon, MCP_ICON_MARKER } from '@/core/tools/toolIcons'; import { TOOL_AGENT_OUTPUT, TOOL_ASK_USER_QUESTION, TOOL_BASH, TOOL_BASH_OUTPUT, TOOL_EDIT, TOOL_GLOB, TOOL_GREP, TOOL_KILL_SHELL, TOOL_LIST_MCP_RESOURCES, TOOL_LS, TOOL_MCP, TOOL_NOTEBOOK_EDIT, TOOL_READ, TOOL_READ_MCP_RESOURCE, TOOL_SKILL, TOOL_SUBAGENT_LEGACY, TOOL_TASK, TOOL_TODO_WRITE, TOOL_WEB_FETCH, TOOL_WEB_SEARCH, TOOL_WRITE, } from '@/core/tools/toolNames'; describe('MCP_ICON_MARKER', () => { it('should be defined as a special marker string', () => { expect(MCP_ICON_MARKER).toBe('__mcp_icon__'); }); }); describe('getToolIcon', () => { it.each([ [TOOL_READ, 'file-text'], [TOOL_WRITE, 'file-plus'], [TOOL_EDIT, 'file-pen'], [TOOL_NOTEBOOK_EDIT, 'file-pen'], [TOOL_BASH, 'terminal'], [TOOL_BASH_OUTPUT, 'terminal'], [TOOL_KILL_SHELL, 'terminal'], [TOOL_GLOB, 'folder-search'], [TOOL_GREP, 'search'], [TOOL_LS, 'list'], [TOOL_TODO_WRITE, 'list-checks'], [TOOL_TASK, 'bot'], [TOOL_SUBAGENT_LEGACY, 'bot'], [TOOL_LIST_MCP_RESOURCES, 'list'], [TOOL_READ_MCP_RESOURCE, 'file-text'], [TOOL_MCP, 'wrench'], [TOOL_WEB_SEARCH, 'globe'], [TOOL_WEB_FETCH, 'download'], [TOOL_AGENT_OUTPUT, 'bot'], [TOOL_ASK_USER_QUESTION, 'help-circle'], [TOOL_SKILL, 'zap'], ])('should return "%s" icon for %s tool', (tool, expectedIcon) => { expect(getToolIcon(tool)).toBe(expectedIcon); }); it('should return MCP_ICON_MARKER for mcp__ prefixed tools', () => { expect(getToolIcon('mcp__server__tool')).toBe(MCP_ICON_MARKER); expect(getToolIcon('mcp__github__search')).toBe(MCP_ICON_MARKER); expect(getToolIcon('mcp__')).toBe(MCP_ICON_MARKER); }); it('should return fallback wrench icon for unknown tools', () => { expect(getToolIcon('UnknownTool')).toBe('wrench'); expect(getToolIcon('')).toBe('wrench'); expect(getToolIcon('SomeCustomTool')).toBe('wrench'); }); it('should not match partial mcp prefix', () => { expect(getToolIcon('mcpTool')).toBe('wrench'); expect(getToolIcon('mcp_single_underscore')).toBe('wrench'); }); }); ================================================ FILE: tests/unit/core/tools/toolInput.test.ts ================================================ import { extractResolvedAnswers, extractResolvedAnswersFromResultText, getPathFromToolInput, } from '@/core/tools/toolInput'; describe('extractResolvedAnswers', () => { it('returns undefined when result is not an object', () => { expect(extractResolvedAnswers('bad')).toBeUndefined(); expect(extractResolvedAnswers(123)).toBeUndefined(); expect(extractResolvedAnswers(undefined)).toBeUndefined(); expect(extractResolvedAnswers(null)).toBeUndefined(); }); it('returns undefined when answers is missing', () => { expect(extractResolvedAnswers({})).toBeUndefined(); }); it('returns undefined when answers is not an object', () => { expect(extractResolvedAnswers({ answers: 'bad' })).toBeUndefined(); expect(extractResolvedAnswers({ answers: null })).toBeUndefined(); expect(extractResolvedAnswers({ answers: [] })).toBeUndefined(); }); it('normalizes structured answers', () => { const answers = { foo: 'bar', baz: 1, ok: true, choices: ['A', 'B'] }; expect(extractResolvedAnswers({ answers })).toEqual({ foo: 'bar', baz: '1', ok: 'true', choices: 'A, B', }); }); it('excludes empty-string answers', () => { expect(extractResolvedAnswers({ answers: { q1: 'yes', q2: '' } })).toEqual({ q1: 'yes' }); }); it('returns undefined when all answers are empty strings', () => { expect(extractResolvedAnswers({ answers: { q1: '', q2: '' } })).toBeUndefined(); }); }); describe('extractResolvedAnswersFromResultText', () => { it('returns undefined for non-string or empty values', () => { expect(extractResolvedAnswersFromResultText(undefined)).toBeUndefined(); expect(extractResolvedAnswersFromResultText(null)).toBeUndefined(); expect(extractResolvedAnswersFromResultText(123)).toBeUndefined(); expect(extractResolvedAnswersFromResultText(' ')).toBeUndefined(); }); it('extracts answers from quoted key-value pairs', () => { expect(extractResolvedAnswersFromResultText('"Color?"="Blue" "Size?"="M"')).toEqual({ 'Color?': 'Blue', 'Size?': 'M', }); }); it('extracts answers from JSON object text', () => { expect(extractResolvedAnswersFromResultText('{"Color?":"Blue","Fast?":true}')).toEqual({ 'Color?': 'Blue', 'Fast?': 'true', }); }); it('returns undefined when text cannot be parsed', () => { expect(extractResolvedAnswersFromResultText('No parsed answers here')).toBeUndefined(); }); it('excludes empty-string values in JSON object text', () => { expect(extractResolvedAnswersFromResultText('{"Color?":"Blue","Name?":""}')).toEqual({ 'Color?': 'Blue', }); }); }); describe('getPathFromToolInput', () => { describe('Read tool', () => { it('should extract file_path from Read tool input', () => { const result = getPathFromToolInput('Read', { file_path: '/path/to/file.txt' }); expect(result).toBe('/path/to/file.txt'); }); it('should return null when file_path is missing', () => { const result = getPathFromToolInput('Read', {}); expect(result).toBeNull(); }); it('should return null when file_path is empty', () => { const result = getPathFromToolInput('Read', { file_path: '' }); expect(result).toBeNull(); }); it('should fall back to notebook_path when file_path is empty', () => { const result = getPathFromToolInput('Read', { file_path: '', notebook_path: '/path/to/notebook.ipynb' }); expect(result).toBe('/path/to/notebook.ipynb'); }); }); describe('Write tool', () => { it('should extract file_path from Write tool input', () => { const result = getPathFromToolInput('Write', { file_path: '/path/to/file.txt' }); expect(result).toBe('/path/to/file.txt'); }); it('should return null when file_path is missing', () => { const result = getPathFromToolInput('Write', { content: 'some content' }); expect(result).toBeNull(); }); it('should fall back to notebook_path when file_path is missing', () => { const result = getPathFromToolInput('Write', { notebook_path: '/path/to/notebook.ipynb' }); expect(result).toBe('/path/to/notebook.ipynb'); }); }); describe('Edit tool', () => { it('should extract file_path from Edit tool input', () => { const result = getPathFromToolInput('Edit', { file_path: '/path/to/file.txt', old_string: 'old', new_string: 'new', }); expect(result).toBe('/path/to/file.txt'); }); it('should return null when file_path is missing', () => { const result = getPathFromToolInput('Edit', { old_string: 'old', new_string: 'new', }); expect(result).toBeNull(); }); it('should fall back to notebook_path when file_path is missing', () => { const result = getPathFromToolInput('Edit', { notebook_path: '/path/to/notebook.ipynb', old_string: 'old', new_string: 'new', }); expect(result).toBe('/path/to/notebook.ipynb'); }); }); describe('NotebookEdit tool', () => { it('should extract file_path from NotebookEdit tool input', () => { const result = getPathFromToolInput('NotebookEdit', { file_path: '/path/to/notebook.ipynb', }); expect(result).toBe('/path/to/notebook.ipynb'); }); it('should extract notebook_path from NotebookEdit tool input', () => { const result = getPathFromToolInput('NotebookEdit', { notebook_path: '/path/to/notebook.ipynb', }); expect(result).toBe('/path/to/notebook.ipynb'); }); it('should prefer file_path over notebook_path', () => { const result = getPathFromToolInput('NotebookEdit', { file_path: '/path/via/file_path.ipynb', notebook_path: '/path/via/notebook_path.ipynb', }); expect(result).toBe('/path/via/file_path.ipynb'); }); it('should return null when both paths are missing', () => { const result = getPathFromToolInput('NotebookEdit', { cell_number: 1 }); expect(result).toBeNull(); }); }); describe('Glob tool', () => { it('should extract path from Glob tool input', () => { const result = getPathFromToolInput('Glob', { path: '/search/path' }); expect(result).toBe('/search/path'); }); it('should extract pattern as fallback from Glob tool input', () => { const result = getPathFromToolInput('Glob', { pattern: '**/*.ts' }); expect(result).toBe('**/*.ts'); }); it('should fall back to pattern when path is empty', () => { const result = getPathFromToolInput('Glob', { path: '', pattern: '**/*.ts' }); expect(result).toBe('**/*.ts'); }); it('should prefer path over pattern', () => { const result = getPathFromToolInput('Glob', { path: '/explicit/path', pattern: '**/*.ts', }); expect(result).toBe('/explicit/path'); }); it('should return null when both path and pattern are missing', () => { const result = getPathFromToolInput('Glob', {}); expect(result).toBeNull(); }); }); describe('Grep tool', () => { it('should extract path from Grep tool input', () => { const result = getPathFromToolInput('Grep', { path: '/search/path', pattern: 'search-regex', }); expect(result).toBe('/search/path'); }); it('should return null when path is missing', () => { const result = getPathFromToolInput('Grep', { pattern: 'search-regex' }); expect(result).toBeNull(); }); it('should not use pattern as path fallback', () => { // Unlike Glob, Grep's pattern is the search regex, not a path const result = getPathFromToolInput('Grep', { pattern: 'search-regex' }); expect(result).toBeNull(); }); }); describe('LS tool', () => { it('should extract path from LS tool input', () => { const result = getPathFromToolInput('LS', { path: '/list/path' }); expect(result).toBe('/list/path'); }); it('should return null when path is missing', () => { const result = getPathFromToolInput('LS', {}); expect(result).toBeNull(); }); }); describe('unsupported tools', () => { it('should return null for Bash tool', () => { const result = getPathFromToolInput('Bash', { command: 'ls -la' }); expect(result).toBeNull(); }); it('should return null for Task tool', () => { const result = getPathFromToolInput('Task', { prompt: 'do something' }); expect(result).toBeNull(); }); it('should return null for WebSearch tool', () => { const result = getPathFromToolInput('WebSearch', { query: 'search term' }); expect(result).toBeNull(); }); it('should return null for WebFetch tool', () => { const result = getPathFromToolInput('WebFetch', { url: 'https://example.com' }); expect(result).toBeNull(); }); it('should return null for unknown tool', () => { const result = getPathFromToolInput('UnknownTool', { file_path: '/path' }); expect(result).toBeNull(); }); it('should return null for empty tool name', () => { const result = getPathFromToolInput('', { file_path: '/path' }); expect(result).toBeNull(); }); }); describe('edge cases', () => { it('should handle paths with spaces', () => { const result = getPathFromToolInput('Read', { file_path: '/path/with spaces/file.txt', }); expect(result).toBe('/path/with spaces/file.txt'); }); it('should handle Windows-style paths', () => { const result = getPathFromToolInput('Write', { file_path: 'C:\\Users\\test\\file.txt', }); expect(result).toBe('C:\\Users\\test\\file.txt'); }); it('should handle relative paths', () => { const result = getPathFromToolInput('Edit', { file_path: './relative/path.txt', }); expect(result).toBe('./relative/path.txt'); }); it('should handle paths starting with tilde', () => { const result = getPathFromToolInput('Read', { file_path: '~/Documents/file.txt', }); expect(result).toBe('~/Documents/file.txt'); }); }); }); ================================================ FILE: tests/unit/core/tools/toolNames.test.ts ================================================ import { BASH_TOOLS, // Tool arrays EDIT_TOOLS, FILE_TOOLS, isBashTool, // Type guards isEditTool, isFileTool, isMcpTool, isReadOnlyTool, isSubagentToolName, isWriteEditTool, MCP_TOOLS, READ_ONLY_TOOLS, skipsBlockedDetection, SUBAGENT_TOOL_NAMES, // Constants TOOL_AGENT_OUTPUT, TOOL_ASK_USER_QUESTION, TOOL_BASH, TOOL_BASH_OUTPUT, TOOL_EDIT, TOOL_ENTER_PLAN_MODE, TOOL_EXIT_PLAN_MODE, TOOL_GLOB, TOOL_GREP, TOOL_KILL_SHELL, TOOL_LIST_MCP_RESOURCES, TOOL_LS, TOOL_MCP, TOOL_NOTEBOOK_EDIT, TOOL_READ, TOOL_READ_MCP_RESOURCE, TOOL_SKILL, TOOL_SUBAGENT_LEGACY, TOOL_TASK, TOOL_TODO_WRITE, TOOL_TOOL_SEARCH, TOOL_WEB_FETCH, TOOL_WEB_SEARCH, TOOL_WRITE, TOOLS_SKIP_BLOCKED_DETECTION, WRITE_EDIT_TOOLS, } from '@/core/tools/toolNames'; describe('Tool Constants', () => { it('should export all tool name constants', () => { expect(TOOL_AGENT_OUTPUT).toBe('TaskOutput'); expect(TOOL_BASH).toBe('Bash'); expect(TOOL_BASH_OUTPUT).toBe('BashOutput'); expect(TOOL_EDIT).toBe('Edit'); expect(TOOL_GLOB).toBe('Glob'); expect(TOOL_GREP).toBe('Grep'); expect(TOOL_KILL_SHELL).toBe('KillShell'); expect(TOOL_LS).toBe('LS'); expect(TOOL_LIST_MCP_RESOURCES).toBe('ListMcpResources'); expect(TOOL_MCP).toBe('Mcp'); expect(TOOL_NOTEBOOK_EDIT).toBe('NotebookEdit'); expect(TOOL_READ).toBe('Read'); expect(TOOL_READ_MCP_RESOURCE).toBe('ReadMcpResource'); expect(TOOL_SKILL).toBe('Skill'); expect(TOOL_TASK).toBe('Agent'); expect(TOOL_SUBAGENT_LEGACY).toBe('Task'); expect(TOOL_TODO_WRITE).toBe('TodoWrite'); expect(TOOL_WEB_FETCH).toBe('WebFetch'); expect(TOOL_WEB_SEARCH).toBe('WebSearch'); expect(TOOL_TOOL_SEARCH).toBe('ToolSearch'); expect(TOOL_WRITE).toBe('Write'); }); }); describe('SUBAGENT_TOOL_NAMES', () => { it('should include both canonical and legacy subagent tool names', () => { expect(SUBAGENT_TOOL_NAMES).toEqual(['Agent', 'Task']); }); }); describe('isSubagentToolName', () => { it('should return true for Agent', () => { expect(isSubagentToolName('Agent')).toBe(true); }); it('should return true for legacy Task', () => { expect(isSubagentToolName('Task')).toBe(true); }); it('should return false for non-subagent tools', () => { expect(isSubagentToolName('TaskOutput')).toBe(false); expect(isSubagentToolName('TodoWrite')).toBe(false); }); }); describe('Tool Arrays', () => { describe('EDIT_TOOLS', () => { it('should contain Write, Edit, and NotebookEdit', () => { expect(EDIT_TOOLS).toContain('Write'); expect(EDIT_TOOLS).toContain('Edit'); expect(EDIT_TOOLS).toContain('NotebookEdit'); expect(EDIT_TOOLS).toHaveLength(3); }); }); describe('WRITE_EDIT_TOOLS', () => { it('should contain Write and Edit only', () => { expect(WRITE_EDIT_TOOLS).toContain('Write'); expect(WRITE_EDIT_TOOLS).toContain('Edit'); expect(WRITE_EDIT_TOOLS).toHaveLength(2); }); }); describe('BASH_TOOLS', () => { it('should contain Bash, BashOutput, and KillShell', () => { expect(BASH_TOOLS).toContain('Bash'); expect(BASH_TOOLS).toContain('BashOutput'); expect(BASH_TOOLS).toContain('KillShell'); expect(BASH_TOOLS).toHaveLength(3); }); }); describe('FILE_TOOLS', () => { it('should contain all file-related tools', () => { expect(FILE_TOOLS).toContain('Read'); expect(FILE_TOOLS).toContain('Write'); expect(FILE_TOOLS).toContain('Edit'); expect(FILE_TOOLS).toContain('Glob'); expect(FILE_TOOLS).toContain('Grep'); expect(FILE_TOOLS).toContain('LS'); expect(FILE_TOOLS).toContain('NotebookEdit'); expect(FILE_TOOLS).toContain('Bash'); expect(FILE_TOOLS).toHaveLength(8); }); }); describe('MCP_TOOLS', () => { it('should contain all MCP-related tools', () => { expect(MCP_TOOLS).toContain('ListMcpResources'); expect(MCP_TOOLS).toContain('ReadMcpResource'); expect(MCP_TOOLS).toContain('Mcp'); expect(MCP_TOOLS).toHaveLength(3); }); }); describe('READ_ONLY_TOOLS', () => { it('should contain all read-only tools', () => { expect(READ_ONLY_TOOLS).toContain('Read'); expect(READ_ONLY_TOOLS).toContain('Grep'); expect(READ_ONLY_TOOLS).toContain('Glob'); expect(READ_ONLY_TOOLS).toContain('LS'); expect(READ_ONLY_TOOLS).toContain('WebSearch'); expect(READ_ONLY_TOOLS).toContain('WebFetch'); expect(READ_ONLY_TOOLS).toHaveLength(6); }); it('should not contain write tools', () => { expect(READ_ONLY_TOOLS).not.toContain('Write'); expect(READ_ONLY_TOOLS).not.toContain('Edit'); expect(READ_ONLY_TOOLS).not.toContain('Bash'); }); }); }); describe('isEditTool', () => { it('should return true for Edit tool', () => { expect(isEditTool('Edit')).toBe(true); }); it('should return true for Write tool', () => { expect(isEditTool('Write')).toBe(true); }); it('should return true for NotebookEdit tool', () => { expect(isEditTool('NotebookEdit')).toBe(true); }); it('should return false for Read tool', () => { expect(isEditTool('Read')).toBe(false); }); it('should return false for Bash tool', () => { expect(isEditTool('Bash')).toBe(false); }); it('should return false for empty string', () => { expect(isEditTool('')).toBe(false); }); it('should return false for unknown tool', () => { expect(isEditTool('UnknownTool')).toBe(false); }); it('should be case-sensitive', () => { expect(isEditTool('edit')).toBe(false); expect(isEditTool('EDIT')).toBe(false); }); }); describe('isWriteEditTool', () => { it('should return true for Write tool', () => { expect(isWriteEditTool('Write')).toBe(true); }); it('should return true for Edit tool', () => { expect(isWriteEditTool('Edit')).toBe(true); }); it('should return false for NotebookEdit tool', () => { expect(isWriteEditTool('NotebookEdit')).toBe(false); }); it('should return false for Read tool', () => { expect(isWriteEditTool('Read')).toBe(false); }); it('should return false for empty string', () => { expect(isWriteEditTool('')).toBe(false); }); it('should return false for unknown tool', () => { expect(isWriteEditTool('UnknownTool')).toBe(false); }); }); describe('isFileTool', () => { it('should return true for Read tool', () => { expect(isFileTool('Read')).toBe(true); }); it('should return true for Write tool', () => { expect(isFileTool('Write')).toBe(true); }); it('should return true for Edit tool', () => { expect(isFileTool('Edit')).toBe(true); }); it('should return true for Glob tool', () => { expect(isFileTool('Glob')).toBe(true); }); it('should return true for Grep tool', () => { expect(isFileTool('Grep')).toBe(true); }); it('should return true for LS tool', () => { expect(isFileTool('LS')).toBe(true); }); it('should return true for NotebookEdit tool', () => { expect(isFileTool('NotebookEdit')).toBe(true); }); it('should return true for Bash tool', () => { expect(isFileTool('Bash')).toBe(true); }); it('should return false for WebSearch tool', () => { expect(isFileTool('WebSearch')).toBe(false); }); it('should return false for Task tool', () => { expect(isFileTool('Task')).toBe(false); }); it('should return false for empty string', () => { expect(isFileTool('')).toBe(false); }); it('should return false for unknown tool', () => { expect(isFileTool('UnknownTool')).toBe(false); }); }); describe('isBashTool', () => { it('should return true for Bash tool', () => { expect(isBashTool('Bash')).toBe(true); }); it('should return true for BashOutput tool', () => { expect(isBashTool('BashOutput')).toBe(true); }); it('should return true for KillShell tool', () => { expect(isBashTool('KillShell')).toBe(true); }); it('should return false for Read tool', () => { expect(isBashTool('Read')).toBe(false); }); it('should return false for Task tool', () => { expect(isBashTool('Task')).toBe(false); }); it('should return false for empty string', () => { expect(isBashTool('')).toBe(false); }); it('should return false for unknown tool', () => { expect(isBashTool('UnknownTool')).toBe(false); }); it('should be case-sensitive', () => { expect(isBashTool('bash')).toBe(false); expect(isBashTool('BASH')).toBe(false); }); }); describe('isMcpTool', () => { it('should return true for ListMcpResources tool', () => { expect(isMcpTool('ListMcpResources')).toBe(true); }); it('should return true for ReadMcpResource tool', () => { expect(isMcpTool('ReadMcpResource')).toBe(true); }); it('should return true for Mcp tool', () => { expect(isMcpTool('Mcp')).toBe(true); }); it('should return false for Read tool', () => { expect(isMcpTool('Read')).toBe(false); }); it('should return false for Bash tool', () => { expect(isMcpTool('Bash')).toBe(false); }); it('should return false for empty string', () => { expect(isMcpTool('')).toBe(false); }); it('should return false for unknown tool', () => { expect(isMcpTool('UnknownTool')).toBe(false); }); it('should return false for mcp-prefixed tool name (not in MCP_TOOLS)', () => { // MCP tools invoked via SDK have mcp__ prefix but are not in MCP_TOOLS expect(isMcpTool('mcp__server__tool')).toBe(false); }); }); describe('isReadOnlyTool', () => { it('should return true for Read tool', () => { expect(isReadOnlyTool('Read')).toBe(true); }); it('should return true for Grep tool', () => { expect(isReadOnlyTool('Grep')).toBe(true); }); it('should return true for Glob tool', () => { expect(isReadOnlyTool('Glob')).toBe(true); }); it('should return true for LS tool', () => { expect(isReadOnlyTool('LS')).toBe(true); }); it('should return true for WebSearch tool', () => { expect(isReadOnlyTool('WebSearch')).toBe(true); }); it('should return true for WebFetch tool', () => { expect(isReadOnlyTool('WebFetch')).toBe(true); }); it('should return false for Write tool', () => { expect(isReadOnlyTool('Write')).toBe(false); }); it('should return false for Edit tool', () => { expect(isReadOnlyTool('Edit')).toBe(false); }); it('should return false for Bash tool', () => { expect(isReadOnlyTool('Bash')).toBe(false); }); it('should return false for Task tool', () => { expect(isReadOnlyTool('Task')).toBe(false); }); it('should return false for empty string', () => { expect(isReadOnlyTool('')).toBe(false); }); it('should return false for unknown tool', () => { expect(isReadOnlyTool('UnknownTool')).toBe(false); }); }); describe('TOOLS_SKIP_BLOCKED_DETECTION', () => { it('should contain EnterPlanMode, ExitPlanMode, and AskUserQuestion', () => { expect(TOOLS_SKIP_BLOCKED_DETECTION).toContain(TOOL_ENTER_PLAN_MODE); expect(TOOLS_SKIP_BLOCKED_DETECTION).toContain(TOOL_EXIT_PLAN_MODE); expect(TOOLS_SKIP_BLOCKED_DETECTION).toContain(TOOL_ASK_USER_QUESTION); expect(TOOLS_SKIP_BLOCKED_DETECTION).toHaveLength(3); }); }); describe('skipsBlockedDetection', () => { it('should return true for EnterPlanMode', () => { expect(skipsBlockedDetection('EnterPlanMode')).toBe(true); }); it('should return true for ExitPlanMode', () => { expect(skipsBlockedDetection('ExitPlanMode')).toBe(true); }); it('should return true for AskUserQuestion', () => { expect(skipsBlockedDetection('AskUserQuestion')).toBe(true); }); it('should return false for regular tools', () => { expect(skipsBlockedDetection('Read')).toBe(false); expect(skipsBlockedDetection('Bash')).toBe(false); expect(skipsBlockedDetection('Write')).toBe(false); }); it('should return false for empty string', () => { expect(skipsBlockedDetection('')).toBe(false); }); it('should be case-sensitive', () => { expect(skipsBlockedDetection('enterplanmode')).toBe(false); expect(skipsBlockedDetection('EXITPLANMODE')).toBe(false); }); }); ================================================ FILE: tests/unit/core/types/mcp.test.ts ================================================ import { DEFAULT_MCP_SERVER, getMcpServerType, isValidMcpServerConfig, type McpServerConfig, } from '@/core/types/mcp'; describe('getMcpServerType', () => { it('should return "sse" for SSE config', () => { const config: McpServerConfig = { type: 'sse', url: 'https://example.com/sse' }; expect(getMcpServerType(config)).toBe('sse'); }); it('should return "http" for HTTP config', () => { const config: McpServerConfig = { type: 'http', url: 'https://example.com/api' }; expect(getMcpServerType(config)).toBe('http'); }); it('should return "http" for URL config without explicit type', () => { // URL-based config without type field defaults to http const config = { url: 'https://example.com/api' } as McpServerConfig; expect(getMcpServerType(config)).toBe('http'); }); it('should return "stdio" for command-based config', () => { const config: McpServerConfig = { command: 'node server.js' }; expect(getMcpServerType(config)).toBe('stdio'); }); it('should return "stdio" for command-based config with explicit type', () => { const config: McpServerConfig = { type: 'stdio', command: 'node server.js' }; expect(getMcpServerType(config)).toBe('stdio'); }); it('should return "stdio" for config with args and env', () => { const config: McpServerConfig = { command: 'node', args: ['server.js'], env: { PORT: '3000' }, }; expect(getMcpServerType(config)).toBe('stdio'); }); }); describe('isValidMcpServerConfig', () => { it('should return true for stdio config with command', () => { expect(isValidMcpServerConfig({ command: 'node server.js' })).toBe(true); }); it('should return true for url-based config', () => { expect(isValidMcpServerConfig({ url: 'https://example.com' })).toBe(true); }); it('should return true for SSE config', () => { expect(isValidMcpServerConfig({ type: 'sse', url: 'https://example.com/sse' })).toBe(true); }); it('should return true for HTTP config', () => { expect(isValidMcpServerConfig({ type: 'http', url: 'https://example.com/api' })).toBe(true); }); it('should return false for null', () => { expect(isValidMcpServerConfig(null)).toBe(false); }); it('should return false for undefined', () => { expect(isValidMcpServerConfig(undefined)).toBe(false); }); it('should return false for non-object', () => { expect(isValidMcpServerConfig('string')).toBe(false); expect(isValidMcpServerConfig(42)).toBe(false); expect(isValidMcpServerConfig(true)).toBe(false); }); it('should return false for empty object', () => { expect(isValidMcpServerConfig({})).toBe(false); }); it('should return false for non-string command', () => { expect(isValidMcpServerConfig({ command: 42 })).toBe(false); expect(isValidMcpServerConfig({ command: null })).toBe(false); expect(isValidMcpServerConfig({ command: true })).toBe(false); }); it('should return false for non-string url', () => { expect(isValidMcpServerConfig({ url: 42 })).toBe(false); expect(isValidMcpServerConfig({ url: null })).toBe(false); expect(isValidMcpServerConfig({ url: true })).toBe(false); }); it('should return false for empty command string', () => { expect(isValidMcpServerConfig({ command: '' })).toBe(false); }); it('should return false for empty url string', () => { expect(isValidMcpServerConfig({ url: '' })).toBe(false); }); }); describe('DEFAULT_MCP_SERVER', () => { it('should have enabled set to true', () => { expect(DEFAULT_MCP_SERVER.enabled).toBe(true); }); it('should have contextSaving set to true', () => { expect(DEFAULT_MCP_SERVER.contextSaving).toBe(true); }); }); ================================================ FILE: tests/unit/core/types/types.test.ts ================================================ import type { ChatMessage, ClaudianSettings, Conversation, ConversationMeta, EnvSnippet, LegacyPermission, StreamChunk, ToolCallInfo } from '@/core/types'; import { CONTEXT_WINDOW_1M, CONTEXT_WINDOW_STANDARD, createPermissionRule, DEFAULT_CLAUDE_MODELS, DEFAULT_SETTINGS, filterVisibleModelOptions, getBashToolBlockedCommands, getCliPlatformKey, getContextWindowSize, getCurrentPlatformBlockedCommands, getCurrentPlatformKey, getDefaultBlockedCommands, isAdaptiveThinkingModel, legacyPermissionsToCCPermissions, legacyPermissionToCCRule, normalizeVisibleModelVariant, parseCCPermissionRule, VIEW_TYPE_CLAUDIAN } from '@/core/types'; describe('types.ts', () => { describe('VIEW_TYPE_CLAUDIAN', () => { it('should be defined as the correct view type', () => { expect(VIEW_TYPE_CLAUDIAN).toBe('claudian-view'); }); }); describe('DEFAULT_SETTINGS', () => { it('should have enableBlocklist set to true by default', () => { expect(DEFAULT_SETTINGS.enableBlocklist).toBe(true); }); it('should have allowExternalAccess set to false by default', () => { expect(DEFAULT_SETTINGS.allowExternalAccess).toBe(false); }); it('should have default blocked commands as platform-keyed object', () => { expect(DEFAULT_SETTINGS.blockedCommands).toHaveProperty('unix'); expect(DEFAULT_SETTINGS.blockedCommands).toHaveProperty('windows'); expect(DEFAULT_SETTINGS.blockedCommands.unix).toBeInstanceOf(Array); expect(DEFAULT_SETTINGS.blockedCommands.windows).toBeInstanceOf(Array); expect(DEFAULT_SETTINGS.blockedCommands.unix.length).toBeGreaterThan(0); expect(DEFAULT_SETTINGS.blockedCommands.windows.length).toBeGreaterThan(0); }); it('should block rm -rf by default on Unix', () => { expect(DEFAULT_SETTINGS.blockedCommands.unix).toContain('rm -rf'); }); it('should block chmod 777 by default on Unix', () => { expect(DEFAULT_SETTINGS.blockedCommands.unix).toContain('chmod 777'); }); it('should block chmod -R 777 by default on Unix', () => { expect(DEFAULT_SETTINGS.blockedCommands.unix).toContain('chmod -R 777'); }); it('should block dangerous commands on Windows', () => { expect(DEFAULT_SETTINGS.blockedCommands.windows).toContain('Remove-Item -Recurse -Force'); expect(DEFAULT_SETTINGS.blockedCommands.windows).toContain('Format-Volume'); }); it('should only contain non-empty default blocked commands', () => { expect(DEFAULT_SETTINGS.blockedCommands.unix.every((cmd) => cmd.trim().length > 0)).toBe(true); expect(new Set(DEFAULT_SETTINGS.blockedCommands.unix).size).toBe(DEFAULT_SETTINGS.blockedCommands.unix.length); expect(DEFAULT_SETTINGS.blockedCommands.windows.every((cmd) => cmd.trim().length > 0)).toBe(true); expect(new Set(DEFAULT_SETTINGS.blockedCommands.windows).size).toBe(DEFAULT_SETTINGS.blockedCommands.windows.length); }); it('should have environmentVariables as empty string by default', () => { expect(DEFAULT_SETTINGS.environmentVariables).toBe(''); }); it('should have envSnippets as empty array by default', () => { expect(DEFAULT_SETTINGS.envSnippets).toEqual([]); }); it('should have lastClaudeModel set to haiku by default', () => { expect(DEFAULT_SETTINGS.lastClaudeModel).toBe('haiku'); }); it('should have lastCustomModel as empty string by default', () => { expect(DEFAULT_SETTINGS.lastCustomModel).toBe(''); }); }); describe('ClaudianSettings type', () => { it('should be assignable with valid settings', () => { const settings: ClaudianSettings = { userName: '', enableBlocklist: false, allowExternalAccess: false, blockedCommands: { unix: ['test'], windows: ['test-win'] }, model: 'haiku', enableAutoTitleGeneration: true, titleGenerationModel: '', thinkingBudget: 'off', permissionMode: 'yolo', excludedTags: [], mediaFolder: '', environmentVariables: '', envSnippets: [], customContextLimits: {}, systemPrompt: '', allowedExportPaths: [], persistentExternalContextPaths: [], slashCommands: [], keyboardNavigation: { scrollUpKey: 'w', scrollDownKey: 's', focusInputKey: 'i' }, locale: 'en', claudeCliPath: '', claudeCliPathsByHost: {}, loadUserClaudeSettings: false, maxTabs: 3, enableChrome: false, enableBangBash: false, enableOpus1M: false, enableSonnet1M: false, tabBarPosition: 'input', enableAutoScroll: true, openInMainTab: false, hiddenSlashCommands: [], effortLevel: 'high', }; expect(settings.enableBlocklist).toBe(false); expect(settings.blockedCommands).toEqual({ unix: ['test'], windows: ['test-win'] }); expect(settings.model).toBe('haiku'); }); it('should accept custom model strings', () => { const settings: ClaudianSettings = { userName: '', enableBlocklist: true, allowExternalAccess: false, blockedCommands: { unix: [], windows: [] }, model: 'anthropic/custom-model-v1', enableAutoTitleGeneration: true, titleGenerationModel: '', thinkingBudget: 'medium', permissionMode: 'normal', excludedTags: ['private'], mediaFolder: 'attachments', environmentVariables: 'API_KEY=test', envSnippets: [], customContextLimits: {}, systemPrompt: '', allowedExportPaths: [], persistentExternalContextPaths: [], slashCommands: [], keyboardNavigation: { scrollUpKey: 'w', scrollDownKey: 's', focusInputKey: 'i' }, locale: 'zh-CN', claudeCliPath: '', claudeCliPathsByHost: {}, loadUserClaudeSettings: false, maxTabs: 3, enableChrome: false, enableBangBash: false, enableOpus1M: false, enableSonnet1M: false, tabBarPosition: 'input', enableAutoScroll: true, openInMainTab: false, hiddenSlashCommands: [], effortLevel: 'high', }; expect(settings.model).toBe('anthropic/custom-model-v1'); }); it('should accept optional lastClaudeModel and lastCustomModel', () => { const settings: ClaudianSettings = { userName: '', enableBlocklist: true, allowExternalAccess: false, blockedCommands: { unix: [], windows: [] }, model: 'sonnet', enableAutoTitleGeneration: true, titleGenerationModel: '', lastClaudeModel: 'opus', lastCustomModel: 'custom/model', thinkingBudget: 'high', permissionMode: 'yolo', excludedTags: [], mediaFolder: '', environmentVariables: '', envSnippets: [], customContextLimits: {}, systemPrompt: '', allowedExportPaths: [], persistentExternalContextPaths: [], slashCommands: [], keyboardNavigation: { scrollUpKey: 'w', scrollDownKey: 's', focusInputKey: 'i' }, locale: 'en', claudeCliPath: '', claudeCliPathsByHost: {}, loadUserClaudeSettings: false, maxTabs: 5, enableChrome: false, enableBangBash: false, enableOpus1M: false, enableSonnet1M: false, tabBarPosition: 'header', enableAutoScroll: false, openInMainTab: false, hiddenSlashCommands: [], effortLevel: 'high', }; expect(settings.lastClaudeModel).toBe('opus'); expect(settings.lastCustomModel).toBe('custom/model'); }); }); describe('EnvSnippet type', () => { it('should store all required fields', () => { const snippet: EnvSnippet = { id: 'snippet-123', name: 'Production Config', description: 'Production environment variables', envVars: 'API_KEY=prod-key\nDEBUG=false', }; expect(snippet.id).toBe('snippet-123'); expect(snippet.name).toBe('Production Config'); expect(snippet.description).toBe('Production environment variables'); expect(snippet.envVars).toContain('API_KEY=prod-key'); }); it('should allow empty description', () => { const snippet: EnvSnippet = { id: 'snippet-789', name: 'Quick Config', description: '', envVars: 'KEY=value', }; expect(snippet.description).toBe(''); }); }); describe('ChatMessage type', () => { it('should accept user role', () => { const msg: ChatMessage = { id: 'msg-1', role: 'user', content: 'Hello', timestamp: Date.now(), }; expect(msg.role).toBe('user'); }); it('should accept assistant role', () => { const msg: ChatMessage = { id: 'msg-1', role: 'assistant', content: 'Hi there!', timestamp: Date.now(), }; expect(msg.role).toBe('assistant'); }); it('should accept optional toolCalls array', () => { const toolCalls: ToolCallInfo[] = [ { id: 'tool-1', name: 'Read', input: { file_path: '/test.txt' }, status: 'completed', result: 'file contents', }, ]; const msg: ChatMessage = { id: 'msg-1', role: 'assistant', content: 'Reading file...', timestamp: Date.now(), toolCalls, }; expect(msg.toolCalls).toEqual(toolCalls); }); }); describe('ToolCallInfo type', () => { it('should store tool name, input, status, and result', () => { const toolCall: ToolCallInfo = { id: 'tool-123', name: 'Bash', input: { command: 'ls -la' }, status: 'completed', result: 'file1.txt\nfile2.txt', }; expect(toolCall.id).toBe('tool-123'); expect(toolCall.name).toBe('Bash'); expect(toolCall.input).toEqual({ command: 'ls -la' }); expect(toolCall.status).toBe('completed'); expect(toolCall.result).toBe('file1.txt\nfile2.txt'); }); it('should accept running status', () => { const toolCall: ToolCallInfo = { id: 'tool-123', name: 'Read', input: { file_path: '/test.txt' }, status: 'running', }; expect(toolCall.status).toBe('running'); }); it('should accept error status', () => { const toolCall: ToolCallInfo = { id: 'tool-123', name: 'Read', input: { file_path: '/test.txt' }, status: 'error', result: 'File not found', }; expect(toolCall.status).toBe('error'); }); }); describe('StreamChunk type', () => { it('should accept text type', () => { const chunk: StreamChunk = { type: 'text', content: 'Hello world', }; expect(chunk.type).toBe('text'); // eslint-disable-next-line jest/no-conditional-expect if (chunk.type === 'text') expect(chunk.content).toBe('Hello world'); }); it('should accept tool_use type', () => { const chunk: StreamChunk = { type: 'tool_use', id: 'tool-123', name: 'Read', input: { file_path: '/test.txt' }, }; expect(chunk.type).toBe('tool_use'); if (chunk.type === 'tool_use') { // Type narrowing block - eslint-disable-next-line jest/no-conditional-expect expect(chunk.id).toBe('tool-123'); // eslint-disable-line jest/no-conditional-expect expect(chunk.name).toBe('Read'); // eslint-disable-line jest/no-conditional-expect expect(chunk.input).toEqual({ file_path: '/test.txt' }); // eslint-disable-line jest/no-conditional-expect } }); it('should accept tool_result type', () => { const chunk: StreamChunk = { type: 'tool_result', id: 'tool-123', content: 'File contents here', }; expect(chunk.type).toBe('tool_result'); if (chunk.type === 'tool_result') { expect(chunk.id).toBe('tool-123'); // eslint-disable-line jest/no-conditional-expect expect(chunk.content).toBe('File contents here'); // eslint-disable-line jest/no-conditional-expect } }); it('should accept error type', () => { const chunk: StreamChunk = { type: 'error', content: 'Something went wrong', }; expect(chunk.type).toBe('error'); // eslint-disable-next-line jest/no-conditional-expect if (chunk.type === 'error') expect(chunk.content).toBe('Something went wrong'); }); it('should accept blocked type', () => { const chunk: StreamChunk = { type: 'blocked', content: 'Command blocked: rm -rf', }; expect(chunk.type).toBe('blocked'); // eslint-disable-next-line jest/no-conditional-expect if (chunk.type === 'blocked') expect(chunk.content).toBe('Command blocked: rm -rf'); }); it('should accept done type', () => { const chunk: StreamChunk = { type: 'done', }; expect(chunk.type).toBe('done'); }); }); describe('Conversation type', () => { it('should store conversation with all required fields', () => { const conversation: Conversation = { id: 'conv-123', title: 'Test Conversation', createdAt: 1700000000000, updatedAt: 1700000001000, sessionId: 'session-abc', messages: [], }; expect(conversation.id).toBe('conv-123'); expect(conversation.title).toBe('Test Conversation'); expect(conversation.createdAt).toBe(1700000000000); expect(conversation.updatedAt).toBe(1700000001000); expect(conversation.sessionId).toBe('session-abc'); expect(conversation.messages).toEqual([]); }); it('should allow null sessionId for new conversations', () => { const conversation: Conversation = { id: 'conv-456', title: 'New Chat', createdAt: Date.now(), updatedAt: Date.now(), sessionId: null, messages: [], }; expect(conversation.sessionId).toBeNull(); }); it('should store messages array with ChatMessage objects', () => { const messages: ChatMessage[] = [ { id: 'msg-1', role: 'user', content: 'Hello', timestamp: Date.now() }, { id: 'msg-2', role: 'assistant', content: 'Hi there!', timestamp: Date.now() }, ]; const conversation: Conversation = { id: 'conv-789', title: 'Chat with Messages', createdAt: Date.now(), updatedAt: Date.now(), sessionId: 'session-xyz', messages, }; expect(conversation.messages).toHaveLength(2); expect(conversation.messages[0].role).toBe('user'); expect(conversation.messages[1].role).toBe('assistant'); }); }); describe('ConversationMeta type', () => { it('should store conversation metadata without messages', () => { const meta: ConversationMeta = { id: 'conv-123', title: 'Test Conversation', createdAt: 1700000000000, updatedAt: 1700000001000, messageCount: 5, preview: 'Hello, how can I...', }; expect(meta.id).toBe('conv-123'); expect(meta.title).toBe('Test Conversation'); expect(meta.createdAt).toBe(1700000000000); expect(meta.updatedAt).toBe(1700000001000); expect(meta.messageCount).toBe(5); expect(meta.preview).toBe('Hello, how can I...'); }); it('should have preview for empty conversations', () => { const meta: ConversationMeta = { id: 'conv-empty', title: 'Empty Chat', createdAt: Date.now(), updatedAt: Date.now(), messageCount: 0, preview: 'New conversation', }; expect(meta.messageCount).toBe(0); expect(meta.preview).toBe('New conversation'); }); }); describe('Platform CLI helpers (deprecated)', () => { describe('getCliPlatformKey', () => { it('should return a valid platform key', () => { const key = getCliPlatformKey(); expect(['macos', 'linux', 'windows']).toContain(key); }); it('should return consistent results', () => { const key1 = getCliPlatformKey(); const key2 = getCliPlatformKey(); expect(key1).toBe(key2); }); }); describe('getCliPlatformKey with mocked platforms', () => { const originalPlatform = process.platform; afterEach(() => { Object.defineProperty(process, 'platform', { value: originalPlatform }); }); it('should return macos for darwin', () => { Object.defineProperty(process, 'platform', { value: 'darwin' }); expect(getCliPlatformKey()).toBe('macos'); }); it('should return windows for win32', () => { Object.defineProperty(process, 'platform', { value: 'win32' }); expect(getCliPlatformKey()).toBe('windows'); }); it('should return linux for linux', () => { Object.defineProperty(process, 'platform', { value: 'linux' }); expect(getCliPlatformKey()).toBe('linux'); }); it('should return linux for unknown platform', () => { Object.defineProperty(process, 'platform', { value: 'freebsd' }); expect(getCliPlatformKey()).toBe('linux'); }); }); describe('DEFAULT_SETTINGS.claudeCliPathsByHost', () => { it('should have empty hostname-based CLI paths by default', () => { expect(DEFAULT_SETTINGS.claudeCliPathsByHost).toBeDefined(); expect(DEFAULT_SETTINGS.claudeCliPathsByHost).toEqual({}); }); }); }); describe('Blocked commands helpers', () => { describe('getDefaultBlockedCommands', () => { it('returns fresh copies each call', () => { const a = getDefaultBlockedCommands(); const b = getDefaultBlockedCommands(); expect(a).toEqual(b); expect(a).not.toBe(b); expect(a.unix).not.toBe(b.unix); }); }); describe('getCurrentPlatformKey', () => { const originalPlatform = process.platform; afterEach(() => { Object.defineProperty(process, 'platform', { value: originalPlatform }); }); it('returns unix for non-Windows platforms', () => { Object.defineProperty(process, 'platform', { value: 'darwin' }); expect(getCurrentPlatformKey()).toBe('unix'); }); it('returns windows for win32', () => { Object.defineProperty(process, 'platform', { value: 'win32' }); expect(getCurrentPlatformKey()).toBe('windows'); }); }); describe('getCurrentPlatformBlockedCommands', () => { it('returns commands for current platform', () => { const commands = getDefaultBlockedCommands(); const result = getCurrentPlatformBlockedCommands(commands); expect(Array.isArray(result)).toBe(true); expect(result.length).toBeGreaterThan(0); }); }); describe('getBashToolBlockedCommands', () => { const originalPlatform = process.platform; afterEach(() => { Object.defineProperty(process, 'platform', { value: originalPlatform }); }); it('returns unix commands on non-Windows', () => { Object.defineProperty(process, 'platform', { value: 'darwin' }); const commands = getDefaultBlockedCommands(); const result = getBashToolBlockedCommands(commands); expect(result).toEqual(commands.unix); }); it('returns merged unix and windows commands on Windows', () => { Object.defineProperty(process, 'platform', { value: 'win32' }); const commands = getDefaultBlockedCommands(); const result = getBashToolBlockedCommands(commands); // Should contain commands from both platforms for (const cmd of commands.unix) { expect(result).toContain(cmd); } for (const cmd of commands.windows) { expect(result).toContain(cmd); } // Should be deduplicated expect(new Set(result).size).toBe(result.length); }); }); }); describe('Permission Conversion Utilities', () => { describe('legacyPermissionToCCRule', () => { it('should convert Bash permission with pattern', () => { const legacy: LegacyPermission = { toolName: 'Bash', pattern: 'git status', approvedAt: Date.now(), scope: 'always', }; expect(legacyPermissionToCCRule(legacy)).toBe('Bash(git status)'); }); it('should convert Read permission with file path', () => { const legacy: LegacyPermission = { toolName: 'Read', pattern: '/path/to/file.txt', approvedAt: Date.now(), scope: 'always', }; expect(legacyPermissionToCCRule(legacy)).toBe('Read(/path/to/file.txt)'); }); it('should return just tool name for wildcard pattern', () => { const legacy: LegacyPermission = { toolName: 'WebSearch', pattern: '*', approvedAt: Date.now(), scope: 'always', }; expect(legacyPermissionToCCRule(legacy)).toBe('WebSearch'); }); it('should return just tool name for empty pattern', () => { const legacy: LegacyPermission = { toolName: 'Glob', pattern: '', approvedAt: Date.now(), scope: 'always', }; expect(legacyPermissionToCCRule(legacy)).toBe('Glob'); }); it('should return just tool name for JSON object pattern (legacy format)', () => { const legacy: LegacyPermission = { toolName: 'CustomTool', pattern: '{"key":"value"}', approvedAt: Date.now(), scope: 'always', }; expect(legacyPermissionToCCRule(legacy)).toBe('CustomTool'); }); }); describe('legacyPermissionsToCCPermissions', () => { it('should convert array of legacy permissions to CC format', () => { const legacy: LegacyPermission[] = [ { toolName: 'Bash', pattern: 'git *', approvedAt: Date.now(), scope: 'always' }, { toolName: 'Read', pattern: '/vault', approvedAt: Date.now(), scope: 'always' }, ]; const result = legacyPermissionsToCCPermissions(legacy); expect(result.allow).toEqual(['Bash(git *)', 'Read(/vault)']); expect(result.deny).toEqual([]); expect(result.ask).toEqual([]); }); it('should skip session-scoped permissions', () => { const legacy: LegacyPermission[] = [ { toolName: 'Bash', pattern: 'npm test', approvedAt: Date.now(), scope: 'always' }, { toolName: 'Bash', pattern: 'rm temp.txt', approvedAt: Date.now(), scope: 'session' }, ]; const result = legacyPermissionsToCCPermissions(legacy); expect(result.allow).toEqual(['Bash(npm test)']); }); it('should deduplicate rules', () => { const legacy: LegacyPermission[] = [ { toolName: 'Read', pattern: '*', approvedAt: Date.now(), scope: 'always' }, { toolName: 'Read', pattern: '*', approvedAt: Date.now() + 1000, scope: 'always' }, ]; const result = legacyPermissionsToCCPermissions(legacy); expect(result.allow).toEqual(['Read']); }); it('should return empty arrays for empty input', () => { const result = legacyPermissionsToCCPermissions([]); expect(result.allow).toEqual([]); expect(result.deny).toEqual([]); expect(result.ask).toEqual([]); }); }); describe('parseCCPermissionRule', () => { it('should parse rule with pattern', () => { const result = parseCCPermissionRule(createPermissionRule('Bash(git status)')); expect(result.tool).toBe('Bash'); expect(result.pattern).toBe('git status'); }); it('should parse rule with complex pattern', () => { const result = parseCCPermissionRule(createPermissionRule('WebFetch(domain:github.com)')); expect(result.tool).toBe('WebFetch'); expect(result.pattern).toBe('domain:github.com'); }); it('should parse rule without pattern', () => { const result = parseCCPermissionRule(createPermissionRule('Read')); expect(result.tool).toBe('Read'); expect(result.pattern).toBeUndefined(); }); it('should handle nested parentheses in pattern', () => { const result = parseCCPermissionRule(createPermissionRule('Bash(echo "hello (world)")')); expect(result.tool).toBe('Bash'); expect(result.pattern).toBe('echo "hello (world)"'); }); it('should handle path patterns', () => { const result = parseCCPermissionRule(createPermissionRule('Read(/Users/test/vault/notes)')); expect(result.tool).toBe('Read'); expect(result.pattern).toBe('/Users/test/vault/notes'); }); it('should return rule as tool for malformed input', () => { const result = parseCCPermissionRule(createPermissionRule('not-valid-format')); expect(result.tool).toBe('not-valid-format'); expect(result.pattern).toBeUndefined(); }); }); }); describe('getContextWindowSize', () => { it('should return standard context window by default', () => { expect(getContextWindowSize('sonnet')).toBe(CONTEXT_WINDOW_STANDARD); expect(getContextWindowSize('opus')).toBe(CONTEXT_WINDOW_STANDARD); expect(getContextWindowSize('haiku')).toBe(CONTEXT_WINDOW_STANDARD); }); it('should use custom limits when provided', () => { const customLimits = { 'custom-model': 256000 }; expect(getContextWindowSize('custom-model', customLimits)).toBe(256000); }); it('should fall back to default when model not in custom limits', () => { const customLimits = { 'other-model': 256000 }; expect(getContextWindowSize('sonnet', customLimits)).toBe(CONTEXT_WINDOW_STANDARD); }); it('should handle empty custom limits object', () => { expect(getContextWindowSize('sonnet', {})).toBe(CONTEXT_WINDOW_STANDARD); }); it('should handle undefined custom limits', () => { expect(getContextWindowSize('sonnet', undefined)).toBe(CONTEXT_WINDOW_STANDARD); }); describe('defensive validation for invalid custom limit values', () => { it('should fall back to default for NaN custom limit', () => { const customLimits = { 'custom-model': NaN }; expect(getContextWindowSize('custom-model', customLimits)).toBe(CONTEXT_WINDOW_STANDARD); }); it('should fall back to default for negative custom limit', () => { const customLimits = { 'custom-model': -100000 }; expect(getContextWindowSize('custom-model', customLimits)).toBe(CONTEXT_WINDOW_STANDARD); }); it('should fall back to default for zero custom limit', () => { const customLimits = { 'custom-model': 0 }; expect(getContextWindowSize('custom-model', customLimits)).toBe(CONTEXT_WINDOW_STANDARD); }); it('should fall back to default for Infinity custom limit', () => { const customLimits = { 'custom-model': Infinity }; expect(getContextWindowSize('custom-model', customLimits)).toBe(CONTEXT_WINDOW_STANDARD); }); it('should fall back to default for -Infinity custom limit', () => { const customLimits = { 'custom-model': -Infinity }; expect(getContextWindowSize('custom-model', customLimits)).toBe(CONTEXT_WINDOW_STANDARD); }); it('should accept valid positive custom limit', () => { const customLimits = { 'custom-model': 256000 }; expect(getContextWindowSize('custom-model', customLimits)).toBe(256000); }); }); describe('[1m] suffix detection', () => { it('should return 1M context window for models with [1m] suffix', () => { expect(getContextWindowSize('opus[1m]')).toBe(CONTEXT_WINDOW_1M); expect(getContextWindowSize('sonnet[1m]')).toBe(CONTEXT_WINDOW_1M); }); it('should return 1M for full model IDs with [1m] suffix', () => { expect(getContextWindowSize('claude-opus-4-6[1m]')).toBe(CONTEXT_WINDOW_1M); expect(getContextWindowSize('claude-sonnet-4-6[1m]')).toBe(CONTEXT_WINDOW_1M); }); it('should prefer custom limits over [1m] suffix', () => { const customLimits = { 'opus[1m]': 500000 }; expect(getContextWindowSize('opus[1m]', customLimits)).toBe(500000); }); it('should return standard for models without [1m] suffix', () => { expect(getContextWindowSize('opus')).toBe(CONTEXT_WINDOW_STANDARD); expect(getContextWindowSize('sonnet')).toBe(CONTEXT_WINDOW_STANDARD); }); }); describe('filterVisibleModelOptions', () => { it('should hide 1M variants when toggles are disabled', () => { const models = filterVisibleModelOptions(DEFAULT_CLAUDE_MODELS, false, false).map((model) => model.value); expect(models).toEqual(['haiku', 'sonnet', 'opus']); }); it('should swap in 1M variants when toggles are enabled', () => { const models = filterVisibleModelOptions(DEFAULT_CLAUDE_MODELS, true, true).map((model) => model.value); expect(models).toEqual(['haiku', 'sonnet[1m]', 'opus[1m]']); }); it('should swap only opus when enableOpus1M is true and enableSonnet1M is false', () => { const models = filterVisibleModelOptions(DEFAULT_CLAUDE_MODELS, true, false).map((model) => model.value); expect(models).toEqual(['haiku', 'sonnet', 'opus[1m]']); }); it('should swap only sonnet when enableSonnet1M is true and enableOpus1M is false', () => { const models = filterVisibleModelOptions(DEFAULT_CLAUDE_MODELS, false, true).map((model) => model.value); expect(models).toEqual(['haiku', 'sonnet[1m]', 'opus']); }); }); describe('normalizeVisibleModelVariant', () => { it('should normalize built-in variants to the visible option', () => { expect(normalizeVisibleModelVariant('sonnet', true, true)).toBe('sonnet[1m]'); expect(normalizeVisibleModelVariant('sonnet[1m]', false, false)).toBe('sonnet'); expect(normalizeVisibleModelVariant('opus', true, false)).toBe('opus[1m]'); expect(normalizeVisibleModelVariant('opus[1m]', false, true)).toBe('opus'); }); it('should leave unrelated model ids unchanged', () => { expect(normalizeVisibleModelVariant('', true, true)).toBe(''); expect(normalizeVisibleModelVariant('haiku', true, true)).toBe('haiku'); expect(normalizeVisibleModelVariant('custom-model', true, true)).toBe('custom-model'); }); }); }); describe('isAdaptiveThinkingModel', () => { it('should return true for default model aliases', () => { expect(isAdaptiveThinkingModel('haiku')).toBe(true); expect(isAdaptiveThinkingModel('sonnet')).toBe(true); expect(isAdaptiveThinkingModel('sonnet[1m]')).toBe(true); expect(isAdaptiveThinkingModel('opus')).toBe(true); expect(isAdaptiveThinkingModel('opus[1m]')).toBe(true); }); it('should return true for full Claude model IDs', () => { expect(isAdaptiveThinkingModel('claude-sonnet-4-6-20250514')).toBe(true); expect(isAdaptiveThinkingModel('claude-opus-4-6-20250514')).toBe(true); expect(isAdaptiveThinkingModel('claude-haiku-4-5-20251001')).toBe(true); }); it('should return false for custom/unknown models', () => { expect(isAdaptiveThinkingModel('custom-model')).toBe(false); expect(isAdaptiveThinkingModel('gpt-4')).toBe(false); expect(isAdaptiveThinkingModel('')).toBe(false); }); it('should return true for provider-qualified Claude model IDs', () => { expect(isAdaptiveThinkingModel('us.anthropic.claude-sonnet-4-20250514-v1:0')).toBe(true); expect(isAdaptiveThinkingModel('anthropic/claude-opus-4-6')).toBe(true); expect(isAdaptiveThinkingModel('eu.anthropic.claude-haiku-4-5-20251001-v1:0')).toBe(true); }); it('should return false for partial model IDs without version suffix', () => { expect(isAdaptiveThinkingModel('claude-haiku')).toBe(false); expect(isAdaptiveThinkingModel('claude-sonnet')).toBe(false); expect(isAdaptiveThinkingModel('claude-opus')).toBe(false); }); it('should return true for full versioned 1M model IDs', () => { expect(isAdaptiveThinkingModel('claude-opus-4-6[1m]')).toBe(true); expect(isAdaptiveThinkingModel('claude-sonnet-4-6[1m]')).toBe(true); }); }); }); ================================================ FILE: tests/unit/features/chat/controllers/BrowserSelectionController.test.ts ================================================ /** @jest-environment jsdom */ import { BrowserSelectionController } from '@/features/chat/controllers/BrowserSelectionController'; function createMockIndicator() { const indicatorEl = document.createElement('div'); indicatorEl.style.display = 'none'; return indicatorEl; } function createMockContextRow(browserIndicator: HTMLElement) { const fileIndicator = { style: { display: 'none' } }; const imagePreview = { style: { display: 'none' } }; const elements: Record<string, any> = { '.claudian-selection-indicator': { style: { display: 'none' } }, '.claudian-browser-selection-indicator': browserIndicator, '.claudian-canvas-indicator': { style: { display: 'none' } }, '.claudian-file-indicator': fileIndicator, '.claudian-image-preview': imagePreview, }; return { classList: { toggle: jest.fn(), }, querySelector: jest.fn((selector: string) => elements[selector] ?? null), } as any; } async function flushMicrotasks(): Promise<void> { await Promise.resolve(); await Promise.resolve(); } describe('BrowserSelectionController', () => { let controller: BrowserSelectionController; let app: any; let indicatorEl: any; let inputEl: HTMLTextAreaElement; let contextRowEl: any; let containerEl: HTMLElement; let selectionText = 'selected web snippet'; let getSelectionSpy: jest.SpyInstance; beforeEach(() => { jest.useFakeTimers(); selectionText = 'selected web snippet'; indicatorEl = createMockIndicator(); inputEl = document.createElement('textarea'); document.body.appendChild(inputEl); contextRowEl = createMockContextRow(indicatorEl); containerEl = document.createElement('div'); const selectionAnchor = document.createElement('span'); containerEl.appendChild(selectionAnchor); getSelectionSpy = jest.spyOn(document, 'getSelection').mockImplementation(() => ({ toString: () => selectionText, anchorNode: selectionAnchor, focusNode: selectionAnchor, } as unknown as Selection)); const view = { getViewType: () => 'surfing-view', getDisplayText: () => 'Surfing', containerEl, currentUrl: 'https://example.com', }; app = { workspace: { activeLeaf: { view }, getMostRecentLeaf: jest.fn(() => ({ view })), }, }; controller = new BrowserSelectionController(app, indicatorEl, inputEl, contextRowEl); }); afterEach(() => { controller.stop(); inputEl.remove(); getSelectionSpy.mockRestore(); jest.useRealTimers(); }); it('captures browser selection and updates indicator', async () => { controller.start(); jest.advanceTimersByTime(250); await flushMicrotasks(); expect(controller.getContext()).toEqual({ source: 'browser:https://example.com', selectedText: 'selected web snippet', title: 'Surfing', url: 'https://example.com', }); expect(indicatorEl.style.display).toBe('block'); expect(indicatorEl.textContent).toBe('1 line selected'); expect(indicatorEl.textContent).not.toContain('source='); expect(indicatorEl.getAttribute('title')).toContain('chars selected'); expect(indicatorEl.getAttribute('title')).toContain('source=browser:https://example.com'); expect(indicatorEl.getAttribute('title')).toContain('title=Surfing'); expect(indicatorEl.getAttribute('title')).toContain('https://example.com'); }); it('shows line-based indicator text for multi-line browser selection', async () => { selectionText = 'line 1\nline 2'; controller.start(); jest.advanceTimersByTime(250); await flushMicrotasks(); expect(indicatorEl.textContent).toBe('2 lines selected'); }); it('clears selection when text is deselected and input is not focused', async () => { controller.start(); jest.advanceTimersByTime(250); await flushMicrotasks(); expect(controller.hasSelection()).toBe(true); selectionText = ''; jest.advanceTimersByTime(250); await flushMicrotasks(); expect(controller.hasSelection()).toBe(false); expect(indicatorEl.style.display).toBe('none'); }); it('keeps selection while input is focused', async () => { controller.start(); jest.advanceTimersByTime(250); await flushMicrotasks(); expect(controller.hasSelection()).toBe(true); selectionText = ''; inputEl.focus(); jest.advanceTimersByTime(250); await flushMicrotasks(); expect(controller.hasSelection()).toBe(true); }); it('clears selection when clear is called', async () => { controller.start(); jest.advanceTimersByTime(250); await flushMicrotasks(); expect(controller.hasSelection()).toBe(true); controller.clear(); expect(controller.hasSelection()).toBe(false); expect(indicatorEl.style.display).toBe('none'); }); it('handles polling errors without unhandled rejection', async () => { const extractSpy = jest.spyOn(controller as any, 'extractSelectedText') .mockRejectedValueOnce(new Error('poll failed')); controller.start(); jest.advanceTimersByTime(250); await flushMicrotasks(); expect(extractSpy).toHaveBeenCalled(); expect(controller.hasSelection()).toBe(false); }); }); ================================================ FILE: tests/unit/features/chat/controllers/CanvasSelectionController.test.ts ================================================ import { CanvasSelectionController } from '@/features/chat/controllers/CanvasSelectionController'; function createMockIndicator() { return { textContent: '', style: { display: 'none' }, } as any; } function createMockContextRow() { const elements: Record<string, any> = { '.claudian-selection-indicator': { style: { display: 'none' } }, '.claudian-canvas-indicator': { style: { display: 'none' } }, '.claudian-file-indicator': null, '.claudian-image-preview': null, }; return { classList: { toggle: jest.fn(), }, querySelector: jest.fn((selector: string) => elements[selector] ?? null), } as any; } function createMockCanvasNode(id: string) { return { id }; } describe('CanvasSelectionController', () => { let controller: CanvasSelectionController; let app: any; let indicatorEl: any; let inputEl: any; let contextRowEl: any; let canvasView: any; let originalDocument: any; beforeEach(() => { jest.useFakeTimers(); indicatorEl = createMockIndicator(); inputEl = {}; contextRowEl = createMockContextRow(); const node1 = createMockCanvasNode('abc123'); const node2 = createMockCanvasNode('def456'); canvasView = { getViewType: () => 'canvas', canvas: { selection: new Set([node1, node2]), }, file: { path: 'my-canvas.canvas' }, }; app = { workspace: { getActiveViewOfType: jest.fn().mockReturnValue(null), getLeavesOfType: jest.fn().mockReturnValue([{ view: canvasView }]), }, }; controller = new CanvasSelectionController(app, indicatorEl, inputEl, contextRowEl); originalDocument = (global as any).document; (global as any).document = { activeElement: null }; }); afterEach(() => { controller.stop(); jest.useRealTimers(); (global as any).document = originalDocument; }); it('captures canvas selection and updates indicator', () => { controller.start(); jest.advanceTimersByTime(250); expect(controller.hasSelection()).toBe(true); expect(controller.getContext()).toEqual({ canvasPath: 'my-canvas.canvas', nodeIds: expect.arrayContaining(['abc123', 'def456']), }); expect(indicatorEl.textContent).toBe('2 nodes selected'); expect(indicatorEl.style.display).toBe('block'); }); it('shows node ID for single selection', () => { const singleNode = createMockCanvasNode('single1'); canvasView.canvas.selection = new Set([singleNode]); controller.start(); jest.advanceTimersByTime(250); expect(controller.getContext()?.nodeIds).toEqual(['single1']); expect(indicatorEl.textContent).toBe('node "single1" selected'); }); it('clears selection when no nodes selected and input not focused', () => { controller.start(); jest.advanceTimersByTime(250); expect(controller.hasSelection()).toBe(true); canvasView.canvas.selection = new Set(); (global as any).document.activeElement = null; jest.advanceTimersByTime(250); expect(controller.hasSelection()).toBe(false); expect(indicatorEl.style.display).toBe('none'); }); it('preserves selection when input is focused (sticky)', () => { controller.start(); jest.advanceTimersByTime(250); expect(controller.hasSelection()).toBe(true); canvasView.canvas.selection = new Set(); (global as any).document.activeElement = inputEl; jest.advanceTimersByTime(250); expect(controller.hasSelection()).toBe(true); expect(indicatorEl.textContent).toBe('2 nodes selected'); }); it('returns null context when no selection', () => { canvasView.canvas.selection = new Set(); controller.start(); jest.advanceTimersByTime(250); expect(controller.getContext()).toBeNull(); }); it('does not update when selection unchanged', () => { controller.start(); jest.advanceTimersByTime(250); contextRowEl.classList.toggle.mockClear(); jest.advanceTimersByTime(250); // toggle should not be called again (no change) expect(contextRowEl.classList.toggle).not.toHaveBeenCalled(); }); it('keeps context row visible when editor selection indicator is visible', () => { const editorIndicator = { style: { display: 'block' } }; contextRowEl.querySelector.mockImplementation((selector: string) => { if (selector === '.claudian-selection-indicator') return editorIndicator; return null; }); controller.updateContextRowVisibility(); expect(contextRowEl.classList.toggle).toHaveBeenCalledWith('has-content', true); }); it('prefers active canvas leaf when multiple canvases are open', () => { const activeNode = createMockCanvasNode('active-node'); const inactiveNode = createMockCanvasNode('inactive-node'); const inactiveCanvasView = { getViewType: () => 'canvas', canvas: { selection: new Set([inactiveNode]) }, file: { path: 'inactive.canvas' }, }; const activeCanvasView = { getViewType: () => 'canvas', canvas: { selection: new Set([activeNode]) }, file: { path: 'active.canvas' }, }; app.workspace.getLeavesOfType.mockReturnValue([ { view: inactiveCanvasView }, { view: activeCanvasView }, ]); app.workspace.activeLeaf = { view: activeCanvasView }; controller.start(); jest.advanceTimersByTime(250); expect(controller.getContext()).toEqual({ canvasPath: 'active.canvas', nodeIds: ['active-node'], }); }); it('handles no canvas view gracefully', () => { app.workspace.activeLeaf = null; app.workspace.getLeavesOfType.mockReturnValue([]); controller.start(); jest.advanceTimersByTime(250); expect(controller.hasSelection()).toBe(false); expect(controller.getContext()).toBeNull(); }); it('clear() resets state and indicator', () => { controller.start(); jest.advanceTimersByTime(250); expect(controller.hasSelection()).toBe(true); controller.clear(); expect(controller.hasSelection()).toBe(false); expect(indicatorEl.style.display).toBe('none'); }); }); ================================================ FILE: tests/unit/features/chat/controllers/ConversationController.test.ts ================================================ import { createMockEl } from '@test/helpers/mockElement'; import { Notice } from 'obsidian'; import { ConversationController, type ConversationControllerDeps } from '@/features/chat/controllers/ConversationController'; import { ChatState } from '@/features/chat/state/ChatState'; import { confirm } from '@/shared/modals/ConfirmModal'; jest.mock('@/shared/modals/ConfirmModal', () => ({ confirm: jest.fn().mockResolvedValue(true), })); const mockNotice = Notice as jest.Mock; function createMockDeps(overrides: Partial<ConversationControllerDeps> = {}): ConversationControllerDeps { const state = new ChatState(); const inputEl = { value: '', focus: jest.fn() } as unknown as HTMLTextAreaElement; const historyDropdown = createMockEl(); let welcomeEl: any = createMockEl(); const messagesEl = createMockEl(); const fileContextManager = { resetForNewConversation: jest.fn(), resetForLoadedConversation: jest.fn(), autoAttachActiveFile: jest.fn(), setCurrentNote: jest.fn(), getCurrentNotePath: jest.fn().mockReturnValue(null), }; return { plugin: { createConversation: jest.fn().mockResolvedValue({ id: 'new-conv', title: 'New Conversation', messages: [], sessionId: null, createdAt: Date.now(), updatedAt: Date.now(), }), switchConversation: jest.fn().mockResolvedValue({ id: 'switched-conv', title: 'Switched Conversation', messages: [], sessionId: null, createdAt: Date.now(), updatedAt: Date.now(), }), getConversationById: jest.fn().mockResolvedValue(null), getConversationList: jest.fn().mockReturnValue([]), findEmptyConversation: jest.fn().mockResolvedValue(null), updateConversation: jest.fn().mockResolvedValue(undefined), renameConversation: jest.fn().mockResolvedValue(undefined), deleteConversation: jest.fn().mockResolvedValue(undefined), agentService: { getSessionId: jest.fn().mockResolvedValue(null), setSessionId: jest.fn(), }, settings: { userName: '', enableAutoTitleGeneration: true, permissionMode: 'yolo', }, } as any, state, renderer: { renderMessages: jest.fn().mockReturnValue(createMockEl()), } as any, subagentManager: { orphanAllActive: jest.fn(), clear: jest.fn(), } as any, getHistoryDropdown: () => historyDropdown as any, getWelcomeEl: () => welcomeEl, setWelcomeEl: (el: any) => { welcomeEl = el; }, getMessagesEl: () => messagesEl as any, getInputEl: () => inputEl, getFileContextManager: () => fileContextManager as any, getImageContextManager: () => ({ clearImages: jest.fn(), }) as any, getMcpServerSelector: () => ({ clearEnabled: jest.fn(), getEnabledServers: jest.fn().mockResolvedValue(new Set()), setEnabledServers: jest.fn(), }) as any, getExternalContextSelector: () => ({ getExternalContexts: jest.fn().mockReturnValue([]), setExternalContexts: jest.fn(), clearExternalContexts: jest.fn(), }) as any, clearQueuedMessage: jest.fn(), getTitleGenerationService: () => null, getStatusPanel: () => ({ remount: jest.fn(), }) as any, ...overrides, }; } describe('ConversationController', () => { let controller: ConversationController; let deps: ConversationControllerDeps; beforeEach(() => { jest.clearAllMocks(); deps = createMockDeps(); controller = new ConversationController(deps); }); describe('Queue Management', () => { describe('Creating new conversation', () => { it('should clear queued message on new conversation', async () => { deps.state.queuedMessage = { content: 'test', images: undefined, editorContext: null, canvasContext: null }; deps.state.isStreaming = false; await controller.createNew(); expect(deps.clearQueuedMessage).toHaveBeenCalled(); }); it('should not create new conversation while streaming', async () => { deps.state.isStreaming = true; await controller.createNew(); expect(deps.plugin.createConversation).not.toHaveBeenCalled(); }); it('should save current conversation before creating new one', async () => { deps.state.messages = [{ id: '1', role: 'user', content: 'test', timestamp: Date.now() }]; deps.state.currentConversationId = 'old-conv'; await controller.createNew(); expect(deps.plugin.updateConversation).toHaveBeenCalledWith('old-conv', expect.any(Object)); }); it('should reset file context for new conversation', async () => { const fileContextManager = deps.getFileContextManager()!; await controller.createNew(); expect(fileContextManager.resetForNewConversation).toHaveBeenCalled(); expect(fileContextManager.autoAttachActiveFile).toHaveBeenCalled(); }); it('should clear todos for new conversation', async () => { deps.state.currentTodos = [ { content: 'Existing todo', status: 'pending', activeForm: 'Doing existing todo' } ]; expect(deps.state.currentTodos).not.toBeNull(); await controller.createNew(); expect(deps.state.currentTodos).toBeNull(); }); it('should reset to entry point state (null conversationId) instead of creating conversation', async () => { // Entry point model: createNew() resets to blank state without creating conversation // Conversation is created lazily on first message send await controller.createNew(); expect(deps.plugin.findEmptyConversation).not.toHaveBeenCalled(); expect(deps.plugin.createConversation).not.toHaveBeenCalled(); expect(deps.plugin.switchConversation).not.toHaveBeenCalled(); expect(deps.state.currentConversationId).toBeNull(); }); it('should clear messages and reset state when creating new', async () => { deps.state.messages = [{ id: '1', role: 'user', content: 'test', timestamp: Date.now() }]; deps.state.currentConversationId = 'old-conv'; const clearMessagesSpy = jest.spyOn(deps.state, 'clearMessages'); await controller.createNew(); expect(clearMessagesSpy).toHaveBeenCalled(); expect(deps.state.currentConversationId).toBeNull(); clearMessagesSpy.mockRestore(); }); }); describe('Switching conversations', () => { it('should clear queued message on conversation switch', async () => { deps.state.currentConversationId = 'old-conv'; deps.state.queuedMessage = { content: 'test', images: undefined, editorContext: null, canvasContext: null }; await controller.switchTo('new-conv'); expect(deps.clearQueuedMessage).toHaveBeenCalled(); }); it('should not switch while streaming', async () => { deps.state.isStreaming = true; deps.state.currentConversationId = 'old-conv'; await controller.switchTo('new-conv'); expect(deps.plugin.switchConversation).not.toHaveBeenCalled(); }); it('should not switch to current conversation', async () => { deps.state.currentConversationId = 'same-conv'; await controller.switchTo('same-conv'); expect(deps.plugin.switchConversation).not.toHaveBeenCalled(); }); it('should reset file context when switching conversations', async () => { deps.state.currentConversationId = 'old-conv'; const fileContextManager = deps.getFileContextManager()!; await controller.switchTo('new-conv'); expect(fileContextManager.resetForLoadedConversation).toHaveBeenCalled(); }); it('should clear input value on switch', async () => { deps.state.currentConversationId = 'old-conv'; const inputEl = deps.getInputEl(); inputEl.value = 'some input'; await controller.switchTo('new-conv'); expect(inputEl.value).toBe(''); }); it('should hide history dropdown after switch', async () => { deps.state.currentConversationId = 'old-conv'; const dropdown = deps.getHistoryDropdown()!; dropdown.addClass('visible'); await controller.switchTo('new-conv'); expect(dropdown.hasClass('visible')).toBe(false); }); }); describe('Welcome visibility', () => { it('should hide welcome when messages exist', () => { deps.state.messages = [{ id: '1', role: 'user', content: 'test', timestamp: Date.now() }]; const welcomeEl = deps.getWelcomeEl()!; controller.updateWelcomeVisibility(); expect(welcomeEl.style.display).toBe('none'); }); it('should show welcome when no messages exist', () => { deps.state.messages = []; const welcomeEl = deps.getWelcomeEl()!; controller.updateWelcomeVisibility(); // When no messages, welcome should not be 'none' (either 'block' or empty string) expect(welcomeEl.style.display).not.toBe('none'); }); it('should update welcome visibility after switching to conversation with messages', async () => { deps.state.currentConversationId = 'old-conv'; deps.state.messages = []; (deps.plugin.switchConversation as jest.Mock).mockResolvedValue({ id: 'new-conv', messages: [{ id: '1', role: 'user', content: 'test', timestamp: Date.now() }], sessionId: null, }); await controller.switchTo('new-conv'); expect(deps.state.messages.length).toBe(1); const welcomeEl = deps.getWelcomeEl()!; expect(welcomeEl.style.display).toBe('none'); }); }); }); describe('initializeWelcome', () => { it('should initialize file context for new tab', () => { const fileContextManager = deps.getFileContextManager()!; controller.initializeWelcome(); expect(fileContextManager.resetForNewConversation).toHaveBeenCalled(); expect(fileContextManager.autoAttachActiveFile).toHaveBeenCalled(); }); it('should not throw if welcomeEl is null', () => { const depsWithNullWelcome = createMockDeps({ getWelcomeEl: () => null, }); const controllerWithNullWelcome = new ConversationController(depsWithNullWelcome); expect(() => controllerWithNullWelcome.initializeWelcome()).not.toThrow(); }); it('should only add greeting if not already present', () => { const welcomeEl = deps.getWelcomeEl()!; const createDivSpy = jest.spyOn(welcomeEl, 'createDiv'); // First call should add greeting controller.initializeWelcome(); expect(createDivSpy).toHaveBeenCalledTimes(1); // Mock querySelector to return an element (greeting already exists) welcomeEl.querySelector = jest.fn().mockReturnValue(createMockEl()); // Second call should not add another greeting controller.initializeWelcome(); expect(createDivSpy).toHaveBeenCalledTimes(1); // Still 1, not 2 }); }); describe('formatDate', () => { it('should return time format for today', () => { const now = new Date(); const result = controller.formatDate(now.getTime()); expect(result).toMatch(/^\d{2}:\d{2}$/); }); it('should return month/day format for a past date', () => { const pastDate = new Date(2023, 0, 15).getTime(); const result = controller.formatDate(pastDate); expect(result).toContain('15'); expect(result.length).toBeGreaterThan(0); }); it('should return month/day format for yesterday', () => { const yesterday = new Date(); yesterday.setDate(yesterday.getDate() - 1); const result = controller.formatDate(yesterday.getTime()); expect(result).not.toMatch(/^\d{2}:\d{2}$/); }); }); describe('toggleHistoryDropdown', () => { it('should add visible class when dropdown is hidden', () => { const dropdown = deps.getHistoryDropdown()!; expect(dropdown.hasClass('visible')).toBe(false); controller.toggleHistoryDropdown(); expect(dropdown.hasClass('visible')).toBe(true); }); it('should remove visible class when dropdown is visible', () => { const dropdown = deps.getHistoryDropdown()!; dropdown.addClass('visible'); controller.toggleHistoryDropdown(); expect(dropdown.hasClass('visible')).toBe(false); }); it('should not throw when dropdown is null', () => { const depsNullDropdown = createMockDeps({ getHistoryDropdown: () => null, }); const ctrl = new ConversationController(depsNullDropdown); expect(() => ctrl.toggleHistoryDropdown()).not.toThrow(); }); }); describe('save edge cases', () => { it('should return early when no conversationId and no messages', async () => { deps.state.currentConversationId = null; deps.state.messages = []; await controller.save(); expect(deps.plugin.updateConversation).not.toHaveBeenCalled(); expect(deps.plugin.createConversation).not.toHaveBeenCalled(); }); it('should lazily create conversation when entry point has messages', async () => { deps.state.currentConversationId = null; deps.state.messages = [{ id: '1', role: 'user', content: 'hello', timestamp: Date.now() }]; (deps.plugin.createConversation as jest.Mock).mockResolvedValue({ id: 'lazy-conv', title: 'New Conversation', messages: [], sessionId: null, createdAt: Date.now(), updatedAt: Date.now(), }); await controller.save(); expect(deps.plugin.createConversation).toHaveBeenCalled(); expect(deps.state.currentConversationId).toBe('lazy-conv'); expect(deps.plugin.updateConversation).toHaveBeenCalledWith( 'lazy-conv', expect.any(Object) ); }); it('should set lastResponseAt when updateLastResponse is true', async () => { deps.state.currentConversationId = 'conv-1'; deps.state.messages = [{ id: '1', role: 'user', content: 'test', timestamp: Date.now() }]; const beforeCall = Date.now(); await controller.save(true); const call = (deps.plugin.updateConversation as jest.Mock).mock.calls[0]; const updates = call[1]; expect(updates.lastResponseAt).toBeDefined(); expect(updates.lastResponseAt).toBeGreaterThanOrEqual(beforeCall); expect(updates.lastResponseAt).toBeLessThanOrEqual(Date.now()); }); it('should NOT clear resumeSessionAt when updateLastResponse is true (caller must pass extraUpdates)', async () => { deps.state.currentConversationId = 'conv-1'; deps.state.messages = [{ id: '1', role: 'user', content: 'test', timestamp: Date.now() }]; await controller.save(true); const call = (deps.plugin.updateConversation as jest.Mock).mock.calls[0]; const updates = call[1]; expect(updates).not.toHaveProperty('resumeSessionAt'); }); it('should clear resumeSessionAt when passed via extraUpdates', async () => { deps.state.currentConversationId = 'conv-1'; deps.state.messages = [{ id: '1', role: 'user', content: 'test', timestamp: Date.now() }]; await controller.save(true, { resumeSessionAt: undefined }); const call = (deps.plugin.updateConversation as jest.Mock).mock.calls[0]; const updates = call[1]; expect(updates.resumeSessionAt).toBeUndefined(); // Verify it's explicitly set (not just missing) expect('resumeSessionAt' in updates).toBe(true); }); it('should not clear resumeSessionAt when updateLastResponse is false', async () => { deps.state.currentConversationId = 'conv-1'; deps.state.messages = [{ id: '1', role: 'user', content: 'test', timestamp: Date.now() }]; await controller.save(false); const call = (deps.plugin.updateConversation as jest.Mock).mock.calls[0]; const updates = call[1]; expect(updates).not.toHaveProperty('resumeSessionAt'); }); }); describe('loadActive with existing conversation', () => { it('should restore currentNote when conversation has one', async () => { const fileContextManager = deps.getFileContextManager()!; deps.state.currentConversationId = 'conv-with-note'; (deps.plugin.getConversationById as jest.Mock).mockResolvedValue({ id: 'conv-with-note', messages: [{ id: '1', role: 'user', content: 'test', timestamp: Date.now() }], sessionId: null, currentNote: 'notes/my-note.md', }); await controller.loadActive(); expect(fileContextManager.setCurrentNote).toHaveBeenCalledWith('notes/my-note.md'); }); it('should auto-attach active file when no currentNote and no messages', async () => { const fileContextManager = deps.getFileContextManager()!; deps.state.currentConversationId = 'empty-conv'; (deps.plugin.getConversationById as jest.Mock).mockResolvedValue({ id: 'empty-conv', messages: [], sessionId: null, currentNote: undefined, }); await controller.loadActive(); expect(fileContextManager.autoAttachActiveFile).toHaveBeenCalled(); expect(fileContextManager.setCurrentNote).not.toHaveBeenCalled(); }); it('should call renderer.renderMessages with greeting callback', async () => { deps.state.currentConversationId = 'conv-1'; (deps.plugin.getConversationById as jest.Mock).mockResolvedValue({ id: 'conv-1', messages: [{ id: '1', role: 'user', content: 'test', timestamp: Date.now() }], sessionId: null, }); await controller.loadActive(); expect(deps.renderer.renderMessages).toHaveBeenCalledWith( expect.any(Array), expect.any(Function) ); const greetingFn = (deps.renderer.renderMessages as jest.Mock).mock.calls[0][1]; expect(greetingFn().length).toBeGreaterThan(0); }); }); describe('switchTo with currentNote', () => { it('should set currentNote when switched conversation has one', async () => { const fileContextManager = deps.getFileContextManager()!; deps.state.currentConversationId = 'old-conv'; (deps.plugin.switchConversation as jest.Mock).mockResolvedValue({ id: 'new-conv', messages: [{ id: '1', role: 'user', content: 'test', timestamp: Date.now() }], sessionId: null, currentNote: 'docs/readme.md', }); await controller.switchTo('new-conv'); expect(fileContextManager.setCurrentNote).toHaveBeenCalledWith('docs/readme.md'); }); it('should not set currentNote when switched conversation has none', async () => { const fileContextManager = deps.getFileContextManager()!; deps.state.currentConversationId = 'old-conv'; (deps.plugin.switchConversation as jest.Mock).mockResolvedValue({ id: 'new-conv', messages: [], sessionId: null, currentNote: undefined, }); await controller.switchTo('new-conv'); expect(fileContextManager.setCurrentNote).not.toHaveBeenCalled(); }); it('should call renderer.renderMessages with greeting callback on switch', async () => { deps.state.currentConversationId = 'old-conv'; (deps.plugin.switchConversation as jest.Mock).mockResolvedValue({ id: 'new-conv', messages: [], sessionId: null, }); await controller.switchTo('new-conv'); expect(deps.renderer.renderMessages).toHaveBeenCalledWith( expect.any(Array), expect.any(Function) ); const greetingFn = (deps.renderer.renderMessages as jest.Mock).mock.calls[0][1]; expect(greetingFn().length).toBeGreaterThan(0); }); }); describe('History Rendering', () => { let dropdown: any; beforeEach(() => { dropdown = createMockEl(); deps.getHistoryDropdown = () => dropdown; }); describe('updateHistoryDropdown with conversations', () => { it('should render conversation items when conversations exist', () => { (deps.plugin.getConversationList as jest.Mock).mockReturnValue([ { id: 'conv-1', title: 'First Conversation', createdAt: 1000, lastResponseAt: 3000 }, { id: 'conv-2', title: 'Second Conversation', createdAt: 2000, lastResponseAt: 2000 }, ]); controller.updateHistoryDropdown(); expect(dropdown.children.length).toBe(2); const list = dropdown.children[1]; expect(list.hasClass('claudian-history-list')).toBe(true); expect(list.children.length).toBe(2); }); it('should show "No conversations" when list is empty', () => { (deps.plugin.getConversationList as jest.Mock).mockReturnValue([]); controller.updateHistoryDropdown(); const list = dropdown.children[1]; expect(list.children[0].hasClass('claudian-history-empty')).toBe(true); }); it('should sort conversations by lastResponseAt descending', () => { (deps.plugin.getConversationList as jest.Mock).mockReturnValue([ { id: 'conv-old', title: 'Old', createdAt: 1000, lastResponseAt: 1000 }, { id: 'conv-new', title: 'New', createdAt: 2000, lastResponseAt: 5000 }, { id: 'conv-mid', title: 'Mid', createdAt: 3000, lastResponseAt: 3000 }, ]); controller.updateHistoryDropdown(); const list = dropdown.children[1]; const firstTitle = list.children[0].querySelector('.claudian-history-item-title'); expect(firstTitle?.textContent).toBe('New'); }); it('should mark current conversation as active', () => { deps.state.currentConversationId = 'conv-1'; (deps.plugin.getConversationList as jest.Mock).mockReturnValue([ { id: 'conv-1', title: 'Current', createdAt: 1000, lastResponseAt: 1000 }, { id: 'conv-2', title: 'Other', createdAt: 2000, lastResponseAt: 2000 }, ]); controller.updateHistoryDropdown(); const list = dropdown.children[1]; const items = list.children; const activeItem = items.find((item: any) => item.hasClass('active')); expect(activeItem).toBeDefined(); }); it('should show loading indicator for pending title generation', () => { (deps.plugin.getConversationList as jest.Mock).mockReturnValue([ { id: 'conv-1', title: 'Generating...', createdAt: 1000, lastResponseAt: 1000, titleGenerationStatus: 'pending' }, ]); controller.updateHistoryDropdown(); const list = dropdown.children[1]; const item = list.children[0]; const loadingEl = item.querySelector('.claudian-action-loading'); expect(loadingEl).toBeTruthy(); }); it('should show regenerate button for failed title generation', () => { (deps.plugin.getConversationList as jest.Mock).mockReturnValue([ { id: 'conv-1', title: 'Fallback Title', createdAt: 1000, lastResponseAt: 1000, titleGenerationStatus: 'failed' }, ]); controller.updateHistoryDropdown(); const list = dropdown.children[1]; const item = list.children[0]; const actions = item.querySelector('.claudian-history-item-actions'); expect(actions).toBeTruthy(); // regenerate button + rename button + delete button = 3 children expect(actions!.children.length).toBe(3); }); it('should not show select click handler on current conversation', () => { deps.state.currentConversationId = 'conv-1'; (deps.plugin.getConversationList as jest.Mock).mockReturnValue([ { id: 'conv-1', title: 'Current', createdAt: 1000, lastResponseAt: 1000 }, ]); controller.updateHistoryDropdown(); const list = dropdown.children[1]; const item = list.children[0]; const content = item.querySelector('.claudian-history-item-content'); const listeners = content?._eventListeners?.get('click'); expect(listeners).toBeUndefined(); }); it('should attach select click handler on non-current conversations', () => { deps.state.currentConversationId = 'conv-1'; (deps.plugin.getConversationList as jest.Mock).mockReturnValue([ { id: 'conv-1', title: 'Current', createdAt: 1000, lastResponseAt: 2000 }, { id: 'conv-2', title: 'Other', createdAt: 2000, lastResponseAt: 1000 }, ]); controller.updateHistoryDropdown(); const list = dropdown.children[1]; // conv-2 is the non-current one (sorted second by lastResponseAt) const otherItem = list.children[1]; const content = otherItem.querySelector('.claudian-history-item-content'); const listeners = content?._eventListeners?.get('click'); expect(listeners).toBeDefined(); expect(listeners!.length).toBe(1); }); it('should not delete while streaming', async () => { deps.state.isStreaming = true; (deps.plugin.getConversationList as jest.Mock).mockReturnValue([ { id: 'conv-1', title: 'Test', createdAt: 1000, lastResponseAt: 1000 }, ]); controller.updateHistoryDropdown(); const list = dropdown.children[1]; const item = list.children[0]; const deleteBtn = item.querySelector('.claudian-delete-btn'); expect(deleteBtn).toBeTruthy(); const clickHandlers = deleteBtn!._eventListeners?.get('click'); expect(clickHandlers).toBeDefined(); await clickHandlers![0]({ stopPropagation: jest.fn() }); expect(deps.plugin.deleteConversation).not.toHaveBeenCalled(); }); }); describe('renderHistoryDropdown', () => { it('should render history items to provided container', () => { const container = createMockEl(); const onSelectConversation = jest.fn(); (deps.plugin.getConversationList as jest.Mock).mockReturnValue([ { id: 'conv-1', title: 'Test', createdAt: 1000, lastResponseAt: 1000 }, ]); controller.renderHistoryDropdown(container, { onSelectConversation }); expect(container.children.length).toBe(2); // header + list }); }); }); describe('History Item Interactions', () => { let dropdown: any; beforeEach(() => { dropdown = createMockEl(); deps.getHistoryDropdown = () => dropdown; }); it('should switch conversation when clicking a non-current item content', async () => { deps.state.currentConversationId = 'conv-1'; (deps.plugin.getConversationList as jest.Mock).mockReturnValue([ { id: 'conv-1', title: 'Current', createdAt: 1000, lastResponseAt: 2000 }, { id: 'conv-2', title: 'Other', createdAt: 2000, lastResponseAt: 1000 }, ]); controller.updateHistoryDropdown(); const list = dropdown.children[1]; const otherItem = list.children[1]; const content = otherItem.querySelector('.claudian-history-item-content'); const clickHandlers = content?._eventListeners?.get('click'); expect(clickHandlers).toBeDefined(); await clickHandlers![0]({ stopPropagation: jest.fn() }); expect(deps.plugin.switchConversation).toHaveBeenCalledWith('conv-2'); }); it('should call regenerateTitle when clicking regenerate button on failed item', async () => { const mockTitleService = { generateTitle: jest.fn().mockResolvedValue(undefined), cancel: jest.fn(), }; deps.getTitleGenerationService = () => mockTitleService as any; (deps.plugin.getConversationList as jest.Mock).mockReturnValue([ { id: 'conv-1', title: 'Failed', createdAt: 1000, lastResponseAt: 1000, titleGenerationStatus: 'failed' }, ]); controller.updateHistoryDropdown(); const list = dropdown.children[1]; const item = list.children[0]; const actions = item.querySelector('.claudian-history-item-actions'); // First child is the regenerate button const regenerateBtn = actions!.children[0]; const clickHandlers = regenerateBtn._eventListeners?.get('click'); expect(clickHandlers).toBeDefined(); (deps.plugin.getConversationById as jest.Mock).mockResolvedValue({ id: 'conv-1', title: 'Failed', messages: [{ role: 'user', content: 'Hello' }], }); await clickHandlers![0]({ stopPropagation: jest.fn() }); expect(deps.plugin.updateConversation).toHaveBeenCalledWith('conv-1', { titleGenerationStatus: 'pending', }); }); it('should invoke rename handler when clicking rename button', () => { (deps.plugin.getConversationList as jest.Mock).mockReturnValue([ { id: 'conv-1', title: 'Test Title', createdAt: 1000, lastResponseAt: 1000 }, ]); controller.updateHistoryDropdown(); const list = dropdown.children[1]; const item = list.children[0]; const actions = item.querySelector('.claudian-history-item-actions'); expect(actions).toBeTruthy(); // For non-failed items: rename is children[0], delete is children[1] const rBtn = actions!.children[0]; expect(rBtn).toBeTruthy(); const clickHandlers = rBtn._eventListeners?.get('click'); expect(clickHandlers).toBeDefined(); const mockInput = createMockEl(); (mockInput as any).type = ''; (mockInput as any).className = ''; (mockInput as any).value = ''; (mockInput as any).focus = jest.fn(); (mockInput as any).select = jest.fn(); const titleEl = item.querySelector('.claudian-history-item-title'); if (titleEl) { (titleEl as any).replaceWith = jest.fn(); } const origDocument = global.document; global.document = { createElement: jest.fn().mockReturnValue(mockInput) } as any; try { clickHandlers![0]({ stopPropagation: jest.fn() }); expect(global.document.createElement).toHaveBeenCalledWith('input'); expect((mockInput as any).value).toBe('Test Title'); expect(titleEl!.replaceWith).toHaveBeenCalledWith(mockInput); } finally { global.document = origDocument; } }); it('should delete conversation and reload active when deleting current conversation', async () => { deps.state.currentConversationId = 'conv-1'; (deps.plugin.getConversationList as jest.Mock).mockReturnValue([ { id: 'conv-1', title: 'Current', createdAt: 1000, lastResponseAt: 1000 }, ]); controller.updateHistoryDropdown(); const list = dropdown.children[1]; const item = list.children[0]; const deleteBtn = item.querySelector('.claudian-delete-btn'); expect(deleteBtn).toBeTruthy(); const clickHandlers = deleteBtn!._eventListeners?.get('click'); expect(clickHandlers).toBeDefined(); await clickHandlers![0]({ stopPropagation: jest.fn() }); expect(deps.plugin.deleteConversation).toHaveBeenCalledWith('conv-1'); }); it('should delete non-current conversation without calling loadActive', async () => { deps.state.currentConversationId = 'conv-1'; (deps.plugin.getConversationList as jest.Mock).mockReturnValue([ { id: 'conv-1', title: 'Current', createdAt: 1000, lastResponseAt: 2000 }, { id: 'conv-2', title: 'Other', createdAt: 2000, lastResponseAt: 1000 }, ]); controller.updateHistoryDropdown(); const list = dropdown.children[1]; const otherItem = list.children[1]; // conv-2 const deleteBtn = otherItem.querySelector('.claudian-delete-btn'); const clickHandlers = deleteBtn!._eventListeners?.get('click'); await clickHandlers![0]({ stopPropagation: jest.fn() }); expect(deps.plugin.deleteConversation).toHaveBeenCalledWith('conv-2'); // Should not have called switchConversation (which is used in loadActive path) // The key check is that deleteConversation was called with conv-2 }); }); describe('loadActive with greeting', () => { it('should show welcome and return early when no conversation exists', async () => { deps.state.currentConversationId = null; await controller.loadActive(); const welcomeEl = deps.getWelcomeEl(); expect(welcomeEl?.style.display).not.toBe('none'); }); }); describe('Greeting Time Branches', () => { it.each([ { name: 'morning (5-12)', hour: 9, day: 1, patterns: ['morning', 'Coffee'] }, { name: 'afternoon (12-18)', hour: 14, day: 2, patterns: ['afternoon'] }, { name: 'evening (18-22)', hour: 20, day: 3, patterns: ['evening', 'Evening', 'your day'] }, { name: 'night owl (22+)', hour: 23, day: 4, patterns: ['night owl', 'Evening'] }, { name: 'early morning night owl (0-4)', hour: 2, day: 0, patterns: ['night owl', 'Evening'] }, ])('should include $name greetings', ({ hour, day, patterns }) => { jest.spyOn(Date.prototype, 'getHours').mockReturnValue(hour); jest.spyOn(Date.prototype, 'getDay').mockReturnValue(day); const greetings = new Set<string>(); for (let i = 0; i < 50; i++) { jest.spyOn(Math, 'random').mockReturnValue(i / 50); greetings.add(controller.getGreeting()); } const hasTimeBased = [...greetings].some(g => patterns.some(p => g.includes(p)) ); expect(hasTimeBased).toBe(true); jest.restoreAllMocks(); }); }); }); describe('ConversationController - Callbacks', () => { it('should call onNewConversation callback', async () => { const onNewConversation = jest.fn(); const deps = createMockDeps(); const controller = new ConversationController(deps, { onNewConversation }); await controller.createNew(); expect(onNewConversation).toHaveBeenCalled(); }); it('should call onConversationSwitched callback', async () => { const onConversationSwitched = jest.fn(); const deps = createMockDeps(); deps.state.currentConversationId = 'old-conv'; const controller = new ConversationController(deps, { onConversationSwitched }); await controller.switchTo('new-conv'); expect(onConversationSwitched).toHaveBeenCalled(); }); it('should call onConversationLoaded callback', async () => { const onConversationLoaded = jest.fn(); const deps = createMockDeps(); const controller = new ConversationController(deps, { onConversationLoaded }); await controller.loadActive(); expect(onConversationLoaded).toHaveBeenCalled(); }); }); describe('ConversationController - Title Generation', () => { let controller: ConversationController; let deps: ConversationControllerDeps; let mockTitleService: any; beforeEach(() => { jest.clearAllMocks(); mockTitleService = { generateTitle: jest.fn().mockResolvedValue(undefined), cancel: jest.fn(), }; deps = createMockDeps({ getTitleGenerationService: () => mockTitleService, }); controller = new ConversationController(deps); }); describe('regenerateTitle', () => { it('should not regenerate if titleService is null', async () => { const depsNoService = createMockDeps({ getTitleGenerationService: () => null, }); const controllerNoService = new ConversationController(depsNoService); (depsNoService.plugin.getConversationById as any) = jest.fn().mockResolvedValue({ id: 'conv-1', title: 'Old Title', messages: [ { role: 'user', content: 'Hello' }, { role: 'assistant', content: 'Hi there!' }, ], }); await controllerNoService.regenerateTitle('conv-1'); expect(depsNoService.plugin.updateConversation).not.toHaveBeenCalled(); }); it('should not regenerate if enableAutoTitleGeneration is false', async () => { deps.plugin.settings.enableAutoTitleGeneration = false; (deps.plugin.getConversationById as any) = jest.fn().mockResolvedValue({ id: 'conv-1', title: 'Old Title', messages: [ { role: 'user', content: 'Hello' }, { role: 'assistant', content: 'Hi there!' }, ], }); await controller.regenerateTitle('conv-1'); expect(mockTitleService.generateTitle).not.toHaveBeenCalled(); expect(deps.plugin.updateConversation).not.toHaveBeenCalled(); deps.plugin.settings.enableAutoTitleGeneration = true; }); it('should not regenerate if conversation not found', async () => { (deps.plugin.getConversationById as any) = jest.fn().mockResolvedValue(null); await controller.regenerateTitle('non-existent'); expect(mockTitleService.generateTitle).not.toHaveBeenCalled(); }); it('should not regenerate if conversation has no messages', async () => { (deps.plugin.getConversationById as any) = jest.fn().mockResolvedValue({ id: 'conv-1', title: 'Title', messages: [], }); await controller.regenerateTitle('conv-1'); expect(mockTitleService.generateTitle).not.toHaveBeenCalled(); }); it('should not regenerate if no user message found', async () => { (deps.plugin.getConversationById as any) = jest.fn().mockResolvedValue({ id: 'conv-1', title: 'Title', messages: [ { role: 'assistant', content: 'Hi' }, { role: 'assistant', content: 'There' }, ], }); await controller.regenerateTitle('conv-1'); expect(mockTitleService.generateTitle).not.toHaveBeenCalled(); }); it('should set pending status before generating', async () => { (deps.plugin.getConversationById as any) = jest.fn().mockResolvedValue({ id: 'conv-1', title: 'Old Title', messages: [ { role: 'user', content: 'Hello' }, { role: 'assistant', content: 'Hi there!' }, ], }); await controller.regenerateTitle('conv-1'); expect(deps.plugin.updateConversation).toHaveBeenCalledWith('conv-1', { titleGenerationStatus: 'pending', }); }); it('should call titleService.generateTitle with correct params', async () => { (deps.plugin.getConversationById as any) = jest.fn().mockResolvedValue({ id: 'conv-1', title: 'Old Title', messages: [ { role: 'user', content: 'Hello world', displayContent: 'Hello world!' }, { role: 'assistant', content: 'Hi there!' }, ], }); await controller.regenerateTitle('conv-1'); expect(mockTitleService.generateTitle).toHaveBeenCalledWith( 'conv-1', 'Hello world!', // Uses displayContent expect.any(Function) ); }); it('should regenerate title with only user message (no assistant yet)', async () => { (deps.plugin.getConversationById as any) = jest.fn().mockResolvedValue({ id: 'conv-1', title: 'Old Title', messages: [{ role: 'user', content: 'Hello world' }], }); await controller.regenerateTitle('conv-1'); expect(mockTitleService.generateTitle).toHaveBeenCalledWith( 'conv-1', 'Hello world', expect.any(Function) ); }); it('should rename conversation with generated title', async () => { (deps.plugin.getConversationById as any) = jest.fn().mockResolvedValue({ id: 'conv-1', title: 'Old Title', messages: [ { role: 'user', content: 'Create a plan' }, { role: 'assistant', content: 'Here is the plan...' }, ], }); mockTitleService.generateTitle.mockImplementation( async (convId: string, _user: string, callback: any) => { await callback(convId, { success: true, title: 'New Generated Title' }); } ); (deps.plugin.renameConversation as any) = jest.fn().mockResolvedValue(undefined); await controller.regenerateTitle('conv-1'); expect(deps.plugin.renameConversation).toHaveBeenCalledWith('conv-1', 'New Generated Title'); }); }); describe('generateFallbackTitle', () => { it('should generate title from first sentence', () => { const title = controller.generateFallbackTitle('How do I set up React? I need help.'); expect(title).toBe('How do I set up React'); }); it('should truncate long titles to 50 chars', () => { const longMessage = 'A'.repeat(100); const title = controller.generateFallbackTitle(longMessage); expect(title.length).toBeLessThanOrEqual(53); // 50 + '...' expect(title).toContain('...'); }); it('should handle messages with no sentence breaks', () => { const title = controller.generateFallbackTitle('Hello world'); expect(title).toBe('Hello world'); }); }); }); describe('ConversationController - MCP Server Persistence', () => { let controller: ConversationController; let deps: ConversationControllerDeps; let mockMcpServerSelector: any; beforeEach(() => { jest.clearAllMocks(); mockMcpServerSelector = { clearEnabled: jest.fn(), getEnabledServers: jest.fn().mockReturnValue(new Set(['mcp-server-1', 'mcp-server-2'])), setEnabledServers: jest.fn(), }; deps = createMockDeps({ getMcpServerSelector: () => mockMcpServerSelector, }); controller = new ConversationController(deps); }); describe('save', () => { it('should save enabled MCP servers to conversation', async () => { deps.state.currentConversationId = 'conv-1'; await controller.save(); expect(deps.plugin.updateConversation).toHaveBeenCalledWith( 'conv-1', expect.objectContaining({ enabledMcpServers: ['mcp-server-1', 'mcp-server-2'], }) ); }); it('should save undefined when no MCP servers enabled', async () => { mockMcpServerSelector.getEnabledServers.mockReturnValue(new Set()); deps.state.currentConversationId = 'conv-1'; await controller.save(); expect(deps.plugin.updateConversation).toHaveBeenCalledWith( 'conv-1', expect.objectContaining({ enabledMcpServers: undefined, }) ); }); }); describe('loadActive', () => { it('should restore enabled MCP servers from conversation', async () => { deps.state.currentConversationId = 'conv-1'; (deps.plugin.getConversationById as jest.Mock).mockResolvedValue({ id: 'conv-1', messages: [], sessionId: null, enabledMcpServers: ['restored-server-1', 'restored-server-2'], }); await controller.loadActive(); expect(mockMcpServerSelector.setEnabledServers).toHaveBeenCalledWith([ 'restored-server-1', 'restored-server-2', ]); }); it('should clear MCP servers when conversation has none', async () => { deps.state.currentConversationId = 'conv-1'; (deps.plugin.getConversationById as jest.Mock).mockResolvedValue({ id: 'conv-1', messages: [], sessionId: null, enabledMcpServers: undefined, }); await controller.loadActive(); expect(mockMcpServerSelector.clearEnabled).toHaveBeenCalled(); }); }); describe('switchTo', () => { it('should restore enabled MCP servers when switching conversations', async () => { deps.state.currentConversationId = 'old-conv'; (deps.plugin.switchConversation as jest.Mock).mockResolvedValue({ id: 'new-conv', messages: [], sessionId: null, enabledMcpServers: ['switched-server'], }); await controller.switchTo('new-conv'); expect(mockMcpServerSelector.setEnabledServers).toHaveBeenCalledWith(['switched-server']); }); it('should clear MCP servers when switching to conversation with no servers', async () => { deps.state.currentConversationId = 'old-conv'; (deps.plugin.switchConversation as jest.Mock).mockResolvedValue({ id: 'new-conv', messages: [], sessionId: null, enabledMcpServers: undefined, }); await controller.switchTo('new-conv'); expect(mockMcpServerSelector.clearEnabled).toHaveBeenCalled(); }); }); describe('createNew', () => { it('should clear enabled MCP servers for new conversation', async () => { await controller.createNew(); expect(mockMcpServerSelector.clearEnabled).toHaveBeenCalled(); }); }); }); describe('ConversationController - Race Condition Guards', () => { let controller: ConversationController; let deps: ConversationControllerDeps; beforeEach(() => { jest.clearAllMocks(); deps = createMockDeps(); controller = new ConversationController(deps); }); describe('createNew guards', () => { it('should not create when isCreatingConversation is already true', async () => { deps.state.isCreatingConversation = true; await controller.createNew(); expect(deps.plugin.createConversation).not.toHaveBeenCalled(); expect(deps.plugin.switchConversation).not.toHaveBeenCalled(); }); it('should not create when isSwitchingConversation is true', async () => { deps.state.isSwitchingConversation = true; await controller.createNew(); expect(deps.plugin.createConversation).not.toHaveBeenCalled(); }); it('should reset even when streaming if force is true', async () => { deps.state.isStreaming = true; deps.state.cancelRequested = false; const initialGeneration = deps.state.streamGeneration; await controller.createNew({ force: true }); expect(deps.state.isStreaming).toBe(false); expect(deps.state.cancelRequested).toBe(true); expect(deps.state.streamGeneration).toBe(initialGeneration + 1); expect(deps.state.currentConversationId).toBeNull(); }); it('should set and reset isCreatingConversation flag during entry point reset', async () => { // Entry point model: createNew() just resets state, doesn't create conversation // But isCreatingConversation flag should still be set during the reset let flagDuringExecution = false; deps.state.clearMessages = jest.fn(() => { flagDuringExecution = deps.state.isCreatingConversation; }); await controller.createNew(); expect(flagDuringExecution).toBe(true); expect(deps.state.isCreatingConversation).toBe(false); }); }); describe('switchTo guards', () => { it('should not switch when isSwitchingConversation is already true', async () => { deps.state.currentConversationId = 'old-conv'; deps.state.isSwitchingConversation = true; await controller.switchTo('new-conv'); expect(deps.plugin.switchConversation).not.toHaveBeenCalled(); }); it('should not switch when isCreatingConversation is true', async () => { deps.state.currentConversationId = 'old-conv'; deps.state.isCreatingConversation = true; await controller.switchTo('new-conv'); expect(deps.plugin.switchConversation).not.toHaveBeenCalled(); }); it('should reset isSwitchingConversation flag even on error', async () => { deps.state.currentConversationId = 'old-conv'; (deps.plugin.switchConversation as jest.Mock).mockRejectedValue(new Error('Switch failed')); await expect(controller.switchTo('new-conv')).rejects.toThrow('Switch failed'); expect(deps.state.isSwitchingConversation).toBe(false); }); it('should reset isSwitchingConversation flag when conversation not found', async () => { deps.state.currentConversationId = 'old-conv'; (deps.plugin.switchConversation as jest.Mock).mockResolvedValue(null); await controller.switchTo('non-existent'); expect(deps.state.isSwitchingConversation).toBe(false); }); it('should set isSwitchingConversation flag during switch', async () => { deps.state.currentConversationId = 'old-conv'; let flagDuringSwitch = false; (deps.plugin.switchConversation as jest.Mock).mockImplementation(async () => { flagDuringSwitch = deps.state.isSwitchingConversation; return { id: 'new-conv', title: 'New Conversation', messages: [], sessionId: null, createdAt: Date.now(), updatedAt: Date.now(), }; }); await controller.switchTo('new-conv'); expect(flagDuringSwitch).toBe(true); expect(deps.state.isSwitchingConversation).toBe(false); }); }); describe('mutual exclusion', () => { it('should prevent createNew during switchTo', async () => { deps.state.currentConversationId = 'old-conv'; // Simulate switchTo in progress let switchPromiseResolve: () => void; const switchPromise = new Promise<void>((resolve) => { switchPromiseResolve = resolve; }); (deps.plugin.switchConversation as jest.Mock).mockImplementation(async () => { // During switch, try to createNew const createPromise = controller.createNew(); // createNew should be blocked because isSwitchingConversation is true expect(deps.plugin.createConversation).not.toHaveBeenCalled(); switchPromiseResolve!(); await createPromise; return { id: 'new-conv', messages: [], sessionId: null, }; }); await controller.switchTo('new-conv'); await switchPromise; expect(deps.plugin.createConversation).not.toHaveBeenCalled(); }); }); }); describe('ConversationController - Persistent External Context Paths', () => { let controller: ConversationController; let deps: ConversationControllerDeps; let mockExternalContextSelector: any; beforeEach(() => { jest.clearAllMocks(); mockExternalContextSelector = { getExternalContexts: jest.fn().mockReturnValue([]), setExternalContexts: jest.fn(), clearExternalContexts: jest.fn(), }; deps = createMockDeps({ getExternalContextSelector: () => mockExternalContextSelector, }); (deps.plugin.settings as any).persistentExternalContextPaths = ['/persistent/path/a', '/persistent/path/b']; controller = new ConversationController(deps); }); describe('createNew', () => { it('should call clearExternalContexts with persistent paths from settings', async () => { await controller.createNew(); expect(mockExternalContextSelector.clearExternalContexts).toHaveBeenCalledWith( ['/persistent/path/a', '/persistent/path/b'] ); }); it('should call clearExternalContexts with empty array if no persistent paths', async () => { (deps.plugin.settings as any).persistentExternalContextPaths = undefined; await controller.createNew(); expect(mockExternalContextSelector.clearExternalContexts).toHaveBeenCalledWith([]); }); }); describe('loadActive', () => { it('should use persistent paths for new conversation (no existing conversation)', async () => { deps.state.currentConversationId = null; await controller.loadActive(); expect(mockExternalContextSelector.clearExternalContexts).toHaveBeenCalledWith( ['/persistent/path/a', '/persistent/path/b'] ); }); it('should use persistent paths for empty conversation (msg=0)', async () => { deps.state.currentConversationId = 'existing-conv'; deps.plugin.getConversationById = jest.fn().mockResolvedValue({ id: 'existing-conv', messages: [], sessionId: null, }); await controller.loadActive(); expect(mockExternalContextSelector.clearExternalContexts).toHaveBeenCalledWith( ['/persistent/path/a', '/persistent/path/b'] ); }); it('should restore saved paths for conversation with messages (msg>0)', async () => { deps.state.currentConversationId = 'existing-conv'; deps.plugin.getConversationById = jest.fn().mockResolvedValue({ id: 'existing-conv', messages: [{ id: '1', role: 'user', content: 'test', timestamp: Date.now() }], sessionId: null, externalContextPaths: ['/saved/path'], }); await controller.loadActive(); expect(mockExternalContextSelector.setExternalContexts).toHaveBeenCalledWith(['/saved/path']); expect(mockExternalContextSelector.clearExternalContexts).not.toHaveBeenCalled(); }); it('should restore empty paths for conversation with messages but no saved paths', async () => { deps.state.currentConversationId = 'existing-conv'; deps.plugin.getConversationById = jest.fn().mockResolvedValue({ id: 'existing-conv', messages: [{ id: '1', role: 'user', content: 'test', timestamp: Date.now() }], sessionId: null, externalContextPaths: undefined, }); await controller.loadActive(); expect(mockExternalContextSelector.setExternalContexts).toHaveBeenCalledWith([]); }); }); describe('switchTo', () => { beforeEach(() => { deps.state.currentConversationId = 'old-conv'; }); it('should use persistent paths when switching to empty conversation (msg=0)', async () => { (deps.plugin.switchConversation as jest.Mock).mockResolvedValue({ id: 'empty-conv', messages: [], sessionId: null, externalContextPaths: ['/old/saved/path'], }); await controller.switchTo('empty-conv'); expect(mockExternalContextSelector.clearExternalContexts).toHaveBeenCalledWith( ['/persistent/path/a', '/persistent/path/b'] ); expect(mockExternalContextSelector.setExternalContexts).not.toHaveBeenCalled(); }); it('should restore saved paths when switching to conversation with messages', async () => { (deps.plugin.switchConversation as jest.Mock).mockResolvedValue({ id: 'conv-with-messages', messages: [{ id: '1', role: 'user', content: 'test', timestamp: Date.now() }], sessionId: null, externalContextPaths: ['/saved/path/from/session'], }); await controller.switchTo('conv-with-messages'); expect(mockExternalContextSelector.setExternalContexts).toHaveBeenCalledWith( ['/saved/path/from/session'] ); expect(mockExternalContextSelector.clearExternalContexts).not.toHaveBeenCalled(); }); it('should restore empty array for conversation with messages but no saved paths', async () => { (deps.plugin.switchConversation as jest.Mock).mockResolvedValue({ id: 'conv-with-messages', messages: [{ id: '1', role: 'user', content: 'test', timestamp: Date.now() }], sessionId: null, externalContextPaths: undefined, }); await controller.switchTo('conv-with-messages'); expect(mockExternalContextSelector.setExternalContexts).toHaveBeenCalledWith([]); }); }); describe('Scenario: Adding persistent paths across sessions', () => { it('should show all persistent paths when returning to empty session', async () => { // Scenario: // 1. User is in session 0 (empty), adds path A as persistent // 2. User switches to session 1 (with messages), adds path B as persistent // 3. User returns to session 0 (empty) - should see both A and B // Step 1: Session 0 is empty, persistent paths = [A] (deps.plugin.settings as any).persistentExternalContextPaths = ['/path/a']; deps.state.currentConversationId = null; await controller.loadActive(); expect(mockExternalContextSelector.clearExternalContexts).toHaveBeenCalledWith(['/path/a']); // Step 2: User switches to session 1 and adds path B, settings now have [A, B] deps.state.currentConversationId = 'session-0'; // Currently in session 0 (deps.plugin.switchConversation as jest.Mock).mockResolvedValue({ id: 'session-1', messages: [{ id: '1', role: 'user', content: 'test', timestamp: Date.now() }], sessionId: null, externalContextPaths: [], }); await controller.switchTo('session-1'); // User adds path B in session 1, settings now have [A, B] (deps.plugin.settings as any).persistentExternalContextPaths = ['/path/a', '/path/b']; // Step 3: User returns to session 0 (empty) (deps.plugin.switchConversation as jest.Mock).mockResolvedValue({ id: 'session-0', messages: [], // Empty session sessionId: null, externalContextPaths: ['/path/a'], // Only had A when originally created }); jest.clearAllMocks(); await controller.switchTo('session-0'); // Should get BOTH paths because session is empty (msg=0) expect(mockExternalContextSelector.clearExternalContexts).toHaveBeenCalledWith( ['/path/a', '/path/b'] ); }); }); }); describe('ConversationController - Previous SDK Session IDs', () => { let controller: ConversationController; let deps: ConversationControllerDeps; let mockAgentService: any; beforeEach(() => { jest.clearAllMocks(); mockAgentService = { getSessionId: jest.fn().mockReturnValue(null), setSessionId: jest.fn(), consumeSessionInvalidation: jest.fn().mockReturnValue(false), }; deps = createMockDeps({ getAgentService: () => mockAgentService, }); controller = new ConversationController(deps); }); describe('save - session change detection', () => { it('should accumulate old sdkSessionId when SDK creates new session', async () => { deps.state.currentConversationId = 'conv-1'; deps.state.messages = [{ id: '1', role: 'user', content: 'test', timestamp: Date.now() }]; // Existing conversation has sdkSessionId 'session-A' (deps.plugin.getConversationById as jest.Mock).mockResolvedValue({ id: 'conv-1', messages: [], sdkSessionId: 'session-A', isNative: true, previousSdkSessionIds: undefined, }); // Agent service reports new session 'session-B' (resume failed, new session created) mockAgentService.getSessionId.mockReturnValue('session-B'); await controller.save(); expect(deps.plugin.updateConversation).toHaveBeenCalledWith( 'conv-1', expect.objectContaining({ sdkSessionId: 'session-B', previousSdkSessionIds: ['session-A'], }) ); }); it('should preserve existing previousSdkSessionIds when session changes again', async () => { deps.state.currentConversationId = 'conv-1'; deps.state.messages = [{ id: '1', role: 'user', content: 'test', timestamp: Date.now() }]; // Conversation already has previous sessions [A], current is B (deps.plugin.getConversationById as jest.Mock).mockResolvedValue({ id: 'conv-1', messages: [], sdkSessionId: 'session-B', isNative: true, previousSdkSessionIds: ['session-A'], }); // Agent service reports new session 'session-C' mockAgentService.getSessionId.mockReturnValue('session-C'); await controller.save(); expect(deps.plugin.updateConversation).toHaveBeenCalledWith( 'conv-1', expect.objectContaining({ sdkSessionId: 'session-C', previousSdkSessionIds: ['session-A', 'session-B'], }) ); }); it('should not modify previousSdkSessionIds when session has not changed', async () => { deps.state.currentConversationId = 'conv-1'; deps.state.messages = [{ id: '1', role: 'user', content: 'test', timestamp: Date.now() }]; (deps.plugin.getConversationById as jest.Mock).mockResolvedValue({ id: 'conv-1', messages: [], sdkSessionId: 'session-A', isNative: true, previousSdkSessionIds: undefined, }); mockAgentService.getSessionId.mockReturnValue('session-A'); await controller.save(); expect(deps.plugin.updateConversation).toHaveBeenCalledWith( 'conv-1', expect.objectContaining({ sdkSessionId: 'session-A', previousSdkSessionIds: undefined, }) ); }); it('should deduplicate session IDs to prevent duplicates from race conditions', async () => { deps.state.currentConversationId = 'conv-1'; deps.state.messages = [{ id: '1', role: 'user', content: 'test', timestamp: Date.now() }]; // Simulate a race condition where session-A is already in previousSdkSessionIds // but sdkSessionId is still session-A (should not duplicate) (deps.plugin.getConversationById as jest.Mock).mockResolvedValue({ id: 'conv-1', messages: [], sdkSessionId: 'session-A', isNative: true, previousSdkSessionIds: ['session-A'], // Already contains A (from prior bug/race) }); // Agent reports new session-B mockAgentService.getSessionId.mockReturnValue('session-B'); await controller.save(); // Should deduplicate: [A, A] -> [A] expect(deps.plugin.updateConversation).toHaveBeenCalledWith( 'conv-1', expect.objectContaining({ sdkSessionId: 'session-B', previousSdkSessionIds: ['session-A'], // Deduplicated, not ['session-A', 'session-A'] }) ); }); }); }); describe('ConversationController - Fork Session ID Isolation', () => { let controller: ConversationController; let deps: ConversationControllerDeps; let mockAgentService: any; beforeEach(() => { jest.clearAllMocks(); mockAgentService = { getSessionId: jest.fn().mockReturnValue(null), setSessionId: jest.fn(), consumeSessionInvalidation: jest.fn().mockReturnValue(false), }; deps = createMockDeps({ getAgentService: () => mockAgentService, }); controller = new ConversationController(deps); }); it('should not persist fork source session ID as conversation own sessionId/sdkSessionId', async () => { deps.state.currentConversationId = 'fork-conv'; deps.state.messages = [{ id: '1', role: 'user', content: 'test', timestamp: Date.now() }]; // Fork conversation: has forkSource but no own sdkSessionId yet (deps.plugin.getConversationById as jest.Mock).mockResolvedValue({ id: 'fork-conv', messages: [], sessionId: null, sdkSessionId: undefined, isNative: true, forkSource: { sessionId: 'source-session-abc', resumeAt: 'assistant-uuid-1' }, }); // Agent service has the fork source ID set for resume purposes mockAgentService.getSessionId.mockReturnValue('source-session-abc'); await controller.save(); expect(deps.plugin.updateConversation).toHaveBeenCalledWith( 'fork-conv', expect.objectContaining({ sessionId: null, sdkSessionId: undefined, }) ); }); it('should persist new session ID after SDK captures a different session for fork', async () => { deps.state.currentConversationId = 'fork-conv'; deps.state.messages = [{ id: '1', role: 'user', content: 'test', timestamp: Date.now() }]; (deps.plugin.getConversationById as jest.Mock).mockResolvedValue({ id: 'fork-conv', messages: [], sessionId: null, sdkSessionId: undefined, isNative: true, forkSource: { sessionId: 'source-session-abc', resumeAt: 'assistant-uuid-1' }, }); // SDK captured a new session (different from fork source) mockAgentService.getSessionId.mockReturnValue('new-session-xyz'); await controller.save(); expect(deps.plugin.updateConversation).toHaveBeenCalledWith( 'fork-conv', expect.objectContaining({ sessionId: 'new-session-xyz', sdkSessionId: 'new-session-xyz', forkSource: undefined, }) ); }); it('should allow normal session ID persistence when fork metadata is already cleared', async () => { deps.state.currentConversationId = 'fork-conv'; deps.state.messages = [{ id: '1', role: 'user', content: 'test', timestamp: Date.now() }]; // Fork conversation after fork metadata was cleared (has its own sdkSessionId) (deps.plugin.getConversationById as jest.Mock).mockResolvedValue({ id: 'fork-conv', messages: [], sessionId: 'new-session-xyz', sdkSessionId: 'new-session-xyz', isNative: true, forkSource: undefined, }); mockAgentService.getSessionId.mockReturnValue('new-session-xyz'); await controller.save(); expect(deps.plugin.updateConversation).toHaveBeenCalledWith( 'fork-conv', expect.objectContaining({ sessionId: 'new-session-xyz', sdkSessionId: 'new-session-xyz', }) ); }); }); describe('ConversationController - switchTo fork path', () => { let controller: ConversationController; let deps: ConversationControllerDeps; let mockAgentService: any; beforeEach(() => { jest.clearAllMocks(); mockAgentService = { getSessionId: jest.fn().mockReturnValue(null), setSessionId: jest.fn(), applyForkState: jest.fn((conv: any) => conv.sessionId ?? conv.forkSource?.sessionId ?? null), consumeSessionInvalidation: jest.fn().mockReturnValue(false), }; deps = createMockDeps({ getAgentService: () => mockAgentService, }); controller = new ConversationController(deps); }); it('should call applyForkState and pass resolved session ID to setSessionId for pending fork', async () => { deps.state.currentConversationId = 'old-conv'; const forkConversation = { id: 'fork-conv', messages: [{ id: '1', role: 'user', content: 'forked msg', timestamp: Date.now() }], sessionId: null, sdkSessionId: undefined, isNative: true, forkSource: { sessionId: 'source-session-abc', resumeAt: 'assistant-uuid-1' }, }; (deps.plugin.switchConversation as jest.Mock).mockResolvedValue(forkConversation); await controller.switchTo('fork-conv'); expect(mockAgentService.applyForkState).toHaveBeenCalledWith(forkConversation); expect(mockAgentService.setSessionId).toHaveBeenCalledWith('source-session-abc', expect.any(Array)); }); it('should resolve to own sessionId when fork already has its own session', async () => { deps.state.currentConversationId = 'old-conv'; const forkConversation = { id: 'fork-conv', messages: [{ id: '1', role: 'user', content: 'forked msg', timestamp: Date.now() }], sessionId: 'own-session-xyz', sdkSessionId: 'own-session-xyz', isNative: true, forkSource: { sessionId: 'source-session-abc', resumeAt: 'assistant-uuid-1' }, }; (deps.plugin.switchConversation as jest.Mock).mockResolvedValue(forkConversation); await controller.switchTo('fork-conv'); expect(mockAgentService.applyForkState).toHaveBeenCalledWith(forkConversation); expect(mockAgentService.setSessionId).toHaveBeenCalledWith('own-session-xyz', expect.any(Array)); }); }); describe('ConversationController - restoreExternalContextPaths null selector', () => { it('should return early when external context selector is null', async () => { const deps = createMockDeps({ getExternalContextSelector: () => null, }); const controller = new ConversationController(deps); deps.state.currentConversationId = 'old-conv'; (deps.plugin.switchConversation as jest.Mock).mockResolvedValue({ id: 'new-conv', messages: [{ id: '1', role: 'user', content: 'test', timestamp: Date.now() }], sessionId: null, externalContextPaths: ['/some/path'], }); // Should not throw even though selector is null await expect(controller.switchTo('new-conv')).resolves.not.toThrow(); }); }); describe('ConversationController - regenerateTitle callback branches', () => { let controller: ConversationController; let deps: ConversationControllerDeps; let mockTitleService: any; beforeEach(() => { jest.clearAllMocks(); mockTitleService = { generateTitle: jest.fn().mockResolvedValue(undefined), cancel: jest.fn(), }; deps = createMockDeps({ getTitleGenerationService: () => mockTitleService, }); controller = new ConversationController(deps); }); it('should mark as failed when generation fails and user has not renamed', async () => { (deps.plugin.getConversationById as jest.Mock).mockResolvedValue({ id: 'conv-1', title: 'Original Title', messages: [ { role: 'user', content: 'Hello' }, { role: 'assistant', content: 'Hi!' }, ], }); mockTitleService.generateTitle.mockImplementation( async (_convId: string, _user: string, callback: any) => { // On callback, getConversationById returns same title (user didn't rename) (deps.plugin.getConversationById as jest.Mock).mockResolvedValue({ id: 'conv-1', title: 'Original Title', messages: [], }); await callback('conv-1', { success: false, title: '' }); } ); await controller.regenerateTitle('conv-1'); expect(deps.plugin.renameConversation).not.toHaveBeenCalled(); expect(deps.plugin.updateConversation).toHaveBeenCalledWith('conv-1', { titleGenerationStatus: 'failed', }); }); it('should clear status when user manually renamed during generation', async () => { (deps.plugin.getConversationById as jest.Mock).mockResolvedValue({ id: 'conv-1', title: 'Original Title', messages: [ { role: 'user', content: 'Hello' }, { role: 'assistant', content: 'Hi!' }, ], }); // Simulate callback where user has renamed the conversation mockTitleService.generateTitle.mockImplementation( async (_convId: string, _user: string, callback: any) => { // On callback, getConversationById returns a different title (user renamed) (deps.plugin.getConversationById as jest.Mock).mockResolvedValue({ id: 'conv-1', title: 'User Renamed Title', messages: [], }); await callback('conv-1', { success: true, title: 'AI Generated Title' }); } ); await controller.regenerateTitle('conv-1'); // Should NOT rename because user already renamed expect(deps.plugin.renameConversation).not.toHaveBeenCalled(); // Should clear the status since user's choice takes precedence expect(deps.plugin.updateConversation).toHaveBeenCalledWith('conv-1', { titleGenerationStatus: undefined, }); }); it('should not apply title when conversation no longer exists during callback', async () => { (deps.plugin.getConversationById as jest.Mock).mockResolvedValue({ id: 'conv-1', title: 'Original Title', messages: [ { role: 'user', content: 'Hello' }, { role: 'assistant', content: 'Hi!' }, ], }); // Simulate callback where conversation was deleted mockTitleService.generateTitle.mockImplementation( async (_convId: string, _user: string, callback: any) => { (deps.plugin.getConversationById as jest.Mock).mockResolvedValue(null); await callback('conv-1', { success: true, title: 'New Title' }); } ); await controller.regenerateTitle('conv-1'); expect(deps.plugin.renameConversation).not.toHaveBeenCalled(); }); }); describe('ConversationController - Rewind', () => { let controller: ConversationController; let deps: ConversationControllerDeps; let mockAgentService: any; beforeEach(() => { jest.clearAllMocks(); mockAgentService = { getSessionId: jest.fn().mockReturnValue(null), setSessionId: jest.fn(), consumeSessionInvalidation: jest.fn().mockReturnValue(false), rewind: jest.fn().mockResolvedValue({ canRewind: true, filesChanged: ['a.ts'] }), }; deps = createMockDeps({ getAgentService: () => mockAgentService, }); controller = new ConversationController(deps); }); it('should find prev/response assistants with bounded scan (skipping non-uuid messages)', async () => { deps.state.currentConversationId = 'conv-1'; deps.state.messages = [ { id: 'm1', role: 'assistant', content: '', timestamp: 1, sdkAssistantUuid: 'prev-a' }, { id: 'm2', role: 'assistant', content: 'boundary', timestamp: 2 }, // No uuid { id: 'm3', role: 'user', content: 'test', timestamp: 3, sdkUserUuid: 'user-uuid' }, { id: 'm4', role: 'assistant', content: 'boundary2', timestamp: 4 }, // No uuid { id: 'm5', role: 'assistant', content: 'resp', timestamp: 5, sdkAssistantUuid: 'resp-a' }, ]; await controller.rewind('m3'); expect(mockAgentService.rewind).toHaveBeenCalledWith('user-uuid', 'prev-a'); }); it('should show Notice when message ID not found', async () => { deps.state.messages = [ { id: 'm1', role: 'assistant', content: '', timestamp: 1, sdkAssistantUuid: 'a1' }, { id: 'm2', role: 'user', content: 'test', timestamp: 2, sdkUserUuid: 'u1' }, { id: 'm3', role: 'assistant', content: '', timestamp: 3, sdkAssistantUuid: 'a2' }, ]; await controller.rewind('nonexistent'); expect(mockNotice).toHaveBeenCalled(); expect(mockAgentService.rewind).not.toHaveBeenCalled(); }); it('should show Notice when streaming', async () => { deps.state.isStreaming = true; deps.state.messages = [ { id: 'm1', role: 'assistant', content: '', timestamp: 1, sdkAssistantUuid: 'a1' }, { id: 'm2', role: 'user', content: 'test', timestamp: 2, sdkUserUuid: 'u1' }, { id: 'm3', role: 'assistant', content: '', timestamp: 3, sdkAssistantUuid: 'a2' }, ]; await controller.rewind('m2'); expect(mockNotice).toHaveBeenCalled(); expect(mockAgentService.rewind).not.toHaveBeenCalled(); }); it('should show Notice when user message has no sdkUserUuid', async () => { deps.state.messages = [ { id: 'm1', role: 'assistant', content: '', timestamp: 1, sdkAssistantUuid: 'a1' }, { id: 'm2', role: 'user', content: 'test', timestamp: 2 }, // No sdkUserUuid { id: 'm3', role: 'assistant', content: '', timestamp: 3, sdkAssistantUuid: 'a2' }, ]; await controller.rewind('m2'); expect(mockNotice).toHaveBeenCalled(); expect(mockAgentService.rewind).not.toHaveBeenCalled(); }); it('should show Notice when no previous assistant with uuid exists', async () => { deps.state.messages = [ { id: 'm1', role: 'user', content: 'test', timestamp: 1, sdkUserUuid: 'u1' }, { id: 'm2', role: 'assistant', content: '', timestamp: 2, sdkAssistantUuid: 'a1' }, ]; await controller.rewind('m1'); expect(mockNotice).toHaveBeenCalled(); expect(mockAgentService.rewind).not.toHaveBeenCalled(); }); it('should show Notice when no response assistant with uuid exists', async () => { deps.state.messages = [ { id: 'm1', role: 'assistant', content: '', timestamp: 1, sdkAssistantUuid: 'a1' }, { id: 'm2', role: 'user', content: 'test', timestamp: 2, sdkUserUuid: 'u1' }, ]; await controller.rewind('m2'); expect(mockNotice).toHaveBeenCalled(); expect(mockAgentService.rewind).not.toHaveBeenCalled(); }); it('should show i18n Notice on SDK rewind exception', async () => { deps.state.currentConversationId = 'conv-1'; deps.state.messages = [ { id: 'm1', role: 'assistant', content: '', timestamp: 1, sdkAssistantUuid: 'a1' }, { id: 'm2', role: 'user', content: 'test', timestamp: 2, sdkUserUuid: 'u1' }, { id: 'm3', role: 'assistant', content: '', timestamp: 3, sdkAssistantUuid: 'a2' }, ]; mockAgentService.rewind.mockRejectedValue(new Error('SDK error')); await controller.rewind('m2'); expect(mockNotice).toHaveBeenCalled(); const msg = mockNotice.mock.calls[0][0] as string; expect(msg).toContain('SDK error'); }); it('should show i18n Notice when canRewind is false', async () => { deps.state.currentConversationId = 'conv-1'; deps.state.messages = [ { id: 'm1', role: 'assistant', content: '', timestamp: 1, sdkAssistantUuid: 'a1' }, { id: 'm2', role: 'user', content: 'test', timestamp: 2, sdkUserUuid: 'u1' }, { id: 'm3', role: 'assistant', content: '', timestamp: 3, sdkAssistantUuid: 'a2' }, ]; mockAgentService.rewind.mockResolvedValue({ canRewind: false, error: 'No checkpoints' }); await controller.rewind('m2'); expect(mockNotice).toHaveBeenCalled(); const msg = mockNotice.mock.calls[0][0] as string; expect(msg).toContain('No checkpoints'); }); it('should truncateAt, save with resumeSessionAt, and renderMessages on success', async () => { deps.state.currentConversationId = 'conv-1'; deps.state.messages = [ { id: 'm1', role: 'assistant', content: '', timestamp: 1, sdkAssistantUuid: 'prev-a' }, { id: 'm2', role: 'user', content: 'test', timestamp: 2, sdkUserUuid: 'user-uuid' }, { id: 'm3', role: 'assistant', content: 'resp', timestamp: 3, sdkAssistantUuid: 'resp-a' }, ]; const truncateSpy = jest.spyOn(deps.state, 'truncateAt'); await controller.rewind('m2'); expect(mockAgentService.rewind).toHaveBeenCalledWith('user-uuid', 'prev-a'); expect(truncateSpy).toHaveBeenCalledWith('m2'); expect(deps.renderer.renderMessages).toHaveBeenCalledWith( expect.any(Array), expect.any(Function) ); expect(deps.plugin.updateConversation).toHaveBeenCalledWith( 'conv-1', expect.objectContaining({ resumeSessionAt: 'prev-a' }) ); // Should populate input with rewound message content const inputEl = deps.getInputEl(); expect(inputEl.value).toBe('test'); expect(inputEl.focus).toHaveBeenCalled(); // Should show success notice with file count const noticeMsg = mockNotice.mock.calls[0][0] as string; expect(noticeMsg).toContain('1'); truncateSpy.mockRestore(); }); it('should abort when confirmation is declined', async () => { deps.state.currentConversationId = 'conv-1'; deps.state.messages = [ { id: 'm1', role: 'assistant', content: '', timestamp: 1, sdkAssistantUuid: 'a1' }, { id: 'm2', role: 'user', content: 'test', timestamp: 2, sdkUserUuid: 'u1' }, { id: 'm3', role: 'assistant', content: '', timestamp: 3, sdkAssistantUuid: 'a2' }, ]; (confirm as jest.Mock).mockResolvedValueOnce(false); await controller.rewind('m2'); expect(mockAgentService.rewind).not.toHaveBeenCalled(); expect(mockNotice).not.toHaveBeenCalled(); }); it('should re-check streaming state after confirmation dialog', async () => { deps.state.currentConversationId = 'conv-1'; deps.state.messages = [ { id: 'm1', role: 'assistant', content: '', timestamp: 1, sdkAssistantUuid: 'a1' }, { id: 'm2', role: 'user', content: 'test', timestamp: 2, sdkUserUuid: 'u1' }, { id: 'm3', role: 'assistant', content: '', timestamp: 3, sdkAssistantUuid: 'a2' }, ]; (confirm as jest.Mock).mockImplementationOnce(async () => { deps.state.isStreaming = true; return true; }); await controller.rewind('m2'); expect(mockAgentService.rewind).not.toHaveBeenCalled(); expect(mockNotice).toHaveBeenCalled(); }); it('should show a warning notice when rewind succeeded but save failed', async () => { deps.state.currentConversationId = 'conv-1'; deps.state.messages = [ { id: 'm1', role: 'assistant', content: '', timestamp: 1, sdkAssistantUuid: 'prev-a' }, { id: 'm2', role: 'user', content: 'test', timestamp: 2, sdkUserUuid: 'user-uuid' }, { id: 'm3', role: 'assistant', content: 'resp', timestamp: 3, sdkAssistantUuid: 'resp-a' }, ]; (deps.plugin.updateConversation as jest.Mock).mockRejectedValueOnce(new Error('Save failed')); await controller.rewind('m2'); expect(mockAgentService.rewind).toHaveBeenCalledWith('user-uuid', 'prev-a'); const msg = mockNotice.mock.calls[0][0] as string; expect(msg).toContain('Save failed'); }); }); ================================================ FILE: tests/unit/features/chat/controllers/InputController.test.ts ================================================ import { createMockEl } from '@test/helpers/mockElement'; import { Notice } from 'obsidian'; import { InputController, type InputControllerDeps } from '@/features/chat/controllers/InputController'; import { ChatState } from '@/features/chat/state/ChatState'; import { ResumeSessionDropdown } from '@/shared/components/ResumeSessionDropdown'; jest.mock('@/shared/components/ResumeSessionDropdown', () => ({ ResumeSessionDropdown: jest.fn(), })); beforeAll(() => { globalThis.requestAnimationFrame = (cb: FrameRequestCallback) => { cb(0); return 0; }; }); const mockNotice = Notice as jest.Mock; function createMockInputEl() { return { value: '', focus: jest.fn(), } as unknown as HTMLTextAreaElement; } function createMockWelcomeEl() { return { style: { display: '' } } as any; } function createMockFileContextManager() { return { startSession: jest.fn(), getCurrentNotePath: jest.fn().mockReturnValue(null), shouldSendCurrentNote: jest.fn().mockReturnValue(false), markCurrentNoteSent: jest.fn(), transformContextMentions: jest.fn().mockImplementation((text: string) => text), }; } function createMockImageContextManager() { return { hasImages: jest.fn().mockReturnValue(false), getAttachedImages: jest.fn().mockReturnValue([]), clearImages: jest.fn(), setImages: jest.fn(), }; } async function* createMockStream(chunks: any[]) { for (const chunk of chunks) { yield chunk; } } function createMockAgentService() { return { query: jest.fn(), cancel: jest.fn(), resetSession: jest.fn(), setApprovedPlanContent: jest.fn(), setCurrentPlanFilePath: jest.fn(), getApprovedPlanContent: jest.fn().mockReturnValue(null), clearApprovedPlanContent: jest.fn(), ensureReady: jest.fn().mockResolvedValue(true), getSessionId: jest.fn().mockReturnValue(null), }; } function createMockInstructionRefineService(overrides: Record<string, jest.Mock> = {}) { return { refineInstruction: jest.fn().mockResolvedValue({ success: true }), resetConversation: jest.fn(), continueConversation: jest.fn(), cancel: jest.fn(), ...overrides, }; } function createMockInstructionModeManager() { return { clear: jest.fn() }; } function createMockDeps(overrides: Partial<InputControllerDeps> = {}): InputControllerDeps & { mockAgentService: ReturnType<typeof createMockAgentService> } { const state = new ChatState(); const inputEl = createMockInputEl(); const queueIndicatorEl = createMockEl(); queueIndicatorEl.style.display = 'none'; jest.spyOn(queueIndicatorEl, 'setText'); state.queueIndicatorEl = queueIndicatorEl as any; const imageContextManager = createMockImageContextManager(); const mockAgentService = createMockAgentService(); return { plugin: { saveSettings: jest.fn(), settings: { slashCommands: [], blockedCommands: { unix: [], windows: [] }, enableBlocklist: true, permissionMode: 'yolo', enableAutoTitleGeneration: true, }, mcpManager: { extractMentions: jest.fn().mockReturnValue(new Set()), transformMentions: jest.fn().mockImplementation((text: string) => text), }, renameConversation: jest.fn(), updateConversation: jest.fn(), getConversationSync: jest.fn().mockReturnValue(null), getConversationById: jest.fn().mockResolvedValue(null), createConversation: jest.fn().mockResolvedValue({ id: 'conv-1' }), } as any, state, renderer: { addMessage: jest.fn().mockReturnValue({ querySelector: jest.fn().mockReturnValue(createMockEl()), }), refreshActionButtons: jest.fn(), } as any, streamController: { showThinkingIndicator: jest.fn(), hideThinkingIndicator: jest.fn(), handleStreamChunk: jest.fn(), finalizeCurrentTextBlock: jest.fn(), finalizeCurrentThinkingBlock: jest.fn(), appendText: jest.fn(), } as any, selectionController: { getContext: jest.fn().mockReturnValue(null), } as any, canvasSelectionController: { getContext: jest.fn().mockReturnValue(null), } as any, conversationController: { save: jest.fn(), generateFallbackTitle: jest.fn().mockReturnValue('Test Title'), updateHistoryDropdown: jest.fn(), clearTerminalSubagentsFromMessages: jest.fn(), } as any, getInputEl: () => inputEl, getInputContainerEl: () => createMockEl() as any, getWelcomeEl: () => null, getMessagesEl: () => createMockEl() as any, getFileContextManager: () => ({ startSession: jest.fn(), getCurrentNotePath: jest.fn().mockReturnValue(null), shouldSendCurrentNote: jest.fn().mockReturnValue(false), markCurrentNoteSent: jest.fn(), transformContextMentions: jest.fn().mockImplementation((text: string) => text), }) as any, getImageContextManager: () => imageContextManager as any, getMcpServerSelector: () => null, getExternalContextSelector: () => null, getInstructionModeManager: () => null, getInstructionRefineService: () => null, getTitleGenerationService: () => null, getStatusPanel: () => null, generateId: () => `msg-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`, resetInputHeight: jest.fn(), getAgentService: () => mockAgentService as any, getSubagentManager: () => ({ resetSpawnedCount: jest.fn(), resetStreamingState: jest.fn() }) as any, mockAgentService, ...overrides, }; } /** * Composite helper for tests that need a complete "sendable" deps setup. * Creates welcomeEl + fileContextManager and sets conversationId by default, * eliminating the repeated boilerplate in send-path tests. */ function createSendableDeps( overrides: Partial<InputControllerDeps> = {}, conversationId: string | null = 'conv-1', ): InputControllerDeps & { mockAgentService: ReturnType<typeof createMockAgentService> } { const welcomeEl = createMockWelcomeEl(); const fileContextManager = createMockFileContextManager(); const result = createMockDeps({ getWelcomeEl: () => welcomeEl, getFileContextManager: () => fileContextManager as any, ...overrides, }); if (conversationId !== null) { result.state.currentConversationId = conversationId; } return result; } describe('InputController - Message Queue', () => { let controller: InputController; let deps: InputControllerDeps; let inputEl: ReturnType<typeof createMockInputEl>; beforeEach(() => { jest.clearAllMocks(); deps = createMockDeps(); inputEl = deps.getInputEl() as ReturnType<typeof createMockInputEl>; controller = new InputController(deps); }); describe('Queuing messages while streaming', () => { it('should queue message when isStreaming is true', async () => { deps.state.isStreaming = true; inputEl.value = 'queued message'; await controller.sendMessage(); expect(deps.state.queuedMessage).toEqual({ content: 'queued message', images: undefined, editorContext: null, browserContext: null, canvasContext: null, hidden: undefined, }); expect(inputEl.value).toBe(''); }); it('should queue message with images when streaming', async () => { deps.state.isStreaming = true; inputEl.value = 'queued with images'; const mockImages = [{ id: 'img1', name: 'test.png' }]; const imageContextManager = deps.getImageContextManager()!; (imageContextManager.hasImages as jest.Mock).mockReturnValue(true); (imageContextManager.getAttachedImages as jest.Mock).mockReturnValue(mockImages); await controller.sendMessage(); expect(deps.state.queuedMessage).toEqual({ content: 'queued with images', images: mockImages, editorContext: null, browserContext: null, canvasContext: null, hidden: undefined, }); expect(imageContextManager.clearImages).toHaveBeenCalled(); }); it('should append new message to existing queued message', async () => { deps.state.isStreaming = true; inputEl.value = 'first message'; await controller.sendMessage(); inputEl.value = 'second message'; await controller.sendMessage(); expect(deps.state.queuedMessage!.content).toBe('first message\n\nsecond message'); }); it('should merge images when appending to queue', async () => { deps.state.isStreaming = true; const imageContextManager = deps.getImageContextManager()!; inputEl.value = 'first'; (imageContextManager.hasImages as jest.Mock).mockReturnValue(true); (imageContextManager.getAttachedImages as jest.Mock).mockReturnValue([{ id: 'img1' }]); await controller.sendMessage(); inputEl.value = 'second'; (imageContextManager.getAttachedImages as jest.Mock).mockReturnValue([{ id: 'img2' }]); await controller.sendMessage(); expect(deps.state.queuedMessage!.images).toHaveLength(2); expect(deps.state.queuedMessage!.images![0].id).toBe('img1'); expect(deps.state.queuedMessage!.images![1].id).toBe('img2'); }); it('should not queue empty message', async () => { deps.state.isStreaming = true; inputEl.value = ''; const imageContextManager = deps.getImageContextManager()!; (imageContextManager.hasImages as jest.Mock).mockReturnValue(false); await controller.sendMessage(); expect(deps.state.queuedMessage).toBeNull(); }); }); describe('Queued message processing', () => { it('should send queued message in non-plan mode', async () => { jest.useFakeTimers(); try { deps.plugin.settings.permissionMode = 'normal'; deps.state.queuedMessage = { content: 'queued plan', images: undefined, editorContext: null, canvasContext: null, }; const sendSpy = jest.spyOn(controller, 'sendMessage').mockResolvedValue(undefined); (controller as any).processQueuedMessage(); jest.runAllTimers(); await Promise.resolve(); expect(sendSpy).toHaveBeenCalledWith(expect.objectContaining({ editorContextOverride: null })); sendSpy.mockRestore(); } finally { jest.useRealTimers(); } }); }); describe('Queue indicator UI', () => { it('should show queue indicator when message is queued', () => { deps.state.queuedMessage = { content: 'test message', images: undefined, editorContext: null, canvasContext: null }; controller.updateQueueIndicator(); const queueIndicatorEl = deps.state.queueIndicatorEl as any; expect(queueIndicatorEl.setText).toHaveBeenCalledWith('⌙ Queued: test message'); expect(queueIndicatorEl.style.display).toBe('block'); }); it('should hide queue indicator when no message is queued', () => { deps.state.queuedMessage = null; controller.updateQueueIndicator(); const queueIndicatorEl = deps.state.queueIndicatorEl as any; expect(queueIndicatorEl.style.display).toBe('none'); }); it('should truncate long message preview in indicator', () => { const longMessage = 'a'.repeat(100); deps.state.queuedMessage = { content: longMessage, images: undefined, editorContext: null, canvasContext: null }; controller.updateQueueIndicator(); const queueIndicatorEl = deps.state.queueIndicatorEl as any; const call = queueIndicatorEl.setText.mock.calls[0][0] as string; expect(call).toContain('...'); }); it('should include [images] when queue message has images', () => { const mockImages = [{ id: 'img1', name: 'test.png' }]; deps.state.queuedMessage = { content: 'queued content', images: mockImages as any, editorContext: null, canvasContext: null }; controller.updateQueueIndicator(); const queueIndicatorEl = deps.state.queueIndicatorEl as any; const call = queueIndicatorEl.setText.mock.calls[0][0] as string; expect(call).toContain('queued content'); expect(call).toContain('[images]'); }); it('should show [images] when queue message has only images', () => { const mockImages = [{ id: 'img1', name: 'test.png' }]; deps.state.queuedMessage = { content: '', images: mockImages as any, editorContext: null, canvasContext: null }; controller.updateQueueIndicator(); const queueIndicatorEl = deps.state.queueIndicatorEl as any; expect(queueIndicatorEl.setText).toHaveBeenCalledWith('⌙ Queued: [images]'); }); }); describe('Clearing queued message', () => { it('should clear queued message and update indicator', () => { deps.state.queuedMessage = { content: 'test', images: undefined, editorContext: null, canvasContext: null }; controller.clearQueuedMessage(); expect(deps.state.queuedMessage).toBeNull(); const queueIndicatorEl = deps.state.queueIndicatorEl as any; expect(queueIndicatorEl.style.display).toBe('none'); }); }); describe('Cancel streaming', () => { it('should clear queue on cancel', () => { deps.state.queuedMessage = { content: 'test', images: undefined, editorContext: null, canvasContext: null }; deps.state.isStreaming = true; controller.cancelStreaming(); expect(deps.state.queuedMessage).toBeNull(); expect(deps.state.cancelRequested).toBe(true); expect((deps as any).mockAgentService.cancel).toHaveBeenCalled(); }); it('should not cancel if not streaming', () => { deps.state.isStreaming = false; controller.cancelStreaming(); expect((deps as any).mockAgentService.cancel).not.toHaveBeenCalled(); }); }); describe('Sending messages', () => { it('should send message, hide welcome, and save conversation', async () => { const welcomeEl = createMockWelcomeEl(); const fileContextManager = createMockFileContextManager(); const imageContextManager = deps.getImageContextManager()!; deps.getWelcomeEl = () => welcomeEl; deps.getFileContextManager = () => fileContextManager as any; deps.state.currentConversationId = 'conv-1'; (deps as any).mockAgentService.query = jest.fn().mockImplementation(() => createMockStream([{ type: 'done' }])); inputEl.value = 'See ![[image.png]]'; await controller.sendMessage(); expect(welcomeEl.style.display).toBe('none'); expect(fileContextManager.startSession).toHaveBeenCalled(); expect(deps.renderer.addMessage).toHaveBeenCalledTimes(2); expect(deps.state.messages).toHaveLength(2); // Without XML context tags, content equals displayContent (no <query> wrapper) expect(deps.state.messages[0].content).toBe('See ![[image.png]]'); expect(deps.state.messages[0].displayContent).toBe('See ![[image.png]]'); expect(deps.state.messages[0].images).toBeUndefined(); expect(imageContextManager.clearImages).toHaveBeenCalled(); expect(deps.plugin.renameConversation).toHaveBeenCalledWith('conv-1', 'Test Title'); // No sdk_user_sent in stream → save without clearing resumeSessionAt expect(deps.conversationController.save).toHaveBeenCalledWith(true, undefined); expect((deps as any).mockAgentService.query).toHaveBeenCalled(); expect(deps.state.isStreaming).toBe(false); }); it('should prepend current note only once per session', async () => { const prompts: string[] = []; let currentNoteSent = false; const fileContextManager = { startSession: jest.fn(), getCurrentNotePath: jest.fn().mockReturnValue('notes/session.md'), shouldSendCurrentNote: jest.fn().mockImplementation(() => !currentNoteSent), markCurrentNoteSent: jest.fn().mockImplementation(() => { currentNoteSent = true; }), transformContextMentions: jest.fn().mockImplementation((text: string) => text), }; deps.getFileContextManager = () => fileContextManager as any; (deps as any).mockAgentService.query = jest.fn().mockImplementation((prompt: string) => { prompts.push(prompt); return createMockStream([{ type: 'done' }]); }); inputEl.value = 'First message'; await controller.sendMessage(); inputEl.value = 'Second message'; await controller.sendMessage(); expect(prompts[0]).toContain('<current_note>'); expect(prompts[1]).not.toContain('<current_note>'); }); it('should include MCP options in query when mentions are present', async () => { const mcpMentions = new Set(['server-a']); const enabledServers = new Set(['server-b']); deps.plugin.mcpManager.extractMentions = jest.fn().mockReturnValue(mcpMentions); deps.getMcpServerSelector = () => ({ getEnabledServers: () => enabledServers, }) as any; (deps as any).mockAgentService.query = jest.fn().mockImplementation(() => createMockStream([{ type: 'done' }])); inputEl.value = 'hello'; await controller.sendMessage(); const queryCall = ((deps as any).mockAgentService.query as jest.Mock).mock.calls[0]; const queryOptions = queryCall[3]; expect(queryOptions.mcpMentions).toBe(mcpMentions); expect(queryOptions.enabledMcpServers).toBe(enabledServers); }); it('should append browser selection context when available', async () => { const mockAgentService = createMockAgentService(); const localDeps = createSendableDeps({ browserSelectionController: { getContext: jest.fn().mockReturnValue({ source: 'surfing-view', selectedText: 'selected from browser', title: 'Surfing', }), } as any, getAgentService: () => mockAgentService as any, }); const localController = new InputController(localDeps); mockAgentService.query.mockImplementation((prompt: string) => { expect(prompt).toContain('<browser_selection source="surfing-view" title="Surfing">'); expect(prompt).toContain('selected from browser'); return createMockStream([{ type: 'done' }]); }); const localInput = localDeps.getInputEl() as ReturnType<typeof createMockInputEl>; localInput.value = 'Summarize this'; await localController.sendMessage(); expect(mockAgentService.query).toHaveBeenCalled(); }); }); describe('Conversation operation guards', () => { it('should not send message when isCreatingConversation is true', async () => { deps.state.isCreatingConversation = true; inputEl.value = 'test message'; await controller.sendMessage(); expect((deps as any).mockAgentService.query).not.toHaveBeenCalled(); // Input should be preserved for retry expect(inputEl.value).toBe('test message'); }); it('should not send message when isSwitchingConversation is true', async () => { deps.state.isSwitchingConversation = true; inputEl.value = 'test message'; await controller.sendMessage(); expect((deps as any).mockAgentService.query).not.toHaveBeenCalled(); // Input should be preserved for retry expect(inputEl.value).toBe('test message'); }); it('should preserve images when blocked by conversation operation', async () => { deps.state.isCreatingConversation = true; inputEl.value = 'test message'; const mockImages = [{ id: 'img1', name: 'test.png' }]; const imageContextManager = deps.getImageContextManager()!; (imageContextManager.hasImages as jest.Mock).mockReturnValue(true); (imageContextManager.getAttachedImages as jest.Mock).mockReturnValue(mockImages); await controller.sendMessage(); expect((deps as any).mockAgentService.query).not.toHaveBeenCalled(); // Images should NOT be cleared expect(imageContextManager.clearImages).not.toHaveBeenCalled(); }); }); describe('Title generation', () => { it('should set pending status and fallback title after first user message', async () => { const mockTitleService = { generateTitle: jest.fn().mockResolvedValue(undefined), cancel: jest.fn(), }; // conversationId=null to test the conversation creation path deps = createSendableDeps({ getTitleGenerationService: () => mockTitleService as any, }, null); ((deps as any).mockAgentService.query as jest.Mock).mockReturnValue( createMockStream([ { type: 'text', content: 'Hello, how can I help?' }, { type: 'done' }, ]) ); (deps.streamController.handleStreamChunk as jest.Mock).mockImplementation(async (chunk, msg) => { if (chunk.type === 'text') { msg.content = chunk.content; } }); inputEl = deps.getInputEl() as ReturnType<typeof createMockInputEl>; inputEl.value = 'Hello world'; controller = new InputController(deps); await controller.sendMessage(); expect(deps.plugin.createConversation).toHaveBeenCalled(); expect(deps.plugin.updateConversation).toHaveBeenCalledWith('conv-1', { titleGenerationStatus: 'pending' }); expect(deps.plugin.renameConversation).toHaveBeenCalledWith('conv-1', 'Test Title'); }); it('should find messages by role, not by index', async () => { deps = createSendableDeps(); ((deps as any).mockAgentService.query as jest.Mock).mockReturnValue( createMockStream([{ type: 'done' }]) ); inputEl = deps.getInputEl() as ReturnType<typeof createMockInputEl>; inputEl.value = 'Test message'; controller = new InputController(deps); await controller.sendMessage(); const userMsg = deps.state.messages.find(m => m.role === 'user'); const assistantMsg = deps.state.messages.find(m => m.role === 'assistant'); expect(userMsg).toBeDefined(); expect(assistantMsg).toBeDefined(); }); it('should call title generation service when available', async () => { const mockTitleService = { generateTitle: jest.fn().mockResolvedValue(undefined), cancel: jest.fn(), }; deps = createSendableDeps({ getTitleGenerationService: () => mockTitleService as any, }); ((deps as any).mockAgentService.query as jest.Mock).mockReturnValue( createMockStream([ { type: 'text', content: 'Response text' }, { type: 'done' }, ]) ); (deps.streamController.handleStreamChunk as jest.Mock).mockImplementation(async (chunk, msg) => { if (chunk.type === 'text') { msg.content = chunk.content; } }); inputEl = deps.getInputEl() as ReturnType<typeof createMockInputEl>; inputEl.value = 'Hello world'; controller = new InputController(deps); await controller.sendMessage(); expect(mockTitleService.generateTitle).toHaveBeenCalled(); const callArgs = mockTitleService.generateTitle.mock.calls[0]; expect(callArgs[0]).toBe('conv-1'); expect(callArgs[1]).toContain('Hello world'); }); it('should not overwrite user-renamed title in callback', async () => { const mockTitleService = { generateTitle: jest.fn().mockResolvedValue(undefined), cancel: jest.fn(), }; deps = createSendableDeps({ getTitleGenerationService: () => mockTitleService as any, }); ((deps as any).mockAgentService.query as jest.Mock).mockReturnValue( createMockStream([ { type: 'text', content: 'Response' }, { type: 'done' }, ]) ); (deps.streamController.handleStreamChunk as jest.Mock).mockImplementation(async (chunk, msg) => { if (chunk.type === 'text') { msg.content = chunk.content; } }); // Simulate user having renamed the conversation (deps.plugin.getConversationById as jest.Mock).mockResolvedValue({ id: 'conv-1', title: 'User Custom Title', }); inputEl = deps.getInputEl() as ReturnType<typeof createMockInputEl>; inputEl.value = 'Test'; controller = new InputController(deps); await controller.sendMessage(); const callback = mockTitleService.generateTitle.mock.calls[0][2]; await callback('conv-1', { success: true, title: 'AI Generated Title' }); // Should clear status since user manually renamed (not apply AI title) expect(deps.plugin.updateConversation).toHaveBeenCalledWith('conv-1', { titleGenerationStatus: undefined }); }); it('should not set pending status when titleService is null', async () => { deps = createSendableDeps({ getTitleGenerationService: () => null, }); ((deps as any).mockAgentService.query as jest.Mock).mockReturnValue( createMockStream([ { type: 'text', content: 'Response' }, { type: 'done' }, ]) ); (deps.streamController.handleStreamChunk as jest.Mock).mockImplementation(async (chunk, msg) => { if (chunk.type === 'text') { msg.content = chunk.content; } }); inputEl = deps.getInputEl() as ReturnType<typeof createMockInputEl>; inputEl.value = 'Test message'; controller = new InputController(deps); await controller.sendMessage(); const updateCalls = (deps.plugin.updateConversation as jest.Mock).mock.calls; const pendingCall = updateCalls.find((call: [string, { titleGenerationStatus?: string }]) => call[1]?.titleGenerationStatus === 'pending' ); expect(pendingCall).toBeUndefined(); }); it('should NOT call title generation service when enableAutoTitleGeneration is false', async () => { const mockTitleService = { generateTitle: jest.fn().mockResolvedValue(undefined), cancel: jest.fn(), }; deps = createSendableDeps({ getTitleGenerationService: () => mockTitleService as any, }); deps.plugin.settings.enableAutoTitleGeneration = false; ((deps as any).mockAgentService.query as jest.Mock).mockReturnValue( createMockStream([ { type: 'text', content: 'Response text' }, { type: 'done' }, ]) ); (deps.streamController.handleStreamChunk as jest.Mock).mockImplementation(async (chunk, msg) => { if (chunk.type === 'text') { msg.content = chunk.content; } }); inputEl = deps.getInputEl() as ReturnType<typeof createMockInputEl>; inputEl.value = 'Hello world'; controller = new InputController(deps); await controller.sendMessage(); expect(mockTitleService.generateTitle).not.toHaveBeenCalled(); const updateCalls = (deps.plugin.updateConversation as jest.Mock).mock.calls; const pendingCall = updateCalls.find((call: [string, { titleGenerationStatus?: string }]) => call[1]?.titleGenerationStatus === 'pending' ); expect(pendingCall).toBeUndefined(); expect(deps.plugin.renameConversation).toHaveBeenCalledWith('conv-1', 'Test Title'); }); }); describe('Auto-hide status panels on response end', () => { it('should clear currentTodos when all todos are completed', async () => { deps = createSendableDeps(); deps.state.currentTodos = [ { content: 'Task 1', status: 'completed', activeForm: 'Task 1' }, { content: 'Task 2', status: 'completed', activeForm: 'Task 2' }, ]; ((deps as any).mockAgentService.query as jest.Mock).mockReturnValue( createMockStream([{ type: 'done' }]) ); inputEl = deps.getInputEl() as ReturnType<typeof createMockInputEl>; inputEl.value = 'Test message'; controller = new InputController(deps); await controller.sendMessage(); expect(deps.state.currentTodos).toBeNull(); }); it('should NOT clear currentTodos when some todos are pending', async () => { deps = createSendableDeps(); deps.state.currentTodos = [ { content: 'Task 1', status: 'completed', activeForm: 'Task 1' }, { content: 'Task 2', status: 'pending', activeForm: 'Task 2' }, ]; ((deps as any).mockAgentService.query as jest.Mock).mockReturnValue( createMockStream([{ type: 'done' }]) ); inputEl = deps.getInputEl() as ReturnType<typeof createMockInputEl>; inputEl.value = 'Test message'; controller = new InputController(deps); await controller.sendMessage(); expect(deps.state.currentTodos).not.toBeNull(); expect(deps.state.currentTodos).toHaveLength(2); }); it('should handle null statusPanel gracefully', async () => { deps = createSendableDeps({ getStatusPanel: () => null, }); ((deps as any).mockAgentService.query as jest.Mock).mockReturnValue( createMockStream([{ type: 'done' }]) ); inputEl = deps.getInputEl() as ReturnType<typeof createMockInputEl>; inputEl.value = 'Test message'; controller = new InputController(deps); await expect(controller.sendMessage()).resolves.not.toThrow(); }); }); describe('Approval inline tracking', () => { it('should dismiss pending inline and clear reference', () => { controller = new InputController(deps); const mockInline = { destroy: jest.fn() }; (controller as any).pendingApprovalInline = mockInline; controller.dismissPendingApproval(); expect(mockInline.destroy).toHaveBeenCalled(); expect((controller as any).pendingApprovalInline).toBeNull(); }); it('should dismiss pending ask inline and clear reference', () => { controller = new InputController(deps); const mockAskInline = { destroy: jest.fn() }; (controller as any).pendingAskInline = mockAskInline; controller.dismissPendingApproval(); expect(mockAskInline.destroy).toHaveBeenCalled(); expect((controller as any).pendingAskInline).toBeNull(); }); it('should dismiss both approval and ask inlines', () => { controller = new InputController(deps); const mockApproval = { destroy: jest.fn() }; const mockAsk = { destroy: jest.fn() }; (controller as any).pendingApprovalInline = mockApproval; (controller as any).pendingAskInline = mockAsk; controller.dismissPendingApproval(); expect(mockApproval.destroy).toHaveBeenCalled(); expect(mockAsk.destroy).toHaveBeenCalled(); expect((controller as any).pendingApprovalInline).toBeNull(); expect((controller as any).pendingAskInline).toBeNull(); }); it('should be a no-op when no inline is pending', () => { controller = new InputController(deps); expect((controller as any).pendingApprovalInline).toBeNull(); expect(() => controller.dismissPendingApproval()).not.toThrow(); }); }); describe('Built-in commands - /add-dir', () => { beforeEach(() => { mockNotice.mockClear(); }); it('should show error notice when external context selector is not available', async () => { deps.getExternalContextSelector = () => null; inputEl.value = '/add-dir /some/path'; controller = new InputController(deps); await controller.sendMessage(); expect(mockNotice).toHaveBeenCalledWith('External context selector not available.'); expect(inputEl.value).toBe(''); }); it('should show success notice when path is added successfully', async () => { const mockExternalContextSelector = { getExternalContexts: jest.fn().mockReturnValue([]), addExternalContext: jest.fn().mockReturnValue({ success: true, normalizedPath: '/some/path' }), }; deps.getExternalContextSelector = () => mockExternalContextSelector; inputEl.value = '/add-dir /some/path'; controller = new InputController(deps); await controller.sendMessage(); expect(mockExternalContextSelector.addExternalContext).toHaveBeenCalledWith('/some/path'); expect(mockNotice).toHaveBeenCalledWith('Added external context: /some/path'); expect(inputEl.value).toBe(''); }); it('should show error notice when /add-dir is called without path', async () => { const mockExternalContextSelector = { getExternalContexts: jest.fn().mockReturnValue([]), addExternalContext: jest.fn().mockReturnValue({ success: false, error: 'No path provided. Usage: /add-dir /absolute/path', }), }; deps.getExternalContextSelector = () => mockExternalContextSelector; inputEl.value = '/add-dir'; controller = new InputController(deps); await controller.sendMessage(); expect(mockExternalContextSelector.addExternalContext).toHaveBeenCalledWith(''); expect(mockNotice).toHaveBeenCalledWith('No path provided. Usage: /add-dir /absolute/path'); expect(inputEl.value).toBe(''); }); it('should show error notice when path addition fails', async () => { const mockExternalContextSelector = { getExternalContexts: jest.fn().mockReturnValue([]), addExternalContext: jest.fn().mockReturnValue({ success: false, error: 'Path must be absolute. Usage: /add-dir /absolute/path', }), }; deps.getExternalContextSelector = () => mockExternalContextSelector; inputEl.value = '/add-dir relative/path'; controller = new InputController(deps); await controller.sendMessage(); expect(mockExternalContextSelector.addExternalContext).toHaveBeenCalledWith('relative/path'); expect(mockNotice).toHaveBeenCalledWith('Path must be absolute. Usage: /add-dir /absolute/path'); expect(inputEl.value).toBe(''); }); it('should handle /add-dir with home path expansion', async () => { const expandedPath = '/Users/test/projects'; const mockExternalContextSelector = { getExternalContexts: jest.fn().mockReturnValue([]), addExternalContext: jest.fn().mockReturnValue({ success: true, normalizedPath: expandedPath }), }; deps.getExternalContextSelector = () => mockExternalContextSelector; inputEl.value = '/add-dir ~/projects'; controller = new InputController(deps); await controller.sendMessage(); expect(mockExternalContextSelector.addExternalContext).toHaveBeenCalledWith('~/projects'); expect(mockNotice).toHaveBeenCalledWith(`Added external context: ${expandedPath}`); }); it('should handle /add-dir with quoted path', async () => { const normalizedPath = '/path/with spaces'; const mockExternalContextSelector = { getExternalContexts: jest.fn().mockReturnValue([]), addExternalContext: jest.fn().mockReturnValue({ success: true, normalizedPath }), }; deps.getExternalContextSelector = () => mockExternalContextSelector; inputEl.value = '/add-dir "/path/with spaces"'; controller = new InputController(deps); await controller.sendMessage(); expect(mockExternalContextSelector.addExternalContext).toHaveBeenCalledWith('"/path/with spaces"'); expect(mockNotice).toHaveBeenCalledWith(`Added external context: ${normalizedPath}`); }); }); describe('Built-in commands - /clear', () => { it('should call conversationController.createNew on /clear', async () => { (deps.conversationController as any).createNew = jest.fn().mockResolvedValue(undefined); inputEl.value = '/clear'; controller = new InputController(deps); await controller.sendMessage(); expect((deps.conversationController as any).createNew).toHaveBeenCalled(); expect(inputEl.value).toBe(''); }); }); describe('Built-in commands - /resume', () => { const mockConversations = [ { id: 'conv-1', title: 'Chat 1', createdAt: 1000, updatedAt: 1000, messageCount: 1, preview: '' }, ]; let mockDropdownInstance: { isVisible: jest.Mock; handleKeydown: jest.Mock; destroy: jest.Mock; }; beforeEach(() => { mockNotice.mockClear(); mockDropdownInstance = { isVisible: jest.fn().mockReturnValue(true), handleKeydown: jest.fn().mockReturnValue(false), destroy: jest.fn(), }; (ResumeSessionDropdown as jest.Mock).mockImplementation(() => mockDropdownInstance); }); it('should show notice when no conversations exist', async () => { (deps.plugin as any).getConversationList = jest.fn().mockReturnValue([]); inputEl.value = '/resume'; controller = new InputController(deps); await controller.sendMessage(); expect(mockNotice).toHaveBeenCalledWith('No conversations to resume'); expect(ResumeSessionDropdown).not.toHaveBeenCalled(); expect(inputEl.value).toBe(''); }); it('should create dropdown when conversations exist', async () => { (deps.plugin as any).getConversationList = jest.fn().mockReturnValue(mockConversations); inputEl.value = '/resume'; controller = new InputController(deps); await controller.sendMessage(); expect(ResumeSessionDropdown).toHaveBeenCalledWith( expect.anything(), expect.anything(), mockConversations, deps.state.currentConversationId, expect.objectContaining({ onSelect: expect.any(Function), onDismiss: expect.any(Function) }), ); expect(controller.isResumeDropdownVisible()).toBe(true); }); it('should call switchTo on select callback', async () => { (deps.plugin as any).getConversationList = jest.fn().mockReturnValue(mockConversations); (deps.conversationController as any).switchTo = jest.fn().mockResolvedValue(undefined); inputEl.value = '/resume'; controller = new InputController(deps); await controller.sendMessage(); const callbacks = (ResumeSessionDropdown as jest.Mock).mock.calls[0][4]; callbacks.onSelect('conv-1'); expect((deps.conversationController as any).switchTo).toHaveBeenCalledWith('conv-1'); expect(mockDropdownInstance.destroy).toHaveBeenCalled(); }); it('should call openConversation on select callback when provided', async () => { (deps.plugin as any).getConversationList = jest.fn().mockReturnValue(mockConversations); (deps.conversationController as any).switchTo = jest.fn().mockResolvedValue(undefined); deps.openConversation = jest.fn().mockResolvedValue(undefined); inputEl.value = '/resume'; controller = new InputController(deps); await controller.sendMessage(); const callbacks = (ResumeSessionDropdown as jest.Mock).mock.calls[0][4]; callbacks.onSelect('conv-1'); expect(deps.openConversation).toHaveBeenCalledWith('conv-1'); expect((deps.conversationController as any).switchTo).not.toHaveBeenCalled(); expect(mockDropdownInstance.destroy).toHaveBeenCalled(); }); it('should destroy dropdown on dismiss callback', async () => { (deps.plugin as any).getConversationList = jest.fn().mockReturnValue(mockConversations); inputEl.value = '/resume'; controller = new InputController(deps); await controller.sendMessage(); const callbacks = (ResumeSessionDropdown as jest.Mock).mock.calls[0][4]; callbacks.onDismiss(); expect(mockDropdownInstance.destroy).toHaveBeenCalled(); expect(controller.isResumeDropdownVisible()).toBe(false); }); it('should show notice with error message when openConversation rejects', async () => { (deps.plugin as any).getConversationList = jest.fn().mockReturnValue(mockConversations); deps.openConversation = jest.fn().mockRejectedValue(new Error('session not found')); inputEl.value = '/resume'; controller = new InputController(deps); await controller.sendMessage(); const callbacks = (ResumeSessionDropdown as jest.Mock).mock.calls[0][4]; callbacks.onSelect('conv-1'); await Promise.resolve(); expect(mockNotice).toHaveBeenCalledWith('Failed to open conversation: session not found'); }); it('should destroy existing dropdown before creating new one', async () => { (deps.plugin as any).getConversationList = jest.fn().mockReturnValue(mockConversations); inputEl.value = '/resume'; controller = new InputController(deps); await controller.sendMessage(); const firstInstance = mockDropdownInstance; // Create new mock instance for second call const secondInstance = { isVisible: jest.fn().mockReturnValue(true), handleKeydown: jest.fn(), destroy: jest.fn() }; (ResumeSessionDropdown as jest.Mock).mockImplementation(() => secondInstance); inputEl.value = '/resume'; await controller.sendMessage(); expect(firstInstance.destroy).toHaveBeenCalled(); expect(ResumeSessionDropdown).toHaveBeenCalledTimes(2); }); }); describe('Built-in commands - /fork', () => { beforeEach(() => { mockNotice.mockClear(); }); it('should call onForkAll callback when /fork is executed', async () => { const mockOnForkAll = jest.fn().mockResolvedValue(undefined); deps.onForkAll = mockOnForkAll; inputEl.value = '/fork'; controller = new InputController(deps); await controller.sendMessage(); expect(mockOnForkAll).toHaveBeenCalled(); expect(inputEl.value).toBe(''); }); it('should show notice when onForkAll is not available', async () => { deps.onForkAll = undefined; inputEl.value = '/fork'; controller = new InputController(deps); await controller.sendMessage(); expect(mockNotice).toHaveBeenCalledWith('Fork not available.'); expect(inputEl.value).toBe(''); }); }); describe('Cancel streaming - restore behavior', () => { it('should set cancelRequested and call agent cancel', () => { deps.state.isStreaming = true; controller = new InputController(deps); controller.cancelStreaming(); expect(deps.state.cancelRequested).toBe(true); expect((deps as any).mockAgentService.cancel).toHaveBeenCalled(); }); it('should restore queued message to input when cancelling', () => { deps.state.isStreaming = true; deps.state.queuedMessage = { content: 'restored text', images: undefined, editorContext: null, canvasContext: null }; controller = new InputController(deps); controller.cancelStreaming(); expect(deps.state.queuedMessage).toBeNull(); expect(inputEl.value).toBe('restored text'); }); it('should restore queued images to image context manager when cancelling', () => { deps.state.isStreaming = true; const mockImages = [{ id: 'img1', name: 'test.png' }]; deps.state.queuedMessage = { content: 'msg', images: mockImages as any, editorContext: null, canvasContext: null }; controller = new InputController(deps); controller.cancelStreaming(); const imageContextManager = deps.getImageContextManager()!; expect(imageContextManager.setImages).toHaveBeenCalledWith(mockImages); }); it('should hide thinking indicator when cancelling', () => { deps.state.isStreaming = true; controller = new InputController(deps); controller.cancelStreaming(); expect(deps.streamController.hideThinkingIndicator).toHaveBeenCalled(); }); it('should be a no-op when not streaming', () => { deps.state.isStreaming = false; controller = new InputController(deps); controller.cancelStreaming(); expect(deps.state.cancelRequested).toBe(false); expect((deps as any).mockAgentService.cancel).not.toHaveBeenCalled(); }); }); describe('ensureServiceInitialized failure', () => { beforeEach(() => { mockNotice.mockClear(); }); it('should show Notice and reset streaming when ensureServiceInitialized returns false', async () => { deps = createSendableDeps({ ensureServiceInitialized: jest.fn().mockResolvedValue(false), }); inputEl = deps.getInputEl() as ReturnType<typeof createMockInputEl>; inputEl.value = 'test message'; controller = new InputController(deps); await controller.sendMessage(); expect(mockNotice).toHaveBeenCalledWith('Failed to initialize agent service. Please try again.'); expect(deps.streamController.hideThinkingIndicator).toHaveBeenCalled(); expect(deps.state.isStreaming).toBe(false); expect((deps as any).mockAgentService.query).not.toHaveBeenCalled(); }); }); describe('Agent service null', () => { beforeEach(() => { mockNotice.mockClear(); }); it('should show Notice when getAgentService returns null', async () => { deps = createSendableDeps({ getAgentService: () => null, }); inputEl = deps.getInputEl() as ReturnType<typeof createMockInputEl>; inputEl.value = 'test message'; controller = new InputController(deps); await controller.sendMessage(); expect(mockNotice).toHaveBeenCalledWith('Agent service not available. Please reload the plugin.'); expect((deps as any).mockAgentService.query).not.toHaveBeenCalled(); }); }); describe('Streaming error handling', () => { it('should catch errors and display via appendText', async () => { deps = createSendableDeps(); ((deps as any).mockAgentService.query as jest.Mock).mockImplementation(() => { throw new Error('Network timeout'); }); inputEl = deps.getInputEl() as ReturnType<typeof createMockInputEl>; inputEl.value = 'test message'; controller = new InputController(deps); await controller.sendMessage(); expect(deps.streamController.appendText).toHaveBeenCalledWith('\n\n**Error:** Network timeout'); expect(deps.state.isStreaming).toBe(false); }); it('should handle non-Error thrown values', async () => { deps = createSendableDeps(); ((deps as any).mockAgentService.query as jest.Mock).mockImplementation(() => { throw 'string error'; }); inputEl = deps.getInputEl() as ReturnType<typeof createMockInputEl>; inputEl.value = 'test message'; controller = new InputController(deps); await controller.sendMessage(); expect(deps.streamController.appendText).toHaveBeenCalledWith('\n\n**Error:** Unknown error'); }); }); describe('Stream interruption', () => { it('should append interrupted text when cancelRequested is true', async () => { deps = createSendableDeps(); ((deps as any).mockAgentService.query as jest.Mock).mockImplementation(() => { return (async function* () { // Simulate cancel requested during streaming deps.state.cancelRequested = true; yield { type: 'text', content: 'partial' }; })(); }); inputEl = deps.getInputEl() as ReturnType<typeof createMockInputEl>; inputEl.value = 'test message'; controller = new InputController(deps); await controller.sendMessage(); expect(deps.streamController.appendText).toHaveBeenCalledWith( expect.stringContaining('Interrupted') ); expect(deps.state.isStreaming).toBe(false); expect(deps.state.cancelRequested).toBe(false); }); it('should append interrupted text when cancelRequested is set after last stream chunk', async () => { deps = createSendableDeps(); ((deps as any).mockAgentService.query as jest.Mock).mockImplementation(() => { return (async function* () { yield { type: 'text', content: 'partial' }; })(); }); (deps.streamController.handleStreamChunk as jest.Mock).mockImplementation(async () => { deps.state.cancelRequested = true; }); inputEl = deps.getInputEl() as ReturnType<typeof createMockInputEl>; inputEl.value = 'test message'; controller = new InputController(deps); await controller.sendMessage(); expect(deps.streamController.appendText).toHaveBeenCalledWith( expect.stringContaining('Interrupted') ); expect(deps.state.isStreaming).toBe(false); expect(deps.state.cancelRequested).toBe(false); }); }); describe('Duration footer', () => { it('should render response duration footer when durationSeconds > 0', async () => { deps = createSendableDeps(); // First call sets responseStartTime; must be non-zero (0 is falsy and skips duration) let callCount = 0; jest.spyOn(performance, 'now').mockImplementation(() => { callCount++; // Returns 1000 for responseStartTime, 6000 for elapsed (5 seconds) return callCount <= 1 ? 1000 : 6000; }); ((deps as any).mockAgentService.query as jest.Mock).mockReturnValue( createMockStream([{ type: 'done' }]) ); inputEl = deps.getInputEl() as ReturnType<typeof createMockInputEl>; inputEl.value = 'test message'; controller = new InputController(deps); await controller.sendMessage(); const assistantMsg = deps.state.messages.find((m: any) => m.role === 'assistant'); expect(assistantMsg).toBeDefined(); expect(assistantMsg!.durationSeconds).toBe(5); expect(assistantMsg!.durationFlavorWord).toBeDefined(); jest.spyOn(performance, 'now').mockRestore(); }); it('should sync to the true bottom after response completion UI updates', async () => { const messagesEl = createMockEl(); messagesEl.scrollTop = 120; messagesEl.scrollHeight = 640; messagesEl.clientHeight = 400; deps = createSendableDeps({ getMessagesEl: () => messagesEl as any, }); let callCount = 0; jest.spyOn(performance, 'now').mockImplementation(() => { callCount++; return callCount <= 1 ? 1000 : 6000; }); ((deps as any).mockAgentService.query as jest.Mock).mockReturnValue( createMockStream([{ type: 'done' }]) ); inputEl = deps.getInputEl() as ReturnType<typeof createMockInputEl>; inputEl.value = 'test message'; controller = new InputController(deps); await controller.sendMessage(); expect(messagesEl.scrollTop).toBe(messagesEl.scrollHeight); jest.spyOn(performance, 'now').mockRestore(); }); }); describe('External context in query', () => { it('should pass externalContextPaths in queryOptions', async () => { const externalPaths = ['/external/path1', '/external/path2']; deps = createSendableDeps({ getExternalContextSelector: () => ({ getExternalContexts: () => externalPaths, addExternalContext: jest.fn(), }), }); ((deps as any).mockAgentService.query as jest.Mock).mockReturnValue( createMockStream([{ type: 'done' }]) ); inputEl = deps.getInputEl() as ReturnType<typeof createMockInputEl>; inputEl.value = 'test message'; controller = new InputController(deps); await controller.sendMessage(); const queryCall = ((deps as any).mockAgentService.query as jest.Mock).mock.calls[0]; const queryOptions = queryCall[3]; expect(queryOptions.externalContextPaths).toEqual(externalPaths); }); }); describe('Editor context', () => { it('should append editorContext to prompt when available', async () => { const editorContext = { notePath: 'test/note.md', mode: 'selection' as const, selectedText: 'selected text content', }; deps = createSendableDeps(); (deps.selectionController.getContext as jest.Mock).mockReturnValue(editorContext); ((deps as any).mockAgentService.query as jest.Mock).mockReturnValue( createMockStream([{ type: 'done' }]) ); inputEl = deps.getInputEl() as ReturnType<typeof createMockInputEl>; inputEl.value = 'hello'; controller = new InputController(deps); await controller.sendMessage(); const queryCall = ((deps as any).mockAgentService.query as jest.Mock).mock.calls[0]; const promptSent = queryCall[0]; expect(promptSent).toContain('selected text content'); expect(promptSent).toContain('test/note.md'); }); it('should preserve preview selection text without fabricating line attributes', async () => { const editorContext = { notePath: 'test/note.md', mode: 'selection' as const, selectedText: ' selected text\nsecond line ', lineCount: 2, }; deps = createSendableDeps(); (deps.selectionController.getContext as jest.Mock).mockReturnValue(editorContext); ((deps as any).mockAgentService.query as jest.Mock).mockReturnValue( createMockStream([{ type: 'done' }]) ); inputEl = deps.getInputEl() as ReturnType<typeof createMockInputEl>; inputEl.value = 'hello'; controller = new InputController(deps); await controller.sendMessage(); const queryCall = ((deps as any).mockAgentService.query as jest.Mock).mock.calls[0]; const promptSent = queryCall[0]; expect(promptSent).toContain('<editor_selection path="test/note.md">\n selected text\nsecond line \n</editor_selection>'); expect(promptSent).not.toContain('lines='); }); }); describe('Built-in commands - unknown', () => { beforeEach(() => { mockNotice.mockClear(); }); it('should show Notice for unknown built-in command', async () => { // Directly call the private method since there's no public API to trigger unknown commands controller = new InputController(deps); await (controller as any).executeBuiltInCommand('nonexistent-command', ''); expect(mockNotice).toHaveBeenCalledWith('Unknown command: nonexistent-command'); }); }); describe('Title generation callback branches', () => { it('should rename conversation when title generation callback succeeds', async () => { const mockTitleService = { generateTitle: jest.fn().mockImplementation( async (convId: string, _user: string, callback: any) => { (deps.plugin.getConversationById as jest.Mock).mockResolvedValue({ id: convId, title: 'Test Title', }); await callback(convId, { success: true, title: 'AI Generated Title' }); } ), cancel: jest.fn(), }; deps = createSendableDeps({ getTitleGenerationService: () => mockTitleService as any, }); ((deps as any).mockAgentService.query as jest.Mock).mockReturnValue( createMockStream([{ type: 'text', content: 'Response' }, { type: 'done' }]) ); (deps.streamController.handleStreamChunk as jest.Mock).mockImplementation(async (chunk, msg) => { if (chunk.type === 'text') msg.content = chunk.content; }); inputEl = deps.getInputEl() as ReturnType<typeof createMockInputEl>; inputEl.value = 'Hello world'; controller = new InputController(deps); await controller.sendMessage(); await new Promise(resolve => setTimeout(resolve, 0)); expect(deps.plugin.renameConversation).toHaveBeenCalledWith('conv-1', 'AI Generated Title'); expect(deps.plugin.updateConversation).toHaveBeenCalledWith('conv-1', { titleGenerationStatus: 'success', }); }); it('should mark as failed when title generation callback fails', async () => { const mockTitleService = { generateTitle: jest.fn().mockImplementation( async (convId: string, _user: string, callback: any) => { (deps.plugin.getConversationById as jest.Mock).mockResolvedValue({ id: convId, title: 'Test Title', }); await callback(convId, { success: false, title: '' }); } ), cancel: jest.fn(), }; deps = createSendableDeps({ getTitleGenerationService: () => mockTitleService as any, }); ((deps as any).mockAgentService.query as jest.Mock).mockReturnValue( createMockStream([{ type: 'text', content: 'Response' }, { type: 'done' }]) ); (deps.streamController.handleStreamChunk as jest.Mock).mockImplementation(async (chunk, msg) => { if (chunk.type === 'text') msg.content = chunk.content; }); inputEl = deps.getInputEl() as ReturnType<typeof createMockInputEl>; inputEl.value = 'Hello world'; controller = new InputController(deps); await controller.sendMessage(); await new Promise(resolve => setTimeout(resolve, 0)); expect(deps.plugin.updateConversation).toHaveBeenCalledWith('conv-1', { titleGenerationStatus: 'failed', }); }); }); describe('handleApprovalRequest', () => { it('should create inline approval and store as pending', async () => { const parentEl = createMockEl(); const inputContainerEl = createMockEl(); (inputContainerEl as any).parentElement = parentEl; deps.getInputContainerEl = () => inputContainerEl as any; controller = new InputController(deps); const approvalPromise = controller.handleApprovalRequest( 'bash', { command: 'ls -la' }, 'Run shell command' ); expect((controller as any).pendingApprovalInline).not.toBeNull(); controller.dismissPendingApproval(); expect((controller as any).pendingApprovalInline).toBeNull(); const result = await approvalPromise; expect(result).toBe('cancel'); }); it('should throw when input container has no parent', async () => { const inputContainerEl = createMockEl(); // no parentElement set deps.getInputContainerEl = () => inputContainerEl as any; controller = new InputController(deps); await expect(controller.handleApprovalRequest('bash', {}, 'test')) .rejects.toThrow('Input container is detached from DOM'); }); it.each([ ['Deny', 'deny'], ['Allow once', 'allow'], ['Always allow', 'allow-always'], ] as const)('should return "%s" → "%s"', async (optionLabel, expected) => { const parentEl = createMockEl(); const inputContainerEl = createMockEl(); (inputContainerEl as any).parentElement = parentEl; deps.getInputContainerEl = () => inputContainerEl as any; controller = new InputController(deps); const approvalPromise = controller.handleApprovalRequest( 'bash', { command: 'ls -la' }, 'Run shell command', ); const items = parentEl.querySelectorAll('claudian-ask-item'); const target = items.find((item: any) => { const label = item.querySelector('claudian-ask-item-label'); return label?.textContent === optionLabel; }); expect(target).toBeDefined(); target!.click(); const result = await approvalPromise; expect(result).toBe(expected); }); it('should render header metadata when approvalOptions provided', async () => { const parentEl = createMockEl(); const inputContainerEl = createMockEl(); (inputContainerEl as any).parentElement = parentEl; deps.getInputContainerEl = () => inputContainerEl as any; controller = new InputController(deps); const approvalPromise = controller.handleApprovalRequest( 'bash', { command: 'rm -rf /' }, 'Run dangerous command', { decisionReason: 'Command is destructive', blockedPath: '/usr/bin/rm', agentID: 'agent-42', }, ); const reasonEl = parentEl.querySelector('claudian-ask-approval-reason'); expect(reasonEl?.textContent).toBe('Command is destructive'); const pathEl = parentEl.querySelector('claudian-ask-approval-blocked-path'); expect(pathEl?.textContent).toBe('/usr/bin/rm'); const agentEl = parentEl.querySelector('claudian-ask-approval-agent'); expect(agentEl?.textContent).toBe('Agent: agent-42'); controller.dismissPendingApproval(); await approvalPromise; }); it('should restore input visibility after overlapping inline prompts are dismissed', async () => { const parentEl = createMockEl(); const inputContainerEl = createMockEl(); (inputContainerEl as any).parentElement = parentEl; deps.getInputContainerEl = () => inputContainerEl as any; controller = new InputController(deps); const approvalPromise = controller.handleApprovalRequest( 'bash', { command: 'ls -la' }, 'Run shell command', ); const askPromise = controller.handleAskUserQuestion({ questions: [ { question: 'Select one option', options: ['Option A', 'Option B'], }, ], }); expect(inputContainerEl.style.display).toBe('none'); controller.dismissPendingApproval(); await expect(approvalPromise).resolves.toBe('cancel'); await expect(askPromise).resolves.toBeNull(); expect(inputContainerEl.style.display).toBe(''); }); it('should keep input hidden until overlapping exit-plan prompt is dismissed', async () => { const parentEl = createMockEl(); const inputContainerEl = createMockEl(); (inputContainerEl as any).parentElement = parentEl; deps.getInputContainerEl = () => inputContainerEl as any; controller = new InputController(deps); const approvalPromise = controller.handleApprovalRequest( 'bash', { command: 'ls -la' }, 'Run shell command', ); const exitPlanPromise = controller.handleExitPlanMode({}); expect(inputContainerEl.style.display).toBe('none'); const items = parentEl.querySelectorAll('claudian-ask-item'); const allowOnceItem = items.find((item: any) => { const label = item.querySelector('claudian-ask-item-label'); return label?.textContent === 'Allow once'; }); expect(allowOnceItem).toBeDefined(); allowOnceItem!.click(); await expect(approvalPromise).resolves.toBe('allow'); expect(inputContainerEl.style.display).toBe('none'); controller.dismissPendingApproval(); await expect(exitPlanPromise).resolves.toBeNull(); expect(inputContainerEl.style.display).toBe(''); }); }); describe('handleInstructionSubmit', () => { it('should create InstructionModal and call refineInstruction', async () => { const mockInstructionRefineService = createMockInstructionRefineService({ refineInstruction: jest.fn().mockResolvedValue({ success: true, refinedInstruction: 'refined instruction', }), }); const mockInstructionModeManager = createMockInstructionModeManager(); deps = createMockDeps({ getInstructionRefineService: () => mockInstructionRefineService as any, getInstructionModeManager: () => mockInstructionModeManager as any, }); deps.plugin.settings.systemPrompt = ''; controller = new InputController(deps); await controller.handleInstructionSubmit('add logging'); expect(mockInstructionRefineService.resetConversation).toHaveBeenCalled(); expect(mockInstructionRefineService.refineInstruction).toHaveBeenCalledWith( 'add logging', '' ); }); it('should return early when instructionRefineService is null', async () => { deps = createMockDeps({ getInstructionRefineService: () => null, }); controller = new InputController(deps); await expect(controller.handleInstructionSubmit('test')).resolves.not.toThrow(); }); }); describe('processQueuedMessage restores images', () => { it('should restore images from queued message', () => { jest.useFakeTimers(); try { const mockImages = [{ id: 'img1', name: 'test.png' }]; deps.state.queuedMessage = { content: 'queued content', images: mockImages as any, editorContext: null, canvasContext: null, }; const imageContextManager = deps.getImageContextManager()!; const sendSpy = jest.spyOn(controller, 'sendMessage').mockResolvedValue(undefined); (controller as any).processQueuedMessage(); jest.runAllTimers(); expect(imageContextManager.setImages).toHaveBeenCalledWith(mockImages); sendSpy.mockRestore(); } finally { jest.useRealTimers(); } }); }); describe('Sending messages - edge cases', () => { it('should not send empty message without images', async () => { inputEl.value = ''; const imageContextManager = deps.getImageContextManager()!; (imageContextManager.hasImages as jest.Mock).mockReturnValue(false); await controller.sendMessage(); expect((deps as any).mockAgentService.query).not.toHaveBeenCalled(); }); it('should send message with only images (empty text)', async () => { const imageContextManager = createMockImageContextManager(); (imageContextManager.hasImages as jest.Mock).mockReturnValue(true); (imageContextManager.getAttachedImages as jest.Mock).mockReturnValue([{ id: 'img1', name: 'test.png' }]); deps = createSendableDeps({ getImageContextManager: () => imageContextManager as any, }); ((deps as any).mockAgentService.query as jest.Mock).mockReturnValue( createMockStream([{ type: 'done' }]) ); inputEl = deps.getInputEl() as ReturnType<typeof createMockInputEl>; inputEl.value = ''; controller = new InputController(deps); await controller.sendMessage(); expect((deps as any).mockAgentService.query).toHaveBeenCalled(); expect(deps.state.messages).toHaveLength(2); expect(deps.state.messages[0].images).toHaveLength(1); }); }); describe('Stream invalidation', () => { it('should break from stream loop and skip cleanup when stream generation changes', async () => { deps = createSendableDeps(); ((deps as any).mockAgentService.query as jest.Mock).mockImplementation(() => { return (async function* () { yield { type: 'text', content: 'partial' }; // Simulate stream invalidation (e.g. tab closed during stream) deps.state.bumpStreamGeneration(); yield { type: 'text', content: 'should not be processed' }; })(); }); inputEl = deps.getInputEl() as ReturnType<typeof createMockInputEl>; inputEl.value = 'test message'; controller = new InputController(deps); await controller.sendMessage(); // The stream was invalidated, so isStreaming should still be true // (cleanup was skipped) and no interrupt text should appear expect(deps.streamController.appendText).not.toHaveBeenCalledWith( expect.stringContaining('Interrupted') ); }); }); describe('handleInstructionSubmit - advanced paths', () => { it('should show clarification when result has clarification', async () => { const mockInstructionRefineService = createMockInstructionRefineService({ refineInstruction: jest.fn().mockResolvedValue({ success: true, clarification: 'Please clarify what you mean', }), }); const mockInstructionModeManager = createMockInstructionModeManager(); deps = createMockDeps({ getInstructionRefineService: () => mockInstructionRefineService as any, getInstructionModeManager: () => mockInstructionModeManager as any, }); controller = new InputController(deps); await controller.handleInstructionSubmit('ambiguous instruction'); expect(mockInstructionRefineService.refineInstruction).toHaveBeenCalledWith( 'ambiguous instruction', undefined ); }); it('should show error when result has no clarification or instruction', async () => { const mockInstructionRefineService = createMockInstructionRefineService(); const mockInstructionModeManager = createMockInstructionModeManager(); deps = createMockDeps({ getInstructionRefineService: () => mockInstructionRefineService as any, getInstructionModeManager: () => mockInstructionModeManager as any, }); controller = new InputController(deps); mockNotice.mockClear(); await controller.handleInstructionSubmit('empty result'); expect(mockNotice).toHaveBeenCalledWith('No instruction received'); expect(mockInstructionModeManager.clear).toHaveBeenCalled(); }); it('should handle cancelled result from refineInstruction', async () => { const mockInstructionRefineService = createMockInstructionRefineService({ refineInstruction: jest.fn().mockResolvedValue({ success: false, error: 'Cancelled', }), }); const mockInstructionModeManager = createMockInstructionModeManager(); deps = createMockDeps({ getInstructionRefineService: () => mockInstructionRefineService as any, getInstructionModeManager: () => mockInstructionModeManager as any, }); controller = new InputController(deps); await controller.handleInstructionSubmit('cancelled instruction'); expect(mockInstructionModeManager.clear).toHaveBeenCalled(); expect(mockNotice).not.toHaveBeenCalledWith(expect.stringContaining('Cancelled')); }); it('should handle non-cancelled error from refineInstruction', async () => { const mockInstructionRefineService = createMockInstructionRefineService({ refineInstruction: jest.fn().mockResolvedValue({ success: false, error: 'API Error', }), }); const mockInstructionModeManager = createMockInstructionModeManager(); deps = createMockDeps({ getInstructionRefineService: () => mockInstructionRefineService as any, getInstructionModeManager: () => mockInstructionModeManager as any, }); controller = new InputController(deps); mockNotice.mockClear(); await controller.handleInstructionSubmit('error instruction'); expect(mockNotice).toHaveBeenCalledWith('API Error'); expect(mockInstructionModeManager.clear).toHaveBeenCalled(); }); it('should handle exception thrown during refineInstruction', async () => { const mockInstructionRefineService = createMockInstructionRefineService({ refineInstruction: jest.fn().mockRejectedValue(new Error('Unexpected error')), }); const mockInstructionModeManager = createMockInstructionModeManager(); deps = createMockDeps({ getInstructionRefineService: () => mockInstructionRefineService as any, getInstructionModeManager: () => mockInstructionModeManager as any, }); controller = new InputController(deps); mockNotice.mockClear(); await controller.handleInstructionSubmit('error instruction'); expect(mockNotice).toHaveBeenCalledWith('Error: Unexpected error'); expect(mockInstructionModeManager.clear).toHaveBeenCalled(); }); }); describe('resumeSessionAt lifecycle', () => { beforeEach(() => { mockNotice.mockClear(); }); it('should call setPendingResumeAt when resumeSessionAt points to last assistant (still-needed)', async () => { deps = createSendableDeps(); const { mockAgentService } = deps as any; mockAgentService.setPendingResumeAt = jest.fn(); mockAgentService.query = jest.fn().mockReturnValue(createMockStream([{ type: 'done' }])); // Pre-populate messages: user → assistant (with sdkAssistantUuid matching resumeSessionAt) deps.state.messages = [ { id: 'msg-u1', role: 'user', content: 'hello', timestamp: 1, sdkUserUuid: 'u1' }, { id: 'msg-a1', role: 'assistant', content: 'hi', timestamp: 2, sdkAssistantUuid: 'a1' }, ]; // Set conversation with resumeSessionAt (deps.plugin.getConversationSync as any) = jest.fn().mockReturnValue({ id: 'conv-1', resumeSessionAt: 'a1', }); inputEl = deps.getInputEl() as ReturnType<typeof createMockInputEl>; inputEl.value = 'follow up'; controller = new InputController(deps); await controller.sendMessage(); expect(mockAgentService.setPendingResumeAt).toHaveBeenCalledWith('a1'); // Should NOT clear metadata eagerly (clearing is done by save(true)) expect(deps.plugin.updateConversation).not.toHaveBeenCalledWith('conv-1', { resumeSessionAt: undefined }); }); it('should NOT call setPendingResumeAt when follow-up already exists (stale)', async () => { deps = createSendableDeps(); const { mockAgentService } = deps as any; mockAgentService.setPendingResumeAt = jest.fn(); mockAgentService.query = jest.fn().mockReturnValue(createMockStream([{ type: 'done' }])); // Messages: user → assistant(a1) → user(follow-up) → assistant // resumeSessionAt=a1 is stale because there's a follow-up after a1 deps.state.messages = [ { id: 'msg-u1', role: 'user', content: 'hello', timestamp: 1, sdkUserUuid: 'u1' }, { id: 'msg-a1', role: 'assistant', content: 'hi', timestamp: 2, sdkAssistantUuid: 'a1' }, { id: 'msg-u2', role: 'user', content: 'follow up', timestamp: 3, sdkUserUuid: 'u2' }, { id: 'msg-a2', role: 'assistant', content: 'response', timestamp: 4, sdkAssistantUuid: 'a2' }, ]; (deps.plugin.getConversationSync as any) = jest.fn().mockReturnValue({ id: 'conv-1', resumeSessionAt: 'a1', }); inputEl = deps.getInputEl() as ReturnType<typeof createMockInputEl>; inputEl.value = 'another message'; controller = new InputController(deps); await controller.sendMessage(); expect(mockAgentService.setPendingResumeAt).not.toHaveBeenCalled(); // Should clear stale metadata expect(deps.plugin.updateConversation).toHaveBeenCalledWith('conv-1', { resumeSessionAt: undefined }); }); it('should clear resumeSessionAt on save when sdk_user_sent is received', async () => { deps = createSendableDeps(); const { mockAgentService } = deps as any; mockAgentService.setPendingResumeAt = jest.fn(); mockAgentService.query = jest.fn().mockReturnValue( createMockStream([ { type: 'sdk_user_uuid', uuid: 'u-new' }, { type: 'sdk_user_sent', uuid: 'u-new' }, { type: 'text', content: 'hi' }, { type: 'done' }, ]) ); deps.state.messages = [ { id: 'msg-u1', role: 'user', content: 'hello', timestamp: 1, sdkUserUuid: 'u1' }, { id: 'msg-a1', role: 'assistant', content: 'hi', timestamp: 2, sdkAssistantUuid: 'a1' }, ]; (deps.plugin.getConversationSync as any) = jest.fn().mockReturnValue({ id: 'conv-1', resumeSessionAt: 'a1', }); inputEl = deps.getInputEl() as ReturnType<typeof createMockInputEl>; inputEl.value = 'follow up'; controller = new InputController(deps); await controller.sendMessage(); // save(true) should include { resumeSessionAt: undefined } because sdk_user_sent was received expect(deps.conversationController.save).toHaveBeenCalledWith(true, { resumeSessionAt: undefined }); }); it('should NOT clear resumeSessionAt on save when query fails before enqueue', async () => { deps = createSendableDeps(); const { mockAgentService } = deps as any; mockAgentService.setPendingResumeAt = jest.fn(); // Stream throws before yielding sdk_user_sent mockAgentService.query = jest.fn().mockImplementation(() => { throw new Error('Connection failed'); }); deps.state.messages = [ { id: 'msg-u1', role: 'user', content: 'hello', timestamp: 1, sdkUserUuid: 'u1' }, { id: 'msg-a1', role: 'assistant', content: 'hi', timestamp: 2, sdkAssistantUuid: 'a1' }, ]; (deps.plugin.getConversationSync as any) = jest.fn().mockReturnValue({ id: 'conv-1', resumeSessionAt: 'a1', }); inputEl = deps.getInputEl() as ReturnType<typeof createMockInputEl>; inputEl.value = 'follow up'; controller = new InputController(deps); await controller.sendMessage(); // save(true) should NOT clear resumeSessionAt because sdk_user_sent was never received expect(deps.conversationController.save).toHaveBeenCalledWith(true, undefined); }); it('should not block send when stale metadata clear fails', async () => { deps = createSendableDeps(); const { mockAgentService } = deps as any; mockAgentService.setPendingResumeAt = jest.fn(); mockAgentService.query = jest.fn().mockReturnValue(createMockStream([{ type: 'done' }])); deps.state.messages = [ { id: 'msg-u1', role: 'user', content: 'hello', timestamp: 1, sdkUserUuid: 'u1' }, { id: 'msg-a1', role: 'assistant', content: 'hi', timestamp: 2, sdkAssistantUuid: 'a1' }, { id: 'msg-u2', role: 'user', content: 'next', timestamp: 3, sdkUserUuid: 'u2' }, { id: 'msg-a2', role: 'assistant', content: 'resp', timestamp: 4, sdkAssistantUuid: 'a2' }, ]; (deps.plugin.getConversationSync as any) = jest.fn().mockReturnValue({ id: 'conv-1', resumeSessionAt: 'a1', }); // Make updateConversation throw (deps.plugin.updateConversation as jest.Mock).mockRejectedValueOnce(new Error('disk error')); inputEl = deps.getInputEl() as ReturnType<typeof createMockInputEl>; inputEl.value = 'test'; controller = new InputController(deps); // Should not throw await expect(controller.sendMessage()).resolves.not.toThrow(); expect(mockAgentService.query).toHaveBeenCalled(); }); }); }); ================================================ FILE: tests/unit/features/chat/controllers/NavigationController.test.ts ================================================ import { NavigationController, type NavigationControllerDeps } from '@/features/chat/controllers/NavigationController'; type Listener = (event: any) => void; /** Mock KeyboardEvent for Node environment. */ class MockKeyboardEvent { public type: string; public key: string; public cancelable: boolean; public bubbles: boolean; public ctrlKey: boolean; public metaKey: boolean; public altKey: boolean; public shiftKey: boolean; private defaultPrevented = false; private propagationStopped = false; constructor(type: string, options: { key: string; cancelable?: boolean; bubbles?: boolean; ctrlKey?: boolean; metaKey?: boolean; altKey?: boolean; shiftKey?: boolean; } = { key: '' }) { this.type = type; this.key = options.key; this.cancelable = options.cancelable ?? false; this.bubbles = options.bubbles ?? false; this.ctrlKey = options.ctrlKey ?? false; this.metaKey = options.metaKey ?? false; this.altKey = options.altKey ?? false; this.shiftKey = options.shiftKey ?? false; } preventDefault(): void { if (this.cancelable) { this.defaultPrevented = true; } } stopPropagation(): void { this.propagationStopped = true; } get defaultPreventedValue(): boolean { return this.defaultPrevented; } } // Replace global KeyboardEvent if not defined if (typeof KeyboardEvent === 'undefined') { (global as any).KeyboardEvent = MockKeyboardEvent; } /** Mock HTML element for testing. */ class MockElement { public tagName: string; public scrollTop = 0; public style: Record<string, string> = {}; private attributes: Map<string, string> = new Map(); private classes: Set<string> = new Set(); private listeners: Map<string, { listener: Listener; options?: AddEventListenerOptions }[]> = new Map(); constructor(tagName = 'DIV') { this.tagName = tagName; } setAttribute(name: string, value: string): void { this.attributes.set(name, value); } getAttribute(name: string): string | null { return this.attributes.get(name) ?? null; } addClass(cls: string): void { this.classes.add(cls); } removeClass(cls: string): void { this.classes.delete(cls); } hasClass(cls: string): boolean { return this.classes.has(cls); } addEventListener(type: string, listener: Listener, options?: AddEventListenerOptions | boolean): void { if (!this.listeners.has(type)) { this.listeners.set(type, []); } const opts = typeof options === 'boolean' ? { capture: options } : options; this.listeners.get(type)!.push({ listener, options: opts }); } removeEventListener(type: string, listener: Listener, options?: AddEventListenerOptions | boolean): void { const eventListeners = this.listeners.get(type); if (eventListeners) { const opts = typeof options === 'boolean' ? { capture: options } : options; const idx = eventListeners.findIndex((l) => l.listener === listener && l.options?.capture === opts?.capture); if (idx !== -1) { eventListeners.splice(idx, 1); } } } dispatchEvent(event: KeyboardEvent): boolean { const eventListeners = this.listeners.get(event.type) ?? []; // Sort by capture phase (capture first, then bubble) const sortedListeners = [...eventListeners].sort((a, b) => { const aCapture = a.options?.capture ?? false; const bCapture = b.options?.capture ?? false; return aCapture === bCapture ? 0 : aCapture ? -1 : 1; }); for (const { listener } of sortedListeners) { listener(event); } return true; } focus(): void { // Mock focus } blur(): void { // Mock blur } } describe('NavigationController', () => { let controller: NavigationController; let messagesEl: MockElement; let inputEl: MockElement; let deps: NavigationControllerDeps; let settings: { scrollUpKey: string; scrollDownKey: string; focusInputKey: string }; let isStreaming: boolean; let shouldSkipEscapeHandling: jest.Mock | undefined; let mockRaf: jest.Mock; let mockCancelRaf: jest.Mock; let originalRaf: typeof requestAnimationFrame; let originalCancelRaf: typeof cancelAnimationFrame; let originalDocument: typeof document; beforeEach(() => { jest.useFakeTimers(); // Save originals originalRaf = global.requestAnimationFrame; originalCancelRaf = global.cancelAnimationFrame; originalDocument = (global as any).document; // Mock requestAnimationFrame let rafId = 0; mockRaf = jest.fn((cb: FrameRequestCallback) => { rafId++; setTimeout(() => cb(performance.now()), 16); return rafId; }); mockCancelRaf = jest.fn(); global.requestAnimationFrame = mockRaf; global.cancelAnimationFrame = mockCancelRaf; // Mock document for event listeners const documentListeners: Map<string, Listener[]> = new Map(); (global as any).document = { addEventListener: (type: string, listener: Listener) => { if (!documentListeners.has(type)) { documentListeners.set(type, []); } documentListeners.get(type)!.push(listener); }, removeEventListener: (type: string, listener: Listener) => { const listeners = documentListeners.get(type); if (listeners) { const idx = listeners.indexOf(listener); if (idx !== -1) { listeners.splice(idx, 1); } } }, dispatchEvent: (event: KeyboardEvent) => { const listeners = documentListeners.get(event.type) ?? []; for (const listener of listeners) { listener(event); } return true; }, }; // Create mock elements messagesEl = new MockElement('DIV'); inputEl = new MockElement('TEXTAREA'); settings = { scrollUpKey: 'w', scrollDownKey: 's', focusInputKey: 'i' }; isStreaming = false; shouldSkipEscapeHandling = undefined; deps = { getMessagesEl: () => messagesEl as unknown as HTMLElement, getInputEl: () => inputEl as unknown as HTMLTextAreaElement, getSettings: () => settings, isStreaming: () => isStreaming, }; controller = new NavigationController(deps); }); afterEach(() => { if (controller) { controller.dispose(); } jest.useRealTimers(); // Restore originals global.requestAnimationFrame = originalRaf; global.cancelAnimationFrame = originalCancelRaf; (global as any).document = originalDocument; }); describe('initialization', () => { it('makes messagesEl focusable with tabindex', () => { controller.initialize(); expect(messagesEl.getAttribute('tabindex')).toBe('0'); }); it('adds focusable CSS class to messagesEl', () => { controller.initialize(); expect(messagesEl.hasClass('claudian-messages-focusable')).toBe(true); }); it('attaches keydown listener to messagesEl', () => { const addEventListenerSpy = jest.spyOn(messagesEl, 'addEventListener'); controller.initialize(); expect(addEventListenerSpy).toHaveBeenCalledWith('keydown', expect.any(Function)); }); it('attaches keyup listener to document', () => { const addEventListenerSpy = jest.spyOn((global as any).document, 'addEventListener'); controller.initialize(); expect(addEventListenerSpy).toHaveBeenCalledWith('keyup', expect.any(Function)); }); it('attaches keydown listener to inputEl with capture phase', () => { const addEventListenerSpy = jest.spyOn(inputEl, 'addEventListener'); controller.initialize(); expect(addEventListenerSpy).toHaveBeenCalledWith('keydown', expect.any(Function), { capture: true }); }); }); describe('disposal', () => { it('removes keydown listener from messagesEl', () => { controller.initialize(); const removeEventListenerSpy = jest.spyOn(messagesEl, 'removeEventListener'); controller.dispose(); expect(removeEventListenerSpy).toHaveBeenCalledWith('keydown', expect.any(Function)); }); it('removes keyup listener from document', () => { controller.initialize(); const removeEventListenerSpy = jest.spyOn((global as any).document, 'removeEventListener'); controller.dispose(); expect(removeEventListenerSpy).toHaveBeenCalledWith('keyup', expect.any(Function)); }); it('removes keydown listener from inputEl', () => { controller.initialize(); const removeEventListenerSpy = jest.spyOn(inputEl, 'removeEventListener'); controller.dispose(); expect(removeEventListenerSpy).toHaveBeenCalledWith('keydown', expect.any(Function), { capture: true }); }); it('cancels ongoing animation frame', () => { controller.initialize(); // Trigger scrolling const keydownEvent = new KeyboardEvent('keydown', { key: 'w' }); messagesEl.dispatchEvent(keydownEvent); controller.dispose(); expect(mockCancelRaf).toHaveBeenCalled(); }); }); describe('scroll key handling', () => { beforeEach(() => { controller.initialize(); messagesEl.scrollTop = 100; }); it('scrolls up when scroll up key is pressed', () => { const keydownEvent = new KeyboardEvent('keydown', { key: 'w' }); messagesEl.dispatchEvent(keydownEvent); // Advance timers to trigger RAF callback jest.advanceTimersByTime(16); expect(messagesEl.scrollTop).toBeLessThan(100); }); it('scrolls down when scroll down key is pressed', () => { const keydownEvent = new KeyboardEvent('keydown', { key: 's' }); messagesEl.dispatchEvent(keydownEvent); jest.advanceTimersByTime(16); expect(messagesEl.scrollTop).toBeGreaterThan(100); }); it('stops scrolling when key is released', () => { // Start scrolling const keydownEvent = new KeyboardEvent('keydown', { key: 'w' }); messagesEl.dispatchEvent(keydownEvent); // Release key - this should trigger cancelAnimationFrame const keyupEvent = new KeyboardEvent('keyup', { key: 'w' }); (global as any).document.dispatchEvent(keyupEvent); // Scrolling should have stopped (cancelAnimationFrame was called) expect(mockCancelRaf).toHaveBeenCalled(); }); it('uses configured scroll keys (case insensitive)', () => { settings.scrollUpKey = 'k'; settings.scrollDownKey = 'j'; // 'w' should not scroll now const keydownW = new KeyboardEvent('keydown', { key: 'w' }); messagesEl.dispatchEvent(keydownW); jest.advanceTimersByTime(16); expect(messagesEl.scrollTop).toBe(100); // 'k' should scroll up const keydownK = new KeyboardEvent('keydown', { key: 'K' }); // Test uppercase messagesEl.dispatchEvent(keydownK); jest.advanceTimersByTime(16); expect(messagesEl.scrollTop).toBeLessThan(100); }); it('prevents default on scroll key press', () => { const keydownEvent = new KeyboardEvent('keydown', { key: 'w', cancelable: true }); const preventDefaultSpy = jest.spyOn(keydownEvent, 'preventDefault'); messagesEl.dispatchEvent(keydownEvent); expect(preventDefaultSpy).toHaveBeenCalled(); }); it('does not start duplicate scroll in same direction', () => { // First keydown const keydown1 = new KeyboardEvent('keydown', { key: 'w' }); messagesEl.dispatchEvent(keydown1); const rafCallCount = mockRaf.mock.calls.length; // Second keydown in same direction const keydown2 = new KeyboardEvent('keydown', { key: 'w' }); messagesEl.dispatchEvent(keydown2); // RAF should not be called again (already scrolling) expect(mockRaf.mock.calls.length).toBe(rafCallCount); }); it('changes direction when opposite key pressed', () => { messagesEl.scrollTop = 100; // Start scrolling up const keydownUp = new KeyboardEvent('keydown', { key: 'w' }); messagesEl.dispatchEvent(keydownUp); jest.advanceTimersByTime(16); const scrollAfterUp = messagesEl.scrollTop; expect(scrollAfterUp).toBeLessThan(100); // Change to scrolling down const keydownDown = new KeyboardEvent('keydown', { key: 's' }); messagesEl.dispatchEvent(keydownDown); jest.advanceTimersByTime(16); expect(messagesEl.scrollTop).toBeGreaterThan(scrollAfterUp); }); it('ignores scroll key when modifier keys are held (Ctrl)', () => { const keydownEvent = new KeyboardEvent('keydown', { key: 'w', ctrlKey: true }); messagesEl.dispatchEvent(keydownEvent); jest.advanceTimersByTime(16); // scrollTop should not change - modifier key blocks scrolling expect(messagesEl.scrollTop).toBe(100); }); it('ignores scroll key when modifier keys are held (Meta/Cmd)', () => { const keydownEvent = new KeyboardEvent('keydown', { key: 'w', metaKey: true }); messagesEl.dispatchEvent(keydownEvent); jest.advanceTimersByTime(16); expect(messagesEl.scrollTop).toBe(100); }); it('ignores scroll key when modifier keys are held (Alt)', () => { const keydownEvent = new KeyboardEvent('keydown', { key: 's', altKey: true }); messagesEl.dispatchEvent(keydownEvent); jest.advanceTimersByTime(16); expect(messagesEl.scrollTop).toBe(100); }); it('ignores scroll key when modifier keys are held (Shift)', () => { const keydownEvent = new KeyboardEvent('keydown', { key: 'w', shiftKey: true }); messagesEl.dispatchEvent(keydownEvent); jest.advanceTimersByTime(16); expect(messagesEl.scrollTop).toBe(100); }); }); describe('focus input key (i)', () => { beforeEach(() => { controller.initialize(); }); it('focuses input when i is pressed on messages', () => { const focusSpy = jest.spyOn(inputEl, 'focus'); const keydownEvent = new KeyboardEvent('keydown', { key: 'i' }); messagesEl.dispatchEvent(keydownEvent); expect(focusSpy).toHaveBeenCalled(); }); it('prevents default on i key press', () => { const keydownEvent = new KeyboardEvent('keydown', { key: 'i', cancelable: true }); const preventDefaultSpy = jest.spyOn(keydownEvent, 'preventDefault'); messagesEl.dispatchEvent(keydownEvent); expect(preventDefaultSpy).toHaveBeenCalled(); }); it('works with uppercase I', () => { const focusSpy = jest.spyOn(inputEl, 'focus'); const keydownEvent = new KeyboardEvent('keydown', { key: 'I' }); messagesEl.dispatchEvent(keydownEvent); expect(focusSpy).toHaveBeenCalled(); }); it('uses configured focus input key', () => { settings.focusInputKey = 'a'; const focusSpy = jest.spyOn(inputEl, 'focus'); // 'i' should not focus now const keydownI = new KeyboardEvent('keydown', { key: 'i' }); messagesEl.dispatchEvent(keydownI); expect(focusSpy).not.toHaveBeenCalled(); // 'a' should focus const keydownA = new KeyboardEvent('keydown', { key: 'a' }); messagesEl.dispatchEvent(keydownA); expect(focusSpy).toHaveBeenCalled(); }); }); describe('escape key in input', () => { beforeEach(() => { controller.initialize(); }); it('blurs input and focuses messages when Escape pressed (not streaming)', () => { isStreaming = false; const blurSpy = jest.spyOn(inputEl, 'blur'); const focusSpy = jest.spyOn(messagesEl, 'focus'); const keydownEvent = new KeyboardEvent('keydown', { key: 'Escape', cancelable: true, bubbles: true }); inputEl.dispatchEvent(keydownEvent); expect(blurSpy).toHaveBeenCalled(); expect(focusSpy).toHaveBeenCalled(); }); it('prevents default and stops propagation when Escape handled', () => { isStreaming = false; const keydownEvent = new KeyboardEvent('keydown', { key: 'Escape', cancelable: true, bubbles: true }); const preventDefaultSpy = jest.spyOn(keydownEvent, 'preventDefault'); const stopPropagationSpy = jest.spyOn(keydownEvent, 'stopPropagation'); inputEl.dispatchEvent(keydownEvent); expect(preventDefaultSpy).toHaveBeenCalled(); expect(stopPropagationSpy).toHaveBeenCalled(); }); it('does not handle Escape when streaming (lets other handlers work)', () => { isStreaming = true; const blurSpy = jest.spyOn(inputEl, 'blur'); const keydownEvent = new KeyboardEvent('keydown', { key: 'Escape', cancelable: true, bubbles: true }); const preventDefaultSpy = jest.spyOn(keydownEvent, 'preventDefault'); inputEl.dispatchEvent(keydownEvent); expect(blurSpy).not.toHaveBeenCalled(); expect(preventDefaultSpy).not.toHaveBeenCalled(); }); it('does not handle Escape when shouldSkipEscapeHandling returns true', () => { // Dispose current controller and create one with shouldSkipEscapeHandling controller.dispose(); shouldSkipEscapeHandling = jest.fn().mockReturnValue(true); controller = new NavigationController({ ...deps, shouldSkipEscapeHandling, }); controller.initialize(); isStreaming = false; const blurSpy = jest.spyOn(inputEl, 'blur'); const keydownEvent = new KeyboardEvent('keydown', { key: 'Escape', cancelable: true, bubbles: true }); const preventDefaultSpy = jest.spyOn(keydownEvent, 'preventDefault'); inputEl.dispatchEvent(keydownEvent); expect(shouldSkipEscapeHandling).toHaveBeenCalled(); expect(blurSpy).not.toHaveBeenCalled(); expect(preventDefaultSpy).not.toHaveBeenCalled(); }); it('handles Escape when shouldSkipEscapeHandling returns false', () => { // Dispose current controller and create one with shouldSkipEscapeHandling controller.dispose(); shouldSkipEscapeHandling = jest.fn().mockReturnValue(false); controller = new NavigationController({ ...deps, shouldSkipEscapeHandling, }); controller.initialize(); isStreaming = false; const blurSpy = jest.spyOn(inputEl, 'blur'); const keydownEvent = new KeyboardEvent('keydown', { key: 'Escape', cancelable: true, bubbles: true }); inputEl.dispatchEvent(keydownEvent); expect(shouldSkipEscapeHandling).toHaveBeenCalled(); expect(blurSpy).toHaveBeenCalled(); }); it('ignores non-Escape keys', () => { const blurSpy = jest.spyOn(inputEl, 'blur'); const keydownEvent = new KeyboardEvent('keydown', { key: 'Enter', cancelable: true }); inputEl.dispatchEvent(keydownEvent); expect(blurSpy).not.toHaveBeenCalled(); }); }); describe('public API', () => { beforeEach(() => { controller.initialize(); }); it('focusMessages focuses the messages element', () => { const focusSpy = jest.spyOn(messagesEl, 'focus'); controller.focusMessages(); expect(focusSpy).toHaveBeenCalled(); }); it('focusInput focuses the input element', () => { const focusSpy = jest.spyOn(inputEl, 'focus'); controller.focusInput(); expect(focusSpy).toHaveBeenCalled(); }); }); describe('edge cases', () => { beforeEach(() => { controller.initialize(); }); it('handles rapid direction changes', () => { messagesEl.scrollTop = 100; // Rapidly alternate directions for (let i = 0; i < 5; i++) { messagesEl.dispatchEvent(new KeyboardEvent('keydown', { key: 'w' })); messagesEl.dispatchEvent(new KeyboardEvent('keydown', { key: 's' })); } // Should not throw and should be scrolling down (last direction) jest.advanceTimersByTime(16); expect(messagesEl.scrollTop).toBeGreaterThan(100); }); it('handles empty settings keys gracefully', () => { settings.scrollUpKey = ''; settings.scrollDownKey = ''; // Should not throw const keydownEvent = new KeyboardEvent('keydown', { key: 'w' }); expect(() => messagesEl.dispatchEvent(keydownEvent)).not.toThrow(); }); }); }); ================================================ FILE: tests/unit/features/chat/controllers/SelectionController.test.ts ================================================ import { SelectionController } from '@/features/chat/controllers/SelectionController'; import { hideSelectionHighlight, showSelectionHighlight } from '@/shared/components/SelectionHighlight'; jest.mock('@/shared/components/SelectionHighlight', () => ({ showSelectionHighlight: jest.fn(), hideSelectionHighlight: jest.fn(), })); function createMockIndicator() { return { textContent: '', style: { display: 'none' }, } as any; } function createMockInput() { const listeners = new Map<string, Set<(...args: unknown[]) => void>>(); return { addEventListener: jest.fn((event: string, listener: (...args: unknown[]) => void) => { const handlers = listeners.get(event) ?? new Set<(...args: unknown[]) => void>(); handlers.add(listener); listeners.set(event, handlers); }), removeEventListener: jest.fn((event: string, listener: (...args: unknown[]) => void) => { listeners.get(event)?.delete(listener); }), trigger: (event: string) => { listeners.get(event)?.forEach(handler => handler()); }, } as any; } function createMockContextRow() { const elements: Record<string, any> = { '.claudian-selection-indicator': { style: { display: 'none' } }, '.claudian-canvas-indicator': { style: { display: 'none' } }, '.claudian-file-indicator': null, '.claudian-image-preview': null, }; return { classList: { toggle: jest.fn(), }, querySelector: jest.fn((selector: string) => elements[selector] ?? null), } as any; } describe('SelectionController', () => { let controller: SelectionController; let app: any; let indicatorEl: any; let inputEl: any; let contextRowEl: any; let editor: any; let editorView: any; let originalDocument: any; beforeEach(() => { jest.useFakeTimers(); (showSelectionHighlight as jest.Mock).mockClear(); (hideSelectionHighlight as jest.Mock).mockClear(); indicatorEl = createMockIndicator(); inputEl = createMockInput(); contextRowEl = createMockContextRow(); editorView = { id: 'editor-view' }; editor = { getSelection: jest.fn().mockReturnValue('selected text'), getCursor: jest.fn((which: 'from' | 'to') => { if (which === 'from') return { line: 0, ch: 0 }; return { line: 0, ch: 4 }; }), posToOffset: jest.fn((pos: { line: number; ch: number }) => pos.line * 100 + pos.ch), cm: editorView, }; const view = { editor, getMode: () => 'source', file: { path: 'notes/test.md' } }; app = { workspace: { getActiveViewOfType: jest.fn().mockReturnValue(view), }, }; controller = new SelectionController(app, indicatorEl, inputEl, contextRowEl); originalDocument = (global as any).document; (global as any).document = { activeElement: null }; }); afterEach(() => { controller.stop(); jest.useRealTimers(); (global as any).document = originalDocument; }); it('captures selection and updates indicator', () => { controller.start(); jest.advanceTimersByTime(250); expect(controller.hasSelection()).toBe(true); expect(controller.getContext()).toEqual({ notePath: 'notes/test.md', mode: 'selection', selectedText: 'selected text', lineCount: 1, startLine: 1, }); expect(indicatorEl.textContent).toBe('1 line selected'); expect(indicatorEl.style.display).toBe('block'); controller.showHighlight(); expect(showSelectionHighlight).toHaveBeenCalledWith(editorView, 0, 4); }); it('clears selection immediately when deselected without input handoff intent', () => { controller.start(); jest.advanceTimersByTime(250); editor.getSelection.mockReturnValue(''); (global as any).document.activeElement = null; jest.advanceTimersByTime(250); expect(controller.hasSelection()).toBe(false); expect(indicatorEl.style.display).toBe('none'); expect(hideSelectionHighlight).toHaveBeenCalledWith(editorView); }); it('clears markdown selection when active view is no longer markdown', () => { controller.start(); jest.advanceTimersByTime(250); expect(controller.hasSelection()).toBe(true); app.workspace.getActiveViewOfType.mockReturnValue(null); (global as any).document.activeElement = null; jest.advanceTimersByTime(250); expect(controller.hasSelection()).toBe(false); expect(indicatorEl.style.display).toBe('none'); expect(hideSelectionHighlight).toHaveBeenCalledWith(editorView); }); it('preserves selection when input focus arrives after a slow editor blur handoff', () => { controller.start(); jest.advanceTimersByTime(250); inputEl.trigger('pointerdown'); editor.getSelection.mockReturnValue(''); (global as any).document.activeElement = null; // Simulate delayed focus handoff under UI load. jest.advanceTimersByTime(1250); expect(controller.hasSelection()).toBe(true); (global as any).document.activeElement = inputEl; jest.advanceTimersByTime(250); expect(controller.hasSelection()).toBe(true); expect(hideSelectionHighlight).not.toHaveBeenCalled(); }); it('clears selection after handoff grace expires when input never receives focus', () => { controller.start(); jest.advanceTimersByTime(250); inputEl.trigger('pointerdown'); editor.getSelection.mockReturnValue(''); (global as any).document.activeElement = null; jest.advanceTimersByTime(1250); expect(controller.hasSelection()).toBe(true); jest.advanceTimersByTime(750); expect(controller.hasSelection()).toBe(false); expect(hideSelectionHighlight).toHaveBeenCalledWith(editorView); }); describe('Reading mode (preview)', () => { let readingView: any; let containerEl: any; beforeEach(() => { containerEl = { contains: jest.fn().mockReturnValue(true), }; readingView = { editor, getMode: () => 'preview', file: { path: 'notes/reading.md' }, containerEl, }; app.workspace.getActiveViewOfType.mockReturnValue(readingView); }); it('captures selection via document.getSelection() in reading mode', () => { const anchorNode = {}; (global as any).document = { activeElement: null, getSelection: jest.fn().mockReturnValue({ toString: () => 'reading selection', anchorNode, }), }; controller.start(); jest.advanceTimersByTime(250); expect(controller.hasSelection()).toBe(true); expect(controller.getContext()).toEqual({ notePath: 'notes/reading.md', mode: 'selection', selectedText: 'reading selection', lineCount: 1, }); expect(indicatorEl.textContent).toBe('1 line selected'); expect(indicatorEl.style.display).toBe('block'); }); it('preserves raw reading mode text and omits line metadata', () => { const anchorNode = {}; (global as any).document = { activeElement: null, getSelection: jest.fn().mockReturnValue({ toString: () => ' reading selection\nsecond line ', anchorNode, }), }; controller.start(); jest.advanceTimersByTime(250); expect(controller.getContext()).toEqual({ notePath: 'notes/reading.md', mode: 'selection', selectedText: ' reading selection\nsecond line ', lineCount: 2, }); expect(indicatorEl.textContent).toBe('2 lines selected'); }); it('does not set highlight in reading mode', () => { const anchorNode = {}; (global as any).document = { activeElement: null, getSelection: jest.fn().mockReturnValue({ toString: () => 'reading selection', anchorNode, }), }; controller.start(); jest.advanceTimersByTime(250); controller.showHighlight(); expect(showSelectionHighlight).not.toHaveBeenCalled(); }); it('clears selection when deselected in reading mode', () => { const anchorNode = {}; (global as any).document = { activeElement: null, getSelection: jest.fn().mockReturnValue({ toString: () => 'reading selection', anchorNode, }), }; controller.start(); jest.advanceTimersByTime(250); expect(controller.hasSelection()).toBe(true); (global as any).document.getSelection.mockReturnValue({ toString: () => '', anchorNode: null, }); jest.advanceTimersByTime(250); expect(controller.hasSelection()).toBe(false); expect(indicatorEl.style.display).toBe('none'); }); it('preserves reading mode selection when input is focused', () => { const anchorNode = {}; (global as any).document = { activeElement: null, getSelection: jest.fn().mockReturnValue({ toString: () => 'reading selection', anchorNode, }), }; controller.start(); jest.advanceTimersByTime(250); expect(controller.hasSelection()).toBe(true); (global as any).document.getSelection.mockReturnValue({ toString: () => '', anchorNode: null, }); (global as any).document.activeElement = inputEl; jest.advanceTimersByTime(250); expect(controller.hasSelection()).toBe(true); }); it('ignores selection outside the view container', () => { containerEl.contains.mockReturnValue(false); const anchorNode = {}; (global as any).document = { activeElement: null, getSelection: jest.fn().mockReturnValue({ toString: () => 'outside selection', anchorNode, }), }; controller.start(); jest.advanceTimersByTime(250); expect(controller.hasSelection()).toBe(false); }); it('uses focusNode when anchorNode is outside the view container', () => { const anchorNode = {}; const focusNode = {}; containerEl.contains.mockImplementation((node: unknown) => node === focusNode); (global as any).document = { activeElement: null, getSelection: jest.fn().mockReturnValue({ toString: () => 'reading selection', anchorNode, focusNode, }), }; controller.start(); jest.advanceTimersByTime(250); expect(controller.hasSelection()).toBe(true); }); it('replaces source selection metadata when switching the same text into preview mode', () => { const sourceView = { editor, getMode: () => 'source', file: { path: 'notes/test.md' } }; app.workspace.getActiveViewOfType.mockReturnValue(sourceView); controller.start(); jest.advanceTimersByTime(250); const previewAnchorNode = {}; readingView.file.path = 'notes/test.md'; app.workspace.getActiveViewOfType.mockReturnValue(readingView); (global as any).document = { activeElement: null, getSelection: jest.fn().mockReturnValue({ toString: () => 'selected text', anchorNode: previewAnchorNode, }), }; (showSelectionHighlight as jest.Mock).mockClear(); jest.advanceTimersByTime(250); controller.showHighlight(); expect(controller.getContext()).toEqual({ notePath: 'notes/test.md', mode: 'selection', selectedText: 'selected text', lineCount: 1, }); expect(showSelectionHighlight).not.toHaveBeenCalled(); }); }); it('keeps context row visible when canvas selection indicator is visible', () => { const canvasIndicator = { style: { display: 'block' } }; contextRowEl.querySelector.mockImplementation((selector: string) => { if (selector === '.claudian-canvas-indicator') return canvasIndicator; return null; }); controller.updateContextRowVisibility(); expect(contextRowEl.classList.toggle).toHaveBeenCalledWith('has-content', true); }); }); ================================================ FILE: tests/unit/features/chat/controllers/StreamController.test.ts ================================================ import { createMockEl } from '@test/helpers/mockElement'; import { TOOL_AGENT_OUTPUT, TOOL_TASK, TOOL_TODO_WRITE } from '@/core/tools/toolNames'; import type { ChatMessage } from '@/core/types'; import { StreamController, type StreamControllerDeps } from '@/features/chat/controllers/StreamController'; import { ChatState } from '@/features/chat/state/ChatState'; jest.mock('@/core/tools', () => { return { extractResolvedAnswers: jest.fn().mockReturnValue(undefined), extractResolvedAnswersFromResultText: jest.fn().mockReturnValue(undefined), parseTodoInput: jest.fn(), }; }); jest.mock('@/features/chat/rendering', () => { return { addSubagentToolCall: jest.fn(), appendThinkingContent: jest.fn(), createAsyncSubagentBlock: jest.fn().mockReturnValue({}), createSubagentBlock: jest.fn().mockReturnValue({ info: { id: 'task-1', description: 'test', status: 'running', toolCalls: [] }, }), createThinkingBlock: jest.fn().mockReturnValue({ container: {}, contentEl: {}, content: '', startTime: Date.now(), }), createWriteEditBlock: jest.fn().mockReturnValue({}), finalizeAsyncSubagent: jest.fn(), finalizeSubagentBlock: jest.fn(), finalizeThinkingBlock: jest.fn().mockReturnValue(0), finalizeWriteEditBlock: jest.fn(), getToolName: jest.fn().mockReturnValue('Read'), getToolSummary: jest.fn().mockReturnValue('file.md'), isBlockedToolResult: jest.fn().mockReturnValue(false), markAsyncSubagentOrphaned: jest.fn(), renderToolCall: jest.fn(), updateAsyncSubagentRunning: jest.fn(), updateSubagentToolResult: jest.fn(), updateToolCallResult: jest.fn(), updateWriteEditWithDiff: jest.fn(), }; }); jest.mock('@/utils/path', () => ({ getVaultPath: jest.fn().mockReturnValue('/test/vault'), })); jest.mock('@/utils/sdkSession', () => ({ loadSubagentToolCalls: jest.fn().mockResolvedValue([]), loadSubagentFinalResult: jest.fn().mockResolvedValue(null), })); function createMockDeps(): StreamControllerDeps { const state = new ChatState(); const messagesEl = createMockEl(); const agentService = { getSessionId: jest.fn().mockReturnValue('session-1'), }; const fileContextManager = { markFileBeingEdited: jest.fn(), trackEditedFile: jest.fn(), getAttachedFiles: jest.fn().mockReturnValue(new Set()), hasFilesChanged: jest.fn().mockReturnValue(false), }; return { plugin: { settings: { permissionMode: 'yolo', }, app: { vault: { adapter: { basePath: '/test/vault', }, }, }, } as any, state, renderer: { renderContent: jest.fn(), addTextCopyButton: jest.fn(), } as any, subagentManager: { isAsyncTask: jest.fn().mockReturnValue(false), isPendingAsyncTask: jest.fn().mockReturnValue(false), isLinkedAgentOutputTool: jest.fn().mockReturnValue(false), handleAgentOutputToolResult: jest.fn().mockReturnValue(undefined), handleAgentOutputToolUse: jest.fn(), handleTaskToolUse: jest.fn().mockReturnValue({ action: 'buffered' }), handleTaskToolResult: jest.fn(), refreshAsyncSubagent: jest.fn(), hasPendingTask: jest.fn().mockReturnValue(false), renderPendingTask: jest.fn().mockReturnValue(null), renderPendingTaskFromTaskResult: jest.fn().mockReturnValue(null), getSyncSubagent: jest.fn().mockReturnValue(undefined), addSyncToolCall: jest.fn(), updateSyncToolResult: jest.fn(), finalizeSyncSubagent: jest.fn().mockReturnValue(null), resetStreamingState: jest.fn(), resetSpawnedCount: jest.fn(), subagentsSpawnedThisStream: 0, } as any, getMessagesEl: () => messagesEl, getFileContextManager: () => fileContextManager as any, updateQueueIndicator: jest.fn(), getAgentService: () => agentService as any, }; } function createTestMessage(): ChatMessage { return { id: 'assistant-1', role: 'assistant', content: '', timestamp: Date.now(), toolCalls: [], contentBlocks: [], }; } function createMockUsage(overrides: Record<string, any> = {}) { return { model: 'model-a', inputTokens: 10, cacheCreationInputTokens: 0, cacheReadInputTokens: 0, contextWindow: 100, contextTokens: 10, percentage: 10, ...overrides, }; } describe('StreamController - Text Content', () => { let controller: StreamController; let deps: StreamControllerDeps; beforeEach(() => { jest.clearAllMocks(); jest.useFakeTimers(); deps = createMockDeps(); controller = new StreamController(deps); deps.state.currentContentEl = createMockEl(); }); afterEach(() => { // Clean up any timers set by ChatState deps.state.resetStreamingState(); jest.useRealTimers(); }); describe('Text streaming', () => { it('should append text content to message', async () => { const msg = createTestMessage(); deps.state.currentTextEl = createMockEl(); await controller.handleStreamChunk({ type: 'text', content: 'Hello ' }, msg); await controller.handleStreamChunk({ type: 'text', content: 'World' }, msg); expect(msg.content).toBe('Hello World'); }); it('should accumulate text across multiple chunks', async () => { const msg = createTestMessage(); deps.state.currentTextEl = createMockEl(); const chunks = ['This ', 'is ', 'a ', 'test.']; for (const chunk of chunks) { await controller.handleStreamChunk({ type: 'text', content: chunk }, msg); } expect(msg.content).toBe('This is a test.'); }); }); describe('Text block finalization', () => { it('should add copy button when finalizing text block with content', () => { const msg = createTestMessage(); deps.state.currentTextEl = createMockEl(); deps.state.currentTextContent = 'Hello World'; controller.finalizeCurrentTextBlock(msg); expect(deps.renderer.addTextCopyButton).toHaveBeenCalledWith( expect.anything(), 'Hello World' ); expect(msg.contentBlocks).toContainEqual({ type: 'text', content: 'Hello World', }); }); it('should not add copy button when no text element exists', () => { const msg = createTestMessage(); deps.state.currentTextEl = null; deps.state.currentTextContent = 'Hello World'; controller.finalizeCurrentTextBlock(msg); expect(deps.renderer.addTextCopyButton).not.toHaveBeenCalled(); // Content block should still be added expect(msg.contentBlocks).toContainEqual({ type: 'text', content: 'Hello World', }); }); it('should not add copy button when no text content exists', () => { const msg = createTestMessage(); deps.state.currentTextEl = createMockEl(); deps.state.currentTextContent = ''; controller.finalizeCurrentTextBlock(msg); expect(deps.renderer.addTextCopyButton).not.toHaveBeenCalled(); expect(msg.contentBlocks).toEqual([]); }); it('should reset text state after finalization', () => { const msg = createTestMessage(); deps.state.currentTextEl = createMockEl(); deps.state.currentTextContent = 'Test content'; controller.finalizeCurrentTextBlock(msg); expect(deps.state.currentTextEl).toBeNull(); expect(deps.state.currentTextContent).toBe(''); }); }); describe('Error and blocked handling', () => { it('should append error message on error chunk', async () => { const msg = createTestMessage(); deps.state.currentTextEl = createMockEl(); await controller.handleStreamChunk( { type: 'error', content: 'Something went wrong' }, msg ); expect(deps.state.currentTextContent).toContain('Error'); }); it('should append blocked message on blocked chunk', async () => { const msg = createTestMessage(); deps.state.currentTextEl = createMockEl(); await controller.handleStreamChunk( { type: 'blocked', content: 'Tool was blocked' }, msg ); expect(deps.state.currentTextContent).toContain('Blocked'); }); }); describe('sdk_assistant_uuid handling', () => { it('should set sdkAssistantUuid on message', async () => { const msg = createTestMessage(); await controller.handleStreamChunk( { type: 'sdk_assistant_uuid', uuid: 'asst-uuid-123' } as any, msg ); expect(msg.sdkAssistantUuid).toBe('asst-uuid-123'); }); it('should overwrite previous sdkAssistantUuid', async () => { const msg = createTestMessage(); msg.sdkAssistantUuid = 'old-uuid'; await controller.handleStreamChunk( { type: 'sdk_assistant_uuid', uuid: 'new-uuid' } as any, msg ); expect(msg.sdkAssistantUuid).toBe('new-uuid'); }); }); describe('Done chunk handling', () => { it('should handle done chunk without error', async () => { const msg = createTestMessage(); deps.state.currentTextEl = createMockEl(); // Should not throw await expect( controller.handleStreamChunk({ type: 'done' }, msg) ).resolves.not.toThrow(); }); }); describe('Usage handling', () => { it('should update usage for current session', async () => { const msg = createTestMessage(); const usage = createMockUsage(); await controller.handleStreamChunk({ type: 'usage', usage, sessionId: 'session-1' }, msg); expect(deps.state.usage).toEqual(usage); }); it('should ignore usage from other sessions', async () => { const msg = createTestMessage(); const usage = createMockUsage(); await controller.handleStreamChunk({ type: 'usage', usage, sessionId: 'session-2' }, msg); expect(deps.state.usage).toBeNull(); }); }); describe('Tool handling', () => { it('should record tool_use and add to content blocks', async () => { const msg = createTestMessage(); deps.state.currentContentEl = createMockEl(); await controller.handleStreamChunk( { type: 'tool_use', id: 'tool-1', name: 'Read', input: { file_path: 'notes/test.md' } }, msg ); expect(msg.toolCalls).toHaveLength(1); expect(msg.toolCalls![0].id).toBe('tool-1'); expect(msg.toolCalls![0].status).toBe('running'); expect(msg.contentBlocks).toHaveLength(1); expect(msg.contentBlocks![0]).toEqual({ type: 'tool_use', toolId: 'tool-1' }); // Thinking indicator is debounced - advance timer to trigger it jest.advanceTimersByTime(500); expect(deps.updateQueueIndicator).toHaveBeenCalled(); }); it('should update tool_result status', async () => { const msg = createTestMessage(); msg.toolCalls = [ { id: 'tool-1', name: 'Read', input: { file_path: 'notes/test.md' }, status: 'running', } as any, ]; deps.state.currentContentEl = createMockEl(); await controller.handleStreamChunk( { type: 'tool_result', id: 'tool-1', content: 'ok' }, msg ); expect(msg.toolCalls![0].status).toBe('completed'); expect(msg.toolCalls![0].result).toBe('ok'); }); it('should add subagent entry to contentBlocks for Task tool', async () => { const msg = createTestMessage(); deps.state.currentContentEl = createMockEl(); // Configure mock to return created_sync when run_in_background is known (deps.subagentManager.handleTaskToolUse as jest.Mock).mockReturnValueOnce({ action: 'created_sync', subagentState: { info: { id: 'task-1', description: 'test', status: 'running', toolCalls: [] }, }, }); await controller.handleStreamChunk( { type: 'tool_use', id: 'task-1', name: TOOL_TASK, input: { prompt: 'Do something', subagent_type: 'general-purpose', run_in_background: false }, }, msg ); expect(msg.contentBlocks).toHaveLength(1); expect(msg.contentBlocks![0]).toEqual({ type: 'subagent', subagentId: 'task-1' }); expect(msg.toolCalls).toContainEqual( expect.objectContaining({ id: 'task-1', name: TOOL_TASK, subagent: expect.objectContaining({ id: 'task-1' }), }) ); }); it('should render TodoWrite inline and update panel', async () => { const { parseTodoInput } = jest.requireMock('@/core/tools'); const { renderToolCall } = jest.requireMock('@/features/chat/rendering'); const mockTodos = [{ content: 'Task 1', status: 'pending', activeForm: 'Working on task 1' }]; parseTodoInput.mockReturnValue(mockTodos); const msg = createTestMessage(); deps.state.currentContentEl = createMockEl(); await controller.handleStreamChunk( { type: 'tool_use', id: 'todo-1', name: TOOL_TODO_WRITE, input: { todos: mockTodos }, }, msg ); // Tool is buffered, should be in pendingTools expect(msg.contentBlocks).toHaveLength(1); expect(msg.contentBlocks![0]).toEqual({ type: 'tool_use', toolId: 'todo-1' }); expect(deps.state.pendingTools.size).toBe(1); // Should update currentTodos for panel immediately (side effect) expect(deps.state.currentTodos).toEqual(mockTodos); // Flush pending tools by sending a different chunk type (text or done) await controller.handleStreamChunk({ type: 'done' }, msg); // Now renderToolCall should have been called expect(renderToolCall).toHaveBeenCalled(); expect(deps.state.pendingTools.size).toBe(0); }); it('should flush pending tools before rendering text content', async () => { const { renderToolCall } = jest.requireMock('@/features/chat/rendering'); const msg = createTestMessage(); deps.state.currentContentEl = createMockEl(); await controller.handleStreamChunk( { type: 'tool_use', id: 'read-1', name: 'Read', input: { file_path: 'test.md' } }, msg ); expect(deps.state.pendingTools.size).toBe(1); expect(renderToolCall).not.toHaveBeenCalled(); deps.state.currentTextEl = createMockEl(); await controller.handleStreamChunk({ type: 'text', content: 'Hello' }, msg); expect(deps.state.pendingTools.size).toBe(0); expect(renderToolCall).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ id: 'read-1', name: 'Read' }), expect.any(Map) ); }); it('should flush pending tools before rendering thinking content', async () => { const { renderToolCall } = jest.requireMock('@/features/chat/rendering'); const msg = createTestMessage(); deps.state.currentContentEl = createMockEl(); await controller.handleStreamChunk( { type: 'tool_use', id: 'grep-1', name: 'Grep', input: { pattern: 'test' } }, msg ); expect(deps.state.pendingTools.size).toBe(1); expect(renderToolCall).not.toHaveBeenCalled(); await controller.handleStreamChunk({ type: 'thinking', content: 'Let me think...' }, msg); expect(deps.state.pendingTools.size).toBe(0); expect(renderToolCall).toHaveBeenCalled(); }); it('should render pending tool when tool_result arrives before flush', async () => { const { renderToolCall } = jest.requireMock('@/features/chat/rendering'); const msg = createTestMessage(); deps.state.currentContentEl = createMockEl(); await controller.handleStreamChunk( { type: 'tool_use', id: 'read-1', name: 'Read', input: { file_path: 'test.md' } }, msg ); expect(deps.state.pendingTools.size).toBe(1); expect(renderToolCall).not.toHaveBeenCalled(); // Result arrives while tool still pending - should render tool first await controller.handleStreamChunk( { type: 'tool_result', id: 'read-1', content: 'file contents here' }, msg ); expect(deps.state.pendingTools.size).toBe(0); expect(renderToolCall).toHaveBeenCalled(); expect(msg.toolCalls![0].status).toBe('completed'); expect(msg.toolCalls![0].result).toBe('file contents here'); }); it('should buffer Write tool and use createWriteEditBlock on flush', async () => { const { createWriteEditBlock, renderToolCall } = jest.requireMock('@/features/chat/rendering'); createWriteEditBlock.mockReturnValue({ wrapperEl: createMockEl() }); const msg = createTestMessage(); deps.state.currentContentEl = createMockEl(); await controller.handleStreamChunk( { type: 'tool_use', id: 'write-1', name: 'Write', input: { file_path: 'test.md', content: 'hello' } }, msg ); expect(deps.state.pendingTools.size).toBe(1); expect(createWriteEditBlock).not.toHaveBeenCalled(); expect(renderToolCall).not.toHaveBeenCalled(); await controller.handleStreamChunk({ type: 'done' }, msg); expect(deps.state.pendingTools.size).toBe(0); expect(createWriteEditBlock).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ id: 'write-1', name: 'Write' }) ); // renderToolCall should NOT be called for Write/Edit tools expect(renderToolCall).not.toHaveBeenCalled(); }); it('should buffer Edit tool and use createWriteEditBlock on flush', async () => { const { createWriteEditBlock } = jest.requireMock('@/features/chat/rendering'); createWriteEditBlock.mockReturnValue({ wrapperEl: createMockEl() }); const msg = createTestMessage(); deps.state.currentContentEl = createMockEl(); await controller.handleStreamChunk( { type: 'tool_use', id: 'edit-1', name: 'Edit', input: { file_path: 'test.md', old_string: 'a', new_string: 'b' } }, msg ); expect(deps.state.pendingTools.size).toBe(1); expect(createWriteEditBlock).not.toHaveBeenCalled(); deps.state.currentTextEl = createMockEl(); await controller.handleStreamChunk({ type: 'text', content: 'Done editing' }, msg); expect(deps.state.pendingTools.size).toBe(0); expect(createWriteEditBlock).toHaveBeenCalled(); }); it('should flush pending tools before rendering blocked message', async () => { const { renderToolCall } = jest.requireMock('@/features/chat/rendering'); const msg = createTestMessage(); deps.state.currentContentEl = createMockEl(); await controller.handleStreamChunk( { type: 'tool_use', id: 'bash-1', name: 'Bash', input: { command: 'ls' } }, msg ); expect(deps.state.pendingTools.size).toBe(1); await controller.handleStreamChunk({ type: 'blocked', content: 'Command blocked' }, msg); expect(deps.state.pendingTools.size).toBe(0); expect(renderToolCall).toHaveBeenCalled(); }); it('should flush pending tools before rendering error message', async () => { const { renderToolCall } = jest.requireMock('@/features/chat/rendering'); const msg = createTestMessage(); deps.state.currentContentEl = createMockEl(); await controller.handleStreamChunk( { type: 'tool_use', id: 'read-1', name: 'Read', input: { file_path: 'missing.md' } }, msg ); expect(deps.state.pendingTools.size).toBe(1); await controller.handleStreamChunk({ type: 'error', content: 'Something went wrong' }, msg); expect(deps.state.pendingTools.size).toBe(0); expect(renderToolCall).toHaveBeenCalled(); }); it('should flush pending tools before Task tool renders', async () => { const { renderToolCall } = jest.requireMock('@/features/chat/rendering'); const msg = createTestMessage(); deps.state.currentContentEl = createMockEl(); (deps.subagentManager.handleTaskToolUse as jest.Mock).mockReturnValueOnce({ action: 'created_sync', subagentState: { info: { id: 'task-1', description: 'test', status: 'running', toolCalls: [] }, }, }); await controller.handleStreamChunk( { type: 'tool_use', id: 'read-1', name: 'Read', input: { file_path: 'test.md' } }, msg ); expect(deps.state.pendingTools.size).toBe(1); expect(renderToolCall).not.toHaveBeenCalled(); await controller.handleStreamChunk( { type: 'tool_use', id: 'task-1', name: TOOL_TASK, input: { prompt: 'Do something', subagent_type: 'general-purpose', run_in_background: false } }, msg ); expect(deps.state.pendingTools.size).toBe(0); expect(renderToolCall).toHaveBeenCalled(); expect(deps.subagentManager.handleTaskToolUse).toHaveBeenCalledWith( 'task-1', expect.objectContaining({ run_in_background: false }), expect.anything() ); }); it('should re-parse TodoWrite on input updates when streaming completes', async () => { const { parseTodoInput } = jest.requireMock('@/core/tools'); const mockTodos = [ { content: 'Task 1', status: 'pending', activeForm: 'Working on task 1' }, ]; // First chunk: partial input, parsing fails parseTodoInput.mockReturnValueOnce(null); const msg = createTestMessage(); deps.state.currentContentEl = createMockEl(); await controller.handleStreamChunk( { type: 'tool_use', id: 'todo-1', name: TOOL_TODO_WRITE, input: { todos: '[' }, // Incomplete JSON }, msg ); // No todos yet expect(deps.state.currentTodos).toBeNull(); // Second chunk: complete input, parsing succeeds parseTodoInput.mockReturnValueOnce(mockTodos); await controller.handleStreamChunk( { type: 'tool_use', id: 'todo-1', name: TOOL_TODO_WRITE, input: { todos: mockTodos }, }, msg ); // Now todos should be updated expect(deps.state.currentTodos).toEqual(mockTodos); }); it('should clear pendingTools on resetStreamingState', async () => { const msg = createTestMessage(); deps.state.currentContentEl = createMockEl(); await controller.handleStreamChunk( { type: 'tool_use', id: 'read-1', name: 'Read', input: { file_path: 'a.md' } }, msg ); await controller.handleStreamChunk( { type: 'tool_use', id: 'read-2', name: 'Read', input: { file_path: 'b.md' } }, msg ); expect(deps.state.pendingTools.size).toBe(2); controller.resetStreamingState(); expect(deps.state.pendingTools.size).toBe(0); }); it('should clear responseStartTime on resetStreamingState', () => { deps.state.responseStartTime = 12345; expect(deps.state.responseStartTime).toBe(12345); controller.resetStreamingState(); expect(deps.state.responseStartTime).toBeNull(); }); }); describe('Timer lifecycle', () => { it('should create timer interval when showing thinking indicator', () => { deps.state.responseStartTime = performance.now(); controller.showThinkingIndicator(); jest.advanceTimersByTime(500); // Past the debounce delay expect(deps.state.flavorTimerInterval).not.toBeNull(); }); it('should clear timer interval when hiding thinking indicator', () => { deps.state.responseStartTime = performance.now(); controller.showThinkingIndicator(); jest.advanceTimersByTime(500); expect(deps.state.flavorTimerInterval).not.toBeNull(); controller.hideThinkingIndicator(); expect(deps.state.flavorTimerInterval).toBeNull(); }); it('should clear timer interval in resetStreamingState', () => { deps.state.responseStartTime = performance.now(); controller.showThinkingIndicator(); jest.advanceTimersByTime(500); expect(deps.state.flavorTimerInterval).not.toBeNull(); controller.resetStreamingState(); expect(deps.state.flavorTimerInterval).toBeNull(); }); it('should not create duplicate intervals on multiple showThinkingIndicator calls', () => { deps.state.responseStartTime = performance.now(); const clearIntervalSpy = jest.spyOn(global, 'clearInterval'); controller.showThinkingIndicator(); jest.advanceTimersByTime(500); const firstInterval = deps.state.flavorTimerInterval; // Second call while indicator exists should not create a new interval controller.showThinkingIndicator(); jest.advanceTimersByTime(500); // Should still have the same interval (no new one created since element exists) expect(deps.state.flavorTimerInterval).toBe(firstInterval); clearIntervalSpy.mockRestore(); }); }); describe('Tool handling - continued', () => { it('should handle multiple pending tools and flush in order', async () => { const { renderToolCall } = jest.requireMock('@/features/chat/rendering'); const msg = createTestMessage(); deps.state.currentContentEl = createMockEl(); await controller.handleStreamChunk( { type: 'tool_use', id: 'read-1', name: 'Read', input: { file_path: 'a.md' } }, msg ); await controller.handleStreamChunk( { type: 'tool_use', id: 'grep-1', name: 'Grep', input: { pattern: 'test' } }, msg ); await controller.handleStreamChunk( { type: 'tool_use', id: 'glob-1', name: 'Glob', input: { pattern: '*.md' } }, msg ); expect(deps.state.pendingTools.size).toBe(3); expect(renderToolCall).not.toHaveBeenCalled(); await controller.handleStreamChunk({ type: 'done' }, msg); expect(deps.state.pendingTools.size).toBe(0); expect(renderToolCall).toHaveBeenCalledTimes(3); // Verify tools were rendered in order (Map preserves insertion order) const calls = renderToolCall.mock.calls; expect(calls[0][1].id).toBe('read-1'); expect(calls[1][1].id).toBe('grep-1'); expect(calls[2][1].id).toBe('glob-1'); }); }); describe('Usage handling - edge cases', () => { it('should skip usage when subagentsSpawnedThisStream > 0', async () => { const msg = createTestMessage(); (deps.subagentManager as any).subagentsSpawnedThisStream = 1; const usage = createMockUsage({ inputTokens: 100, contextWindow: 200, contextTokens: 100, percentage: 50 }); await controller.handleStreamChunk({ type: 'usage', usage, sessionId: 'session-1' }, msg); expect(deps.state.usage).toBeNull(); }); it('should skip usage when chunk has sessionId but currentSessionId is null', async () => { const nullSessionDeps = createMockDeps(); nullSessionDeps.getAgentService = () => ({ getSessionId: jest.fn().mockReturnValue(null) }) as any; nullSessionDeps.state.currentContentEl = createMockEl(); const nullSessionController = new StreamController(nullSessionDeps); const msg = createTestMessage(); const usage = createMockUsage(); await nullSessionController.handleStreamChunk({ type: 'usage', usage, sessionId: 'some-session' }, msg); expect(nullSessionDeps.state.usage).toBeNull(); }); it('should update usage when no sessionId on chunk', async () => { const msg = createTestMessage(); const usage = createMockUsage(); await controller.handleStreamChunk({ type: 'usage', usage } as any, msg); expect(deps.state.usage).toEqual(usage); }); it('should not update usage when ignoreUsageUpdates is true', async () => { const msg = createTestMessage(); deps.state.ignoreUsageUpdates = true; const usage = createMockUsage(); await controller.handleStreamChunk({ type: 'usage', usage, sessionId: 'session-1' }, msg); expect(deps.state.usage).toBeNull(); }); }); describe('Thinking indicator - edge cases', () => { it('should not show indicator when no currentContentEl', () => { deps.state.currentContentEl = null; controller.showThinkingIndicator(); jest.advanceTimersByTime(500); expect(deps.state.thinkingEl).toBeNull(); }); it('should not show indicator when currentThinkingState is active', () => { deps.state.currentThinkingState = { content: 'thinking...', container: {}, contentEl: {}, startTime: Date.now() } as any; controller.showThinkingIndicator(); jest.advanceTimersByTime(500); expect(deps.state.thinkingEl).toBeNull(); }); it('should re-append existing indicator to bottom when called again', () => { deps.state.responseStartTime = performance.now(); controller.showThinkingIndicator(); jest.advanceTimersByTime(500); const thinkingEl = deps.state.thinkingEl; expect(thinkingEl).not.toBeNull(); controller.showThinkingIndicator(); expect(deps.state.thinkingEl).toBe(thinkingEl); expect(deps.updateQueueIndicator).toHaveBeenCalled(); }); }); describe('scrollToBottom - settings', () => { it('should not scroll when enableAutoScroll setting is false', async () => { (deps.plugin.settings as any).enableAutoScroll = false; const messagesEl = deps.getMessagesEl(); Object.defineProperty(messagesEl, 'scrollHeight', { value: 1000, configurable: true }); messagesEl.scrollTop = 0; const msg = createTestMessage(); deps.state.currentTextEl = createMockEl(); await controller.handleStreamChunk({ type: 'text', content: 'Hello' }, msg); expect(messagesEl.scrollTop).toBe(0); }); it('should not scroll when autoScrollEnabled state is false', async () => { deps.state.autoScrollEnabled = false; const messagesEl = deps.getMessagesEl(); Object.defineProperty(messagesEl, 'scrollHeight', { value: 1000, configurable: true }); messagesEl.scrollTop = 0; const msg = createTestMessage(); deps.state.currentTextEl = createMockEl(); await controller.handleStreamChunk({ type: 'text', content: 'Hello' }, msg); expect(messagesEl.scrollTop).toBe(0); }); }); describe('Subagent chunk handling', () => { it('should ignore subagent chunk with text type (no-op)', async () => { const msg = createTestMessage(); deps.state.currentContentEl = createMockEl(); (deps.subagentManager.getSyncSubagent as jest.Mock).mockReturnValueOnce({ info: { id: 'task-1', description: 'test', status: 'running', toolCalls: [] }, }); await controller.handleStreamChunk( { type: 'text', content: 'Subagent text', parentToolUseId: 'task-1' } as any, msg ); // No text appended to main message expect(msg.content).toBe(''); }); it('should handle subagent tool_result chunk', async () => { const msg = createTestMessage(); deps.state.currentContentEl = createMockEl(); const toolCall = { id: 'read-1', name: 'Read', input: {}, status: 'running' }; (deps.subagentManager.getSyncSubagent as jest.Mock).mockReturnValueOnce({ info: { id: 'task-1', description: 'test', status: 'running', toolCalls: [toolCall] }, }); await controller.handleStreamChunk( { type: 'tool_result', id: 'read-1', content: 'file content', parentToolUseId: 'task-1' } as any, msg ); expect(deps.subagentManager.updateSyncToolResult).toHaveBeenCalledWith( 'task-1', 'read-1', expect.objectContaining({ status: 'completed', result: 'file content' }) ); }); it('should handle subagent tool_use chunk', async () => { const msg = createTestMessage(); deps.state.currentContentEl = createMockEl(); (deps.subagentManager.getSyncSubagent as jest.Mock).mockReturnValueOnce({ info: { id: 'task-1', description: 'test', status: 'running', toolCalls: [] }, }); await controller.handleStreamChunk( { type: 'tool_use', id: 'grep-1', name: 'Grep', input: { pattern: 'test' }, parentToolUseId: 'task-1' } as any, msg ); expect(deps.subagentManager.addSyncToolCall).toHaveBeenCalledWith( 'task-1', expect.objectContaining({ id: 'grep-1', name: 'Grep', status: 'running' }) ); }); it('should skip subagent chunk when no sync subagent found', async () => { const msg = createTestMessage(); deps.state.currentContentEl = createMockEl(); (deps.subagentManager.getSyncSubagent as jest.Mock).mockReturnValueOnce(undefined); await controller.handleStreamChunk( { type: 'text', content: 'orphan', parentToolUseId: 'unknown-task' } as any, msg ); // Should not throw expect(msg.content).toBe(''); }); }); describe('Async subagent handling', () => { it('should handle created_async action from Task tool use', async () => { const msg = createTestMessage(); deps.state.currentContentEl = createMockEl(); (deps.subagentManager.handleTaskToolUse as jest.Mock).mockReturnValueOnce({ action: 'created_async', info: { id: 'task-1', description: 'background task', status: 'running', toolCalls: [], mode: 'async' }, }); await controller.handleStreamChunk( { type: 'tool_use', id: 'task-1', name: TOOL_TASK, input: { prompt: 'Do something', run_in_background: true } }, msg ); expect(msg.toolCalls).toContainEqual( expect.objectContaining({ id: 'task-1', name: TOOL_TASK, subagent: expect.objectContaining({ id: 'task-1', mode: 'async', }), }) ); expect(msg.contentBlocks).toContainEqual({ type: 'subagent', subagentId: 'task-1', mode: 'async' }); }); it('should handle label_updated action from Task tool use (no-op for message)', async () => { const msg = createTestMessage(); deps.state.currentContentEl = createMockEl(); (deps.subagentManager.handleTaskToolUse as jest.Mock).mockReturnValueOnce({ action: 'label_updated', }); await controller.handleStreamChunk( { type: 'tool_use', id: 'task-1', name: TOOL_TASK, input: { prompt: 'Updated' } }, msg ); expect(msg.toolCalls).toContainEqual( expect.objectContaining({ id: 'task-1', name: TOOL_TASK, }) ); expect(msg.contentBlocks).toEqual([]); }); }); describe('onAsyncSubagentStateChange', () => { it('should update subagent in messages', () => { const subagent = { id: 'task-1', description: 'test', status: 'completed', result: 'done', toolCalls: [] } as any; deps.state.messages = [{ id: 'a1', role: 'assistant', content: '', timestamp: Date.now(), toolCalls: [{ id: 'task-1', name: TOOL_TASK, input: { description: 'test' }, status: 'running', subagent: { id: 'task-1', description: 'test', status: 'running', toolCalls: [] }, }], }] as any; controller.onAsyncSubagentStateChange(subagent); const taskTool = deps.state.messages[0].toolCalls![0]; expect(taskTool.status).toBe('completed'); expect(taskTool.subagent?.status).toBe('completed'); expect(taskTool.subagent?.result).toBe('done'); }); it('should not crash when subagent not found in messages', () => { const subagent = { id: 'unknown', description: 'test', status: 'completed', toolCalls: [] } as any; deps.state.messages = [{ id: 'a1', role: 'assistant', content: '', timestamp: Date.now(), toolCalls: [{ id: 'task-1', name: TOOL_TASK, input: { description: 'test' }, status: 'running', }], }] as any; expect(() => controller.onAsyncSubagentStateChange(subagent)).not.toThrow(); }); }); describe('Thinking block finalization', () => { it('should finalize thinking block and add to contentBlocks', async () => { const msg = createTestMessage(); deps.state.currentContentEl = createMockEl(); deps.state.currentThinkingState = { content: 'Let me think...', container: createMockEl(), contentEl: createMockEl(), startTime: Date.now(), } as any; controller.finalizeCurrentThinkingBlock(msg); expect(msg.contentBlocks).toContainEqual( expect.objectContaining({ type: 'thinking', content: 'Let me think...' }) ); expect(deps.state.currentThinkingState).toBeNull(); }); it('should not add to contentBlocks when no thinking content', () => { const msg = createTestMessage(); deps.state.currentThinkingState = { content: '', container: createMockEl(), contentEl: createMockEl(), startTime: Date.now(), } as any; controller.finalizeCurrentThinkingBlock(msg); expect(msg.contentBlocks).toEqual([]); }); it('should be a no-op when no thinking state', () => { const msg = createTestMessage(); deps.state.currentThinkingState = null; controller.finalizeCurrentThinkingBlock(msg); expect(msg.contentBlocks).toEqual([]); }); }); describe('Pending Task tool handling', () => { it('should render pending Task as sync when child chunk arrives', async () => { const msg = createTestMessage(); deps.state.currentContentEl = createMockEl(); // Task without run_in_background - manager returns buffered await controller.handleStreamChunk( { type: 'tool_use', id: 'task-1', name: TOOL_TASK, input: { prompt: 'Do something', subagent_type: 'general-purpose' } }, msg ); // Manager's handleTaskToolUse should have been called expect(deps.subagentManager.handleTaskToolUse).toHaveBeenCalledWith( 'task-1', expect.objectContaining({ prompt: 'Do something' }), expect.anything() ); // Configure manager for child chunk: pending task exists, render returns sync (deps.subagentManager.hasPendingTask as jest.Mock).mockReturnValueOnce(true); (deps.subagentManager.renderPendingTask as jest.Mock).mockReturnValueOnce({ mode: 'sync', subagentState: { info: { id: 'task-1', description: 'Do something', status: 'running', toolCalls: [] }, }, }); // Also configure getSyncSubagent for the child chunk routing (deps.subagentManager.getSyncSubagent as jest.Mock).mockReturnValueOnce({ info: { id: 'task-1', description: 'Do something', status: 'running', toolCalls: [] }, }); // Child chunk arrives with parentToolUseId - should trigger render await controller.handleStreamChunk( { type: 'tool_use', id: 'read-1', name: 'Read', input: { file_path: 'test.md' }, parentToolUseId: 'task-1' } as any, msg ); // Task toolCall should carry linked subagent expect(msg.toolCalls).toContainEqual( expect.objectContaining({ id: 'task-1', name: TOOL_TASK, subagent: expect.objectContaining({ id: 'task-1' }), }) ); expect(deps.subagentManager.renderPendingTask).toHaveBeenCalledWith('task-1', deps.state.currentContentEl); }); it('should not crash stream when pending Task rendering returns null via child chunk', async () => { const msg = createTestMessage(); deps.state.currentContentEl = createMockEl(); // Task without run_in_background - manager returns buffered await controller.handleStreamChunk( { type: 'tool_use', id: 'task-1', name: TOOL_TASK, input: { prompt: 'Do something', subagent_type: 'general-purpose' } }, msg ); // Configure manager: pending task exists but render returns null (error case) (deps.subagentManager.hasPendingTask as jest.Mock).mockReturnValueOnce(true); (deps.subagentManager.renderPendingTask as jest.Mock).mockReturnValueOnce(null); // Child chunk arrives - renderPendingTask returns null but shouldn't crash await controller.handleStreamChunk( { type: 'tool_use', id: 'read-1', name: 'Read', input: { file_path: 'test.md' }, parentToolUseId: 'task-1' } as any, msg ); // Should not throw - manager handled errors internally expect(deps.subagentManager.renderPendingTask).toHaveBeenCalledWith('task-1', deps.state.currentContentEl); }); it('should not crash stream when pending Task rendering returns null via tool_result', async () => { const msg = createTestMessage(); deps.state.currentContentEl = createMockEl(); // Task without run_in_background - manager returns buffered await controller.handleStreamChunk( { type: 'tool_use', id: 'task-1', name: TOOL_TASK, input: { prompt: 'Do something', subagent_type: 'general-purpose' } }, msg ); // Configure manager: pending task exists but render returns null (deps.subagentManager.hasPendingTask as jest.Mock).mockReturnValueOnce(true); (deps.subagentManager.renderPendingTaskFromTaskResult as jest.Mock).mockReturnValueOnce(null); // Tool result arrives - pending resolver returns null but stream should continue await controller.handleStreamChunk( { type: 'tool_result', id: 'task-1', content: 'Task completed' }, msg ); // Should not throw - manager handled errors internally expect(deps.subagentManager.renderPendingTaskFromTaskResult).toHaveBeenCalledWith( 'task-1', 'Task completed', false, deps.state.currentContentEl, undefined ); }); it('should resolve pending Task as async via tool_result and continue async lifecycle', async () => { const msg = createTestMessage(); deps.state.currentContentEl = createMockEl(); await controller.handleStreamChunk( { type: 'tool_use', id: 'task-1', name: TOOL_TASK, input: { prompt: 'Do something' } }, msg ); (deps.subagentManager.hasPendingTask as jest.Mock).mockReturnValueOnce(true); (deps.subagentManager.renderPendingTaskFromTaskResult as jest.Mock).mockReturnValueOnce({ mode: 'async', info: { id: 'task-1', description: 'Do something', prompt: 'Do something', mode: 'async', isExpanded: false, status: 'running', toolCalls: [], asyncStatus: 'pending', }, }); (deps.subagentManager.isPendingAsyncTask as jest.Mock).mockReturnValueOnce(true); await controller.handleStreamChunk( { type: 'tool_result', id: 'task-1', content: '{"agent_id":"agent-1"}' }, msg ); expect(deps.subagentManager.renderPendingTaskFromTaskResult).toHaveBeenCalledWith( 'task-1', '{"agent_id":"agent-1"}', false, deps.state.currentContentEl, undefined ); expect(deps.subagentManager.handleTaskToolResult).toHaveBeenCalledWith( 'task-1', '{"agent_id":"agent-1"}', undefined, undefined ); expect(msg.contentBlocks).toContainEqual({ type: 'subagent', subagentId: 'task-1', mode: 'async', }); expect(msg.toolCalls).toContainEqual( expect.objectContaining({ id: 'task-1', name: TOOL_TASK, subagent: expect.objectContaining({ mode: 'async' }), }) ); }); it('should pass task toolUseResult into pending Task resolver', async () => { const msg = createTestMessage(); deps.state.currentContentEl = createMockEl(); await controller.handleStreamChunk( { type: 'tool_use', id: 'task-1', name: TOOL_TASK, input: { prompt: 'Do something' } }, msg ); const toolUseResult = { isAsync: true, status: 'async_launched', agentId: 'agent-1' }; (deps.subagentManager.hasPendingTask as jest.Mock).mockReturnValueOnce(true); (deps.subagentManager.renderPendingTaskFromTaskResult as jest.Mock).mockReturnValueOnce(null); await controller.handleStreamChunk( { type: 'tool_result', id: 'task-1', content: 'Launching...', toolUseResult } as any, msg ); expect(deps.subagentManager.renderPendingTaskFromTaskResult).toHaveBeenCalledWith( 'task-1', 'Launching...', false, deps.state.currentContentEl, toolUseResult ); }); }); describe('Text ↔ Thinking transitions', () => { it('text arrives while thinking state is active → finalizeCurrentThinkingBlock is called', async () => { const { finalizeThinkingBlock } = jest.requireMock('@/features/chat/rendering'); const msg = createTestMessage(); deps.state.currentContentEl = createMockEl(); deps.state.currentThinkingState = { content: 'Let me think...', container: createMockEl(), contentEl: createMockEl(), startTime: Date.now(), } as any; await controller.handleStreamChunk({ type: 'text', content: 'Hello' }, msg); expect(finalizeThinkingBlock).toHaveBeenCalled(); expect(deps.state.currentThinkingState).toBeNull(); expect(msg.contentBlocks).toContainEqual( expect.objectContaining({ type: 'thinking', content: 'Let me think...' }) ); }); it('thinking arrives while textEl exists → finalizeCurrentTextBlock is called', async () => { const msg = createTestMessage(); deps.state.currentContentEl = createMockEl(); deps.state.currentTextEl = createMockEl(); deps.state.currentTextContent = 'Some text'; await controller.handleStreamChunk({ type: 'thinking', content: 'Hmm...' }, msg); expect(deps.state.currentTextEl).toBeNull(); expect(msg.contentBlocks).toContainEqual( expect.objectContaining({ type: 'text', content: 'Some text' }) ); expect(deps.renderer.addTextCopyButton).toHaveBeenCalledWith( expect.anything(), 'Some text' ); }); it('tool_use arrives while thinking state → finalizeCurrentThinkingBlock is called', async () => { const { finalizeThinkingBlock } = jest.requireMock('@/features/chat/rendering'); const msg = createTestMessage(); deps.state.currentContentEl = createMockEl(); deps.state.currentThinkingState = { content: 'Reasoning...', container: createMockEl(), contentEl: createMockEl(), startTime: Date.now(), } as any; await controller.handleStreamChunk( { type: 'tool_use', id: 'read-1', name: 'Read', input: { file_path: 'test.md' } }, msg ); expect(finalizeThinkingBlock).toHaveBeenCalled(); expect(deps.state.currentThinkingState).toBeNull(); expect(msg.contentBlocks).toContainEqual( expect.objectContaining({ type: 'thinking', content: 'Reasoning...' }) ); }); }); describe('Agent output tool use/result', () => { it('TOOL_AGENT_OUTPUT chunk creates tool call and delegates to subagentManager.handleAgentOutputToolUse', async () => { const msg = createTestMessage(); deps.state.currentContentEl = createMockEl(); await controller.handleStreamChunk( { type: 'tool_use', id: 'agent-out-1', name: TOOL_AGENT_OUTPUT, input: { task_id: 'task-1' } }, msg ); expect(deps.subagentManager.handleAgentOutputToolUse).toHaveBeenCalledWith( expect.objectContaining({ id: 'agent-out-1', name: TOOL_AGENT_OUTPUT, status: 'running', }) ); expect(msg.toolCalls).toEqual([]); expect(msg.contentBlocks).toEqual([]); }); it('Agent output tool result handled via handleAgentOutputToolResult returning true', async () => { const { updateToolCallResult } = jest.requireMock('@/features/chat/rendering'); const msg = createTestMessage(); deps.state.currentContentEl = createMockEl(); (deps.subagentManager.isLinkedAgentOutputTool as jest.Mock).mockReturnValueOnce(true); (deps.subagentManager.handleAgentOutputToolResult as jest.Mock).mockReturnValueOnce({}); await controller.handleStreamChunk( { type: 'tool_result', id: 'agent-out-1', content: 'agent result', toolUseResult: { foo: 'bar' } as any }, msg ); expect(deps.subagentManager.handleAgentOutputToolResult).toHaveBeenCalledWith( 'agent-out-1', 'agent result', false, { foo: 'bar' } ); expect(updateToolCallResult).not.toHaveBeenCalled(); }); it('hydrates async subagent tool calls from sidecar during streaming completion', async () => { const { loadSubagentToolCalls, loadSubagentFinalResult } = jest.requireMock('@/utils/sdkSession'); const msg = createTestMessage(); deps.state.currentContentEl = createMockEl(); const completedSubagent = { id: 'task-1', description: 'Background task', prompt: 'Do work', mode: 'async', status: 'completed', toolCalls: [], isExpanded: false, asyncStatus: 'completed', agentId: 'agent-1', result: 'Done', }; (deps.subagentManager.isLinkedAgentOutputTool as jest.Mock).mockReturnValueOnce(true); (deps.subagentManager.handleAgentOutputToolResult as jest.Mock).mockReturnValueOnce(completedSubagent); loadSubagentToolCalls.mockResolvedValueOnce([ { id: 'read-1', name: 'Read', input: { file_path: 'notes.md' }, status: 'completed', result: 'content', isExpanded: false, }, ]); await controller.handleStreamChunk( { type: 'tool_result', id: 'agent-out-1', content: 'agent result' }, msg ); expect(loadSubagentToolCalls).toHaveBeenCalledWith( '/test/vault', 'session-1', 'agent-1' ); expect(loadSubagentFinalResult).toHaveBeenCalledWith( '/test/vault', 'session-1', 'agent-1' ); expect(completedSubagent.toolCalls).toHaveLength(1); expect(deps.subagentManager.refreshAsyncSubagent).toHaveBeenCalledWith(completedSubagent); }); it('hydrates async subagent final result from sidecar even when tool calls already exist', async () => { const { loadSubagentFinalResult, loadSubagentToolCalls } = jest.requireMock('@/utils/sdkSession'); const msg = createTestMessage(); deps.state.currentContentEl = createMockEl(); const completedSubagent = { id: 'task-2', description: 'Background task', prompt: 'Do work', mode: 'async', status: 'completed', toolCalls: [ { id: 'existing-tool', name: 'Read', input: { file_path: 'notes.md' }, status: 'completed', result: 'existing', isExpanded: false, }, ], isExpanded: false, asyncStatus: 'completed', agentId: 'agent-2', result: 'Short placeholder', }; (deps.subagentManager.isLinkedAgentOutputTool as jest.Mock).mockReturnValueOnce(true); (deps.subagentManager.handleAgentOutputToolResult as jest.Mock).mockReturnValueOnce(completedSubagent); loadSubagentFinalResult.mockResolvedValueOnce('Recovered final result from sidecar'); await controller.handleStreamChunk( { type: 'tool_result', id: 'agent-out-2', content: 'agent result' }, msg ); expect(loadSubagentToolCalls).not.toHaveBeenCalled(); expect(loadSubagentFinalResult).toHaveBeenCalledWith( '/test/vault', 'session-1', 'agent-2' ); expect(completedSubagent.result).toBe('Recovered final result from sidecar'); expect(deps.subagentManager.refreshAsyncSubagent).toHaveBeenCalledWith(completedSubagent); }); it('does not retry async subagent final result hydration when sidecar matches current result', async () => { const { loadSubagentFinalResult, loadSubagentToolCalls } = jest.requireMock('@/utils/sdkSession'); const msg = createTestMessage(); deps.state.currentContentEl = createMockEl(); const completedSubagent = { id: 'task-2b', description: 'Background task', prompt: 'Do work', mode: 'async', status: 'completed', toolCalls: [ { id: 'existing-tool', name: 'Read', input: { file_path: 'notes.md' }, status: 'completed', result: 'existing', isExpanded: false, }, ], isExpanded: false, asyncStatus: 'completed', agentId: 'agent-2b', result: 'Already final', }; (deps.subagentManager.isLinkedAgentOutputTool as jest.Mock).mockReturnValueOnce(true); (deps.subagentManager.handleAgentOutputToolResult as jest.Mock).mockReturnValueOnce(completedSubagent); loadSubagentFinalResult.mockResolvedValueOnce('Already final'); await controller.handleStreamChunk( { type: 'tool_result', id: 'agent-out-2b', content: 'agent result' }, msg ); expect(loadSubagentToolCalls).not.toHaveBeenCalled(); expect(loadSubagentFinalResult).toHaveBeenCalledTimes(1); expect(deps.subagentManager.refreshAsyncSubagent).not.toHaveBeenCalled(); jest.advanceTimersByTime(3000); await Promise.resolve(); await Promise.resolve(); expect(loadSubagentFinalResult).toHaveBeenCalledTimes(1); }); it('retries async subagent final result hydration when first sidecar read is stale', async () => { const { loadSubagentFinalResult, loadSubagentToolCalls } = jest.requireMock('@/utils/sdkSession'); const msg = createTestMessage(); deps.state.currentContentEl = createMockEl(); const completedSubagent = { id: 'task-3', description: 'Background task', prompt: 'Do work', mode: 'async', status: 'completed', toolCalls: [ { id: 'existing-tool', name: 'Read', input: { file_path: 'notes.md' }, status: 'completed', result: 'existing', isExpanded: false, }, ], isExpanded: false, asyncStatus: 'completed', agentId: 'agent-3', result: 'Intermediate line', }; (deps.subagentManager.isLinkedAgentOutputTool as jest.Mock).mockReturnValueOnce(true); (deps.subagentManager.handleAgentOutputToolResult as jest.Mock).mockReturnValueOnce(completedSubagent); loadSubagentFinalResult .mockResolvedValueOnce(null) .mockResolvedValueOnce('Recovered final result after delayed flush'); await controller.handleStreamChunk( { type: 'tool_result', id: 'agent-out-3', content: 'agent result' }, msg ); expect(loadSubagentToolCalls).not.toHaveBeenCalled(); expect(loadSubagentFinalResult).toHaveBeenCalledTimes(1); expect(deps.subagentManager.refreshAsyncSubagent).not.toHaveBeenCalled(); jest.advanceTimersByTime(200); await Promise.resolve(); await Promise.resolve(); expect(loadSubagentFinalResult).toHaveBeenCalledTimes(2); expect(completedSubagent.result).toBe('Recovered final result after delayed flush'); expect(deps.subagentManager.refreshAsyncSubagent).toHaveBeenCalledWith(completedSubagent); }); }); describe('Tool header update on input re-dispatch', () => { it('second tool_use with same id updates existing tool input and header', async () => { const { getToolName, getToolSummary } = jest.requireMock('@/features/chat/rendering'); const msg = createTestMessage(); deps.state.currentContentEl = createMockEl(); // First tool_use - creates the tool call await controller.handleStreamChunk( { type: 'tool_use', id: 'read-1', name: 'Read', input: { file_path: 'test.md' } }, msg ); // Flush the tool so it transitions from pending to rendered await controller.handleStreamChunk({ type: 'done' }, msg); // Manually set up a rendered tool element with name + summary children // (the mock renderToolCall doesn't actually populate toolCallElements) const toolEl = createMockEl(); const nameChild = toolEl.createDiv({ cls: 'claudian-tool-name' }); nameChild.setText('Read'); const summaryChild = toolEl.createDiv({ cls: 'claudian-tool-summary' }); summaryChild.setText('test.md'); deps.state.toolCallElements.set('read-1', toolEl); getToolName.mockReturnValueOnce('Read'); getToolSummary.mockReturnValueOnce('updated.md'); // Second tool_use with same id - should update input and header await controller.handleStreamChunk( { type: 'tool_use', id: 'read-1', name: 'Read', input: { file_path: 'updated.md' } }, msg ); // Input should be merged expect(msg.toolCalls![0].input).toEqual( expect.objectContaining({ file_path: 'updated.md' }) ); // getToolName/getToolSummary should have been called with updated input expect(getToolName).toHaveBeenCalledWith('Read', expect.objectContaining({ file_path: 'updated.md' })); expect(getToolSummary).toHaveBeenCalledWith('Read', expect.objectContaining({ file_path: 'updated.md' })); // Header texts should be updated expect(nameChild.textContent).toBe('Read'); expect(summaryChild.textContent).toBe('updated.md'); }); }); describe('Sync subagent finalization', () => { it('tool_result for a sync subagent calls finalizeSyncSubagent and updates Task toolCall', async () => { const msg = createTestMessage(); deps.state.currentContentEl = createMockEl(); msg.toolCalls = [ { id: 'task-1', name: TOOL_TASK, input: { description: 'Do something' }, status: 'running', subagent: { id: 'task-1', description: 'Do something', status: 'running', toolCalls: [], isExpanded: false }, } as any, ]; // getSyncSubagent returns a subagent state (indicating this is a sync subagent) (deps.subagentManager.getSyncSubagent as jest.Mock).mockReturnValueOnce({ info: { id: 'task-1', description: 'Do something', status: 'running', toolCalls: [], isExpanded: false }, }); await controller.handleStreamChunk( { type: 'tool_result', id: 'task-1', content: 'Task completed successfully' }, msg ); expect(deps.subagentManager.finalizeSyncSubagent).toHaveBeenCalledWith( 'task-1', 'Task completed successfully', false, undefined ); expect(msg.toolCalls![0].status).toBe('completed'); expect(msg.toolCalls![0].result).toBe('Task completed successfully'); expect(msg.toolCalls![0].subagent?.status).toBe('completed'); expect(msg.toolCalls![0].subagent?.result).toBe('Task completed successfully'); }); }); describe('Async task tool result', () => { it('tool_result for a pending async task returns true from handleAsyncTaskToolResult', async () => { const { updateToolCallResult } = jest.requireMock('@/features/chat/rendering'); const msg = createTestMessage(); deps.state.currentContentEl = createMockEl(); (deps.subagentManager.isPendingAsyncTask as jest.Mock).mockReturnValueOnce(true); await controller.handleStreamChunk( { type: 'tool_result', id: 'task-1', content: 'Task started in background' }, msg ); expect(deps.subagentManager.handleTaskToolResult).toHaveBeenCalledWith( 'task-1', 'Task started in background', undefined, undefined ); expect(updateToolCallResult).not.toHaveBeenCalled(); expect(msg.toolCalls).toEqual([]); }); it('passes structured toolUseResult through to async Task result handler', async () => { const msg = createTestMessage(); deps.state.currentContentEl = createMockEl(); (deps.subagentManager.isPendingAsyncTask as jest.Mock).mockReturnValueOnce(true); const structured = { data: { agent_id: 'agent-from-structured' } }; await controller.handleStreamChunk( { type: 'tool_result', id: 'task-1', content: 'Task started', toolUseResult: structured } as any, msg ); expect(deps.subagentManager.handleTaskToolResult).toHaveBeenCalledWith( 'task-1', 'Task started', undefined, structured ); }); }); describe('showThinkingIndicator - timer disconnection cleanup', () => { it('should clear interval when timerSpan becomes disconnected from DOM', () => { // Use a non-zero value: with fake timers, performance.now() starts at 0, // and !0 is truthy which would cause updateTimer to return early. jest.advanceTimersByTime(1); deps.state.responseStartTime = performance.now(); controller.showThinkingIndicator(); jest.advanceTimersByTime(500); // Past debounce delay expect(deps.state.flavorTimerInterval).not.toBeNull(); const thinkingEl = deps.state.thinkingEl; expect(thinkingEl).not.toBeNull(); // The timer span is the second child (first is flavor text, second is hint) const timerSpan = thinkingEl!.children[1]; expect(timerSpan).toBeDefined(); // Mock elements don't have isConnected by default (undefined = falsy), // so first set it to true so the timer runs normally on its first tick. Object.defineProperty(timerSpan, 'isConnected', { value: true, writable: true, configurable: true }); // Advance time - interval should still run (isConnected is true) jest.advanceTimersByTime(1000); expect(deps.state.flavorTimerInterval).not.toBeNull(); // Verify the interval callback actually ran by checking the timer text was updated expect((timerSpan as any).textContent).toContain('esc to interrupt'); // Now simulate disconnection from DOM (timerSpan as any).isConnected = false; // Advance time to trigger the interval callback jest.advanceTimersByTime(1000); // Interval should have been cleared because isConnected is false expect(deps.state.flavorTimerInterval).toBeNull(); }); }); describe('showThinkingIndicator - pre-existing interval', () => { it('should clear pre-existing interval before creating new one', () => { // Advance fake clock so performance.now() returns non-zero jest.advanceTimersByTime(1); deps.state.responseStartTime = performance.now(); const clearIntervalSpy = jest.spyOn(global, 'clearInterval'); // Manually set a pre-existing interval deps.state.flavorTimerInterval = setInterval(() => {}, 9999) as unknown as ReturnType<typeof setInterval>; controller.showThinkingIndicator(); jest.advanceTimersByTime(500); // clearInterval should have been called for the pre-existing interval expect(clearIntervalSpy).toHaveBeenCalled(); // A new interval should have been created expect(deps.state.flavorTimerInterval).not.toBeNull(); clearIntervalSpy.mockRestore(); }); }); describe('appendThinking - no currentContentEl', () => { it('should not create thinking state when currentContentEl is null', async () => { const msg = createTestMessage(); deps.state.currentContentEl = null; await controller.handleStreamChunk({ type: 'thinking', content: 'test thinking' }, msg); // No thinking state should be created expect(deps.state.currentThinkingState).toBeNull(); }); }); describe('showThinkingIndicator - responseStartTime null in timer', () => { it('should not update timer text when responseStartTime is null', () => { // Advance fake clock so performance.now() returns non-zero jest.advanceTimersByTime(1); deps.state.responseStartTime = performance.now(); controller.showThinkingIndicator(); jest.advanceTimersByTime(500); expect(deps.state.thinkingEl).not.toBeNull(); // Get timerSpan and set isConnected to true for proper timer operation const timerSpan = deps.state.thinkingEl!.children[1]; Object.defineProperty(timerSpan, 'isConnected', { value: true, configurable: true }); // Clear responseStartTime to trigger early return in updateTimer deps.state.responseStartTime = null; // Advance time to trigger timer callback - should not throw jest.advanceTimersByTime(1000); // Timer should still be set (interval not cleared by the null check) expect(deps.state.flavorTimerInterval).not.toBeNull(); }); }); }); describe('StreamController - Plan Mode', () => { let controller: StreamController; let deps: StreamControllerDeps; beforeEach(() => { jest.clearAllMocks(); jest.useFakeTimers(); deps = createMockDeps(); controller = new StreamController(deps); deps.state.currentContentEl = createMockEl(); }); afterEach(() => { deps.state.resetStreamingState(); jest.useRealTimers(); }); describe('capturePlanFilePath', () => { it('should capture plan file path from Write tool_use', async () => { const msg = createTestMessage(); await controller.handleStreamChunk( { type: 'tool_use', id: 'write-1', name: 'Write', input: { file_path: '/home/user/.claude/plans/plan.md' } }, msg ); expect(deps.state.planFilePath).toBe('/home/user/.claude/plans/plan.md'); }); it('should capture plan file path with Windows backslashes', async () => { const msg = createTestMessage(); await controller.handleStreamChunk( { type: 'tool_use', id: 'write-1', name: 'Write', input: { file_path: 'C:\\.claude\\plans\\plan.md' } }, msg ); expect(deps.state.planFilePath).toBe('C:\\.claude\\plans\\plan.md'); }); it('should not capture non-plan Write paths', async () => { const msg = createTestMessage(); await controller.handleStreamChunk( { type: 'tool_use', id: 'write-1', name: 'Write', input: { file_path: '/home/user/notes/todo.md' } }, msg ); expect(deps.state.planFilePath).toBeNull(); }); it('should not capture plan path from non-Write tools', async () => { const msg = createTestMessage(); await controller.handleStreamChunk( { type: 'tool_use', id: 'read-1', name: 'Read', input: { file_path: '/home/user/.claude/plans/plan.md' } }, msg ); expect(deps.state.planFilePath).toBeNull(); }); it('should capture plan file path on subsequent tool_use input update', async () => { const msg = createTestMessage(); msg.toolCalls = [{ id: 'write-1', name: 'Write', input: { content: 'plan content' }, status: 'running', }]; // Second tool_use chunk with same ID updates the input (file_path arrives later) await controller.handleStreamChunk( { type: 'tool_use', id: 'write-1', name: 'Write', input: { file_path: '/home/user/.claude/plans/plan.md' } }, msg ); expect(deps.state.planFilePath).toBe('/home/user/.claude/plans/plan.md'); }); }); describe('blocked detection bypass', () => { it('should hydrate AskUserQuestion resolvedAnswers from result text fallback', async () => { const coreTools = jest.requireMock('@/core/tools'); (coreTools.extractResolvedAnswers as jest.Mock).mockReturnValueOnce(undefined); (coreTools.extractResolvedAnswersFromResultText as jest.Mock).mockReturnValueOnce({ 'Color?': 'Blue', }); const msg = createTestMessage(); msg.toolCalls = [{ id: 'ask-1', name: 'AskUserQuestion', input: { questions: [{ question: 'Color?' }] }, status: 'running', }]; await controller.handleStreamChunk( { type: 'tool_result', id: 'ask-1', content: '"Color?"="Blue"' }, msg ); expect(msg.toolCalls![0].resolvedAnswers).toEqual({ 'Color?': 'Blue' }); }); it('should not mark AskUserQuestion as blocked even when result looks blocked', async () => { const { isBlockedToolResult } = jest.requireMock('@/features/chat/rendering'); (isBlockedToolResult as jest.Mock).mockReturnValueOnce(true); const msg = createTestMessage(); msg.toolCalls = [{ id: 'ask-1', name: 'AskUserQuestion', input: {}, status: 'running', }]; await controller.handleStreamChunk( { type: 'tool_result', id: 'ask-1', content: 'User denied this action.' }, msg ); expect(msg.toolCalls![0].status).toBe('completed'); }); it('should not mark ExitPlanMode as blocked even when result looks blocked', async () => { const { isBlockedToolResult } = jest.requireMock('@/features/chat/rendering'); (isBlockedToolResult as jest.Mock).mockReturnValueOnce(true); const msg = createTestMessage(); msg.toolCalls = [{ id: 'exit-1', name: 'ExitPlanMode', input: {}, status: 'running', }]; await controller.handleStreamChunk( { type: 'tool_result', id: 'exit-1', content: 'User denied.' }, msg ); expect(msg.toolCalls![0].status).toBe('completed'); }); it('should mark regular tool as blocked when result is blocked', async () => { const { isBlockedToolResult } = jest.requireMock('@/features/chat/rendering'); (isBlockedToolResult as jest.Mock).mockReturnValueOnce(true); const msg = createTestMessage(); msg.toolCalls = [{ id: 'bash-1', name: 'Bash', input: { command: 'rm -rf /' }, status: 'running', }]; await controller.handleStreamChunk( { type: 'tool_result', id: 'bash-1', content: 'Command blocked by security policy' }, msg ); expect(msg.toolCalls![0].status).toBe('blocked'); }); }); }); ================================================ FILE: tests/unit/features/chat/controllers/contextRowVisibility.test.ts ================================================ import { updateContextRowHasContent } from '@/features/chat/controllers/contextRowVisibility'; function createContextRow(browserIndicator: HTMLElement | null): HTMLElement { const editorIndicator = { style: { display: 'none' } }; const canvasIndicator = { style: { display: 'none' } }; const fileIndicator = { style: { display: 'none' } }; const imagePreview = { style: { display: 'none' } }; const lookup = new Map<string, unknown>([ ['.claudian-selection-indicator', editorIndicator], ['.claudian-browser-selection-indicator', browserIndicator], ['.claudian-canvas-indicator', canvasIndicator], ['.claudian-file-indicator', fileIndicator], ['.claudian-image-preview', imagePreview], ]); return { classList: { toggle: jest.fn(), }, querySelector: jest.fn((selector: string) => lookup.get(selector) ?? null), } as unknown as HTMLElement; } describe('updateContextRowHasContent', () => { it('does not treat missing browser indicator as visible content', () => { const contextRowEl = createContextRow(null); expect(() => updateContextRowHasContent(contextRowEl)).not.toThrow(); expect((contextRowEl.classList.toggle as jest.Mock)).toHaveBeenCalledWith('has-content', false); }); it('treats browser indicator as visible only when display is block', () => { const browserIndicator = { style: { display: 'block' } } as unknown as HTMLElement; const contextRowEl = createContextRow(browserIndicator); updateContextRowHasContent(contextRowEl); expect((contextRowEl.classList.toggle as jest.Mock)).toHaveBeenCalledWith('has-content', true); }); }); ================================================ FILE: tests/unit/features/chat/controllers/index.test.ts ================================================ import { ConversationController, InputController, NavigationController, SelectionController, StreamController, } from '@/features/chat/controllers'; describe('features/chat/controllers index', () => { it('re-exports runtime symbols', () => { expect(ConversationController).toBeDefined(); expect(InputController).toBeDefined(); expect(NavigationController).toBeDefined(); expect(SelectionController).toBeDefined(); expect(StreamController).toBeDefined(); }); }); ================================================ FILE: tests/unit/features/chat/rendering/DiffRenderer.test.ts ================================================ import { createMockEl } from '@test/helpers/mockElement'; import type { DiffLine, StructuredPatchHunk } from '@/core/types/diff'; import { renderDiffContent, splitIntoHunks } from '@/features/chat/rendering/DiffRenderer'; import { countLineChanges, structuredPatchToDiffLines } from '@/utils/diff'; /** Recursively count elements matching a class. */ function countByClass(el: any, cls: string): number { let count = el.hasClass(cls) ? 1 : 0; for (const child of el._children) count += countByClass(child, cls); return count; } /** Generate N insert DiffLines. */ function makeInsertLines(n: number): DiffLine[] { return Array.from({ length: n }, (_, i) => ({ type: 'insert' as const, text: `line ${i + 1}`, newLineNum: i + 1, })); } describe('DiffRenderer', () => { describe('structuredPatchToDiffLines', () => { it('should return empty array for empty hunks', () => { const result = structuredPatchToDiffLines([]); expect(result).toEqual([]); }); it('should convert a simple insertion hunk', () => { const hunks: StructuredPatchHunk[] = [{ oldStart: 1, oldLines: 2, newStart: 1, newLines: 3, lines: [' line1', '+inserted', ' line2'], }]; const result = structuredPatchToDiffLines(hunks); expect(result).toHaveLength(3); expect(result[0]).toEqual({ type: 'equal', text: 'line1', oldLineNum: 1, newLineNum: 1 }); expect(result[1]).toEqual({ type: 'insert', text: 'inserted', newLineNum: 2 }); expect(result[2]).toEqual({ type: 'equal', text: 'line2', oldLineNum: 2, newLineNum: 3 }); }); it('should convert a simple deletion hunk', () => { const hunks: StructuredPatchHunk[] = [{ oldStart: 1, oldLines: 3, newStart: 1, newLines: 2, lines: [' line1', '-deleted', ' line2'], }]; const result = structuredPatchToDiffLines(hunks); expect(result).toHaveLength(3); expect(result[0]).toEqual({ type: 'equal', text: 'line1', oldLineNum: 1, newLineNum: 1 }); expect(result[1]).toEqual({ type: 'delete', text: 'deleted', oldLineNum: 2 }); expect(result[2]).toEqual({ type: 'equal', text: 'line2', oldLineNum: 3, newLineNum: 2 }); }); it('should convert a replacement (delete + insert)', () => { const hunks: StructuredPatchHunk[] = [{ oldStart: 1, oldLines: 3, newStart: 1, newLines: 3, lines: [' line1', '-old', '+new', ' line3'], }]; const result = structuredPatchToDiffLines(hunks); expect(result).toHaveLength(4); expect(result[0]).toEqual({ type: 'equal', text: 'line1', oldLineNum: 1, newLineNum: 1 }); expect(result[1]).toEqual({ type: 'delete', text: 'old', oldLineNum: 2 }); expect(result[2]).toEqual({ type: 'insert', text: 'new', newLineNum: 2 }); expect(result[3]).toEqual({ type: 'equal', text: 'line3', oldLineNum: 3, newLineNum: 3 }); }); it('should handle multiple hunks', () => { const hunks: StructuredPatchHunk[] = [ { oldStart: 1, oldLines: 2, newStart: 1, newLines: 2, lines: [' ctx', '-old1', '+new1'], }, { oldStart: 10, oldLines: 2, newStart: 10, newLines: 2, lines: [' ctx2', '-old2', '+new2'], }, ]; const result = structuredPatchToDiffLines(hunks); expect(result).toHaveLength(6); // First hunk expect(result[0]).toEqual({ type: 'equal', text: 'ctx', oldLineNum: 1, newLineNum: 1 }); expect(result[1]).toEqual({ type: 'delete', text: 'old1', oldLineNum: 2 }); expect(result[2]).toEqual({ type: 'insert', text: 'new1', newLineNum: 2 }); // Second hunk expect(result[3]).toEqual({ type: 'equal', text: 'ctx2', oldLineNum: 10, newLineNum: 10 }); expect(result[4]).toEqual({ type: 'delete', text: 'old2', oldLineNum: 11 }); expect(result[5]).toEqual({ type: 'insert', text: 'new2', newLineNum: 11 }); }); it('should handle hunk with only insertions (new file)', () => { const hunks: StructuredPatchHunk[] = [{ oldStart: 0, oldLines: 0, newStart: 1, newLines: 3, lines: ['+line1', '+line2', '+line3'], }]; const result = structuredPatchToDiffLines(hunks); expect(result).toHaveLength(3); expect(result.every(l => l.type === 'insert')).toBe(true); expect(result[0]).toEqual({ type: 'insert', text: 'line1', newLineNum: 1 }); expect(result[1]).toEqual({ type: 'insert', text: 'line2', newLineNum: 2 }); expect(result[2]).toEqual({ type: 'insert', text: 'line3', newLineNum: 3 }); }); it('should handle lines with special characters', () => { const hunks: StructuredPatchHunk[] = [{ oldStart: 1, oldLines: 1, newStart: 1, newLines: 1, lines: ['-return "bar";', '+return `bar`;'], }]; const result = structuredPatchToDiffLines(hunks); expect(result[0].text).toBe('return "bar";'); expect(result[1].text).toBe('return `bar`;'); }); it('should handle unicode content', () => { const hunks: StructuredPatchHunk[] = [{ oldStart: 1, oldLines: 1, newStart: 1, newLines: 1, lines: ['-こんにちは', '+さようなら'], }]; const result = structuredPatchToDiffLines(hunks); expect(result[0]).toEqual({ type: 'delete', text: 'こんにちは', oldLineNum: 1 }); expect(result[1]).toEqual({ type: 'insert', text: 'さようなら', newLineNum: 1 }); }); it('should track line numbers correctly across mixed operations', () => { const hunks: StructuredPatchHunk[] = [{ oldStart: 5, oldLines: 4, newStart: 5, newLines: 5, lines: [' ctx', '-del1', '-del2', '+ins1', '+ins2', '+ins3', ' ctx2'], }]; const result = structuredPatchToDiffLines(hunks); // Context: oldLine=5, newLine=5 expect(result[0]).toEqual({ type: 'equal', text: 'ctx', oldLineNum: 5, newLineNum: 5 }); // Deletes: oldLine 6,7 expect(result[1]).toEqual({ type: 'delete', text: 'del1', oldLineNum: 6 }); expect(result[2]).toEqual({ type: 'delete', text: 'del2', oldLineNum: 7 }); // Inserts: newLine 6,7,8 expect(result[3]).toEqual({ type: 'insert', text: 'ins1', newLineNum: 6 }); expect(result[4]).toEqual({ type: 'insert', text: 'ins2', newLineNum: 7 }); expect(result[5]).toEqual({ type: 'insert', text: 'ins3', newLineNum: 8 }); // Context: oldLine=8, newLine=9 expect(result[6]).toEqual({ type: 'equal', text: 'ctx2', oldLineNum: 8, newLineNum: 9 }); }); }); describe('countLineChanges', () => { it('should return zeros for no changes', () => { const diffLines: DiffLine[] = [ { type: 'equal', text: 'line1', oldLineNum: 1, newLineNum: 1 }, { type: 'equal', text: 'line2', oldLineNum: 2, newLineNum: 2 }, ]; const stats = countLineChanges(diffLines); expect(stats).toEqual({ added: 0, removed: 0 }); }); it('should count inserted lines', () => { const diffLines: DiffLine[] = [ { type: 'equal', text: 'line1', oldLineNum: 1, newLineNum: 1 }, { type: 'insert', text: 'new1', newLineNum: 2 }, { type: 'insert', text: 'new2', newLineNum: 3 }, { type: 'equal', text: 'line2', oldLineNum: 2, newLineNum: 4 }, ]; const stats = countLineChanges(diffLines); expect(stats).toEqual({ added: 2, removed: 0 }); }); it('should count deleted lines', () => { const diffLines: DiffLine[] = [ { type: 'equal', text: 'line1', oldLineNum: 1, newLineNum: 1 }, { type: 'delete', text: 'old1', oldLineNum: 2 }, { type: 'delete', text: 'old2', oldLineNum: 3 }, { type: 'equal', text: 'line2', oldLineNum: 4, newLineNum: 2 }, ]; const stats = countLineChanges(diffLines); expect(stats).toEqual({ added: 0, removed: 2 }); }); it('should count both insertions and deletions', () => { const diffLines: DiffLine[] = [ { type: 'delete', text: 'old', oldLineNum: 1 }, { type: 'insert', text: 'new1', newLineNum: 1 }, { type: 'insert', text: 'new2', newLineNum: 2 }, ]; const stats = countLineChanges(diffLines); expect(stats).toEqual({ added: 2, removed: 1 }); }); it('should return zeros for empty array', () => { const stats = countLineChanges([]); expect(stats).toEqual({ added: 0, removed: 0 }); }); }); describe('splitIntoHunks', () => { it('should return empty array for no changes', () => { const diffLines: DiffLine[] = [ { type: 'equal', text: 'line1', oldLineNum: 1, newLineNum: 1 }, { type: 'equal', text: 'line2', oldLineNum: 2, newLineNum: 2 }, ]; const hunks = splitIntoHunks(diffLines); expect(hunks).toEqual([]); }); it('should return empty array for empty diff', () => { const hunks = splitIntoHunks([]); expect(hunks).toEqual([]); }); it('should create single hunk for adjacent changes', () => { const diffLines: DiffLine[] = [ { type: 'equal', text: 'line1', oldLineNum: 1, newLineNum: 1 }, { type: 'delete', text: 'old', oldLineNum: 2 }, { type: 'insert', text: 'new', newLineNum: 2 }, { type: 'equal', text: 'line2', oldLineNum: 3, newLineNum: 3 }, ]; const hunks = splitIntoHunks(diffLines, 3); expect(hunks).toHaveLength(1); expect(hunks[0].lines).toHaveLength(4); }); it('should include context lines around changes', () => { const lines: DiffLine[] = []; // 10 equal lines, then 1 change, then 10 equal lines for (let i = 1; i <= 10; i++) { lines.push({ type: 'equal', text: `line${i}`, oldLineNum: i, newLineNum: i }); } lines.push({ type: 'insert', text: 'inserted', newLineNum: 11 }); for (let i = 11; i <= 20; i++) { lines.push({ type: 'equal', text: `line${i}`, oldLineNum: i, newLineNum: i + 1 }); } const hunks = splitIntoHunks(lines, 3); expect(hunks).toHaveLength(1); // Should include 3 context lines before, 1 change, 3 context lines after = 7 lines expect(hunks[0].lines.length).toBe(7); }); it('should create multiple hunks for distant changes', () => { const lines: DiffLine[] = []; // 10 equal lines for (let i = 1; i <= 10; i++) { lines.push({ type: 'equal', text: `line${i}`, oldLineNum: i, newLineNum: i }); } // 1 change lines.push({ type: 'insert', text: 'change1', newLineNum: 11 }); // 20 equal lines (more than 2*context, so hunks will be separate) for (let i = 11; i <= 30; i++) { lines.push({ type: 'equal', text: `line${i}`, oldLineNum: i, newLineNum: i + 1 }); } // Another change lines.push({ type: 'insert', text: 'change2', newLineNum: 32 }); // 10 more equal lines for (let i = 31; i <= 40; i++) { lines.push({ type: 'equal', text: `line${i}`, oldLineNum: i, newLineNum: i + 2 }); } const hunks = splitIntoHunks(lines, 3); expect(hunks).toHaveLength(2); }); it('should merge overlapping context regions into single hunk', () => { const lines: DiffLine[] = []; // 3 equal lines for (let i = 1; i <= 3; i++) { lines.push({ type: 'equal', text: `line${i}`, oldLineNum: i, newLineNum: i }); } // Change 1 lines.push({ type: 'insert', text: 'change1', newLineNum: 4 }); // 4 equal lines (less than 2*3=6, so contexts overlap) for (let i = 4; i <= 7; i++) { lines.push({ type: 'equal', text: `line${i}`, oldLineNum: i, newLineNum: i + 1 }); } // Change 2 lines.push({ type: 'insert', text: 'change2', newLineNum: 9 }); // 3 equal lines for (let i = 8; i <= 10; i++) { lines.push({ type: 'equal', text: `line${i}`, oldLineNum: i, newLineNum: i + 2 }); } const hunks = splitIntoHunks(lines, 3); // Should merge into single hunk since context regions overlap expect(hunks).toHaveLength(1); }); it('should calculate correct starting line numbers for hunks', () => { const lines: DiffLine[] = [ { type: 'equal', text: 'line1', oldLineNum: 1, newLineNum: 1 }, { type: 'equal', text: 'line2', oldLineNum: 2, newLineNum: 2 }, { type: 'equal', text: 'line3', oldLineNum: 3, newLineNum: 3 }, { type: 'delete', text: 'old', oldLineNum: 4 }, { type: 'insert', text: 'new', newLineNum: 4 }, { type: 'equal', text: 'line5', oldLineNum: 5, newLineNum: 5 }, ]; const hunks = splitIntoHunks(lines, 2); expect(hunks).toHaveLength(1); expect(hunks[0].oldStart).toBe(2); // Context starts at line 2 expect(hunks[0].newStart).toBe(2); }); }); describe('renderDiffContent', () => { it('should render all lines when all-inserts count is within cap', () => { const container = createMockEl(); const lines = makeInsertLines(20); renderDiffContent(container, lines); // All 20 insert lines rendered, no separator expect(countByClass(container, 'claudian-diff-insert')).toBe(20); expect(countByClass(container, 'claudian-diff-separator')).toBe(0); }); it('should cap all-inserts diff at 20 lines with remainder message', () => { const container = createMockEl(); const lines = makeInsertLines(100); renderDiffContent(container, lines); // Only 20 insert lines rendered expect(countByClass(container, 'claudian-diff-insert')).toBe(20); // Separator shows remaining count const separator = container._children.find( (c: any) => c.hasClass('claudian-diff-separator'), ); expect(separator).toBeDefined(); expect(separator.textContent).toBe('... 80 more lines'); }); it('should not cap mixed diff lines (edits with context)', () => { const container = createMockEl(); // Build a diff with equal + insert lines — not all-inserts const lines: DiffLine[] = [ { type: 'equal', text: 'ctx', oldLineNum: 1, newLineNum: 1 }, ...makeInsertLines(30), ]; renderDiffContent(container, lines); // All 30 insert lines rendered (not capped because not all-inserts) expect(countByClass(container, 'claudian-diff-insert')).toBe(30); }); }); }); ================================================ FILE: tests/unit/features/chat/rendering/InlineAskUserQuestion.test.ts ================================================ import { createMockEl } from '@test/helpers/mockElement'; import { type InlineAskQuestionConfig, InlineAskUserQuestion } from '@/features/chat/rendering/InlineAskUserQuestion'; beforeAll(() => { globalThis.requestAnimationFrame = (cb: FrameRequestCallback) => { cb(0); return 0; }; // Mock document.activeElement for focus checks in updateFocusIndicator (globalThis as any).document = { activeElement: null }; }); function makeInput( questions: Array<{ question: string; options: unknown[]; multiSelect?: boolean; header?: string; }>, ): Record<string, unknown> { return { questions }; } function renderWidget( input: Record<string, unknown>, signal?: AbortSignal, ): { container: any; resolve: jest.Mock; widget: InlineAskUserQuestion } { const container = createMockEl(); const resolve = jest.fn(); const widget = new InlineAskUserQuestion(container, input, resolve, signal); widget.render(); return { container, resolve, widget }; } function fireKeyDown( root: any, key: string, opts: { shiftKey?: boolean } = {}, ): void { const event = { type: 'keydown', key, shiftKey: opts.shiftKey ?? false, preventDefault: jest.fn(), stopPropagation: jest.fn(), }; root.dispatchEvent(event); } function findRoot(container: any): any { return container.querySelector('.claudian-ask-question-inline'); } function findItems(container: any): any[] { return container.querySelectorAll('claudian-ask-item'); } describe('InlineAskUserQuestion', () => { describe('parseQuestions', () => { it('resolves null when input has no questions', () => { const { resolve } = renderWidget({}); expect(resolve).toHaveBeenCalledWith(null); }); it('resolves null when questions is not an array', () => { const { resolve } = renderWidget({ questions: 'bad' }); expect(resolve).toHaveBeenCalledWith(null); }); it('resolves null when questions array is empty', () => { const { resolve } = renderWidget({ questions: [] }); expect(resolve).toHaveBeenCalledWith(null); }); it('filters out questions with no options', () => { const input = makeInput([ { question: 'Q1', options: [] }, { question: 'Q2', options: ['A'] }, ]); const { resolve } = renderWidget(input); // Should render — Q2 is valid expect(resolve).not.toHaveBeenCalled(); }); it('resolves null when all questions have empty options', () => { const input = makeInput([ { question: 'Q1', options: [] }, { question: 'Q2', options: [] }, ]); const { resolve } = renderWidget(input); expect(resolve).toHaveBeenCalledWith(null); }); it('filters out entries missing required fields', () => { const input = { questions: [ { question: 'Valid', options: ['A'] }, { options: ['B'] }, // missing question 'not an object', null, ], }; const { resolve } = renderWidget(input); // Only "Valid" survives — widget should render expect(resolve).not.toHaveBeenCalled(); }); it('deduplicates options with the same label', () => { const input = makeInput([ { question: 'Pick', options: ['A', 'A', 'B'] }, ]); const { container } = renderWidget(input); // Find option items (excluding custom input row) const items = container.querySelectorAll('claudian-ask-item'); // 2 unique options + 1 custom input row = 3 const optionLabels = items .filter((item: any) => !item.hasClass('claudian-ask-custom-item')) .map((item: any) => { const labelEl = item.querySelector('claudian-ask-item-label'); return labelEl?.textContent; }); expect(optionLabels).toEqual(['A', 'B']); }); it('uses header when provided, falls back to Q index', () => { const input = makeInput([ { question: 'First', options: ['A'], header: 'MyHeader' }, { question: 'Second', options: ['B'] }, ]); const { container } = renderWidget(input); const tabLabels = container.querySelectorAll('claudian-ask-tab-label'); // Tab labels: MyHeader, Q2, Submit expect(tabLabels[0]?.textContent).toBe('MyHeader'); expect(tabLabels[1]?.textContent).toBe('Q2'); }); it('treats non-boolean multiSelect values as false', () => { const input = { questions: [ { question: 'Pick one', options: ['A', 'B'], multiSelect: 'false' }, ], }; const { container } = renderWidget(input); const items = findItems(container).filter( (i: any) => !i.hasClass('claudian-ask-custom-item'), ); items[0]?.click(); expect(container.querySelector('claudian-ask-review-title')?.textContent).toBe('Review your answers'); }); it('truncates header to 12 characters', () => { const input = makeInput([ { question: 'Q', options: ['A'], header: 'VeryLongHeaderText' }, ]); const { container } = renderWidget(input); const tabLabels = container.querySelectorAll('claudian-ask-tab-label'); expect(tabLabels[0]?.textContent).toBe('VeryLongHead'); }); }); describe('coerceOption / extractLabel', () => { it('handles string options', () => { const input = makeInput([{ question: 'Q', options: ['Yes', 'No'] }]); const { container } = renderWidget(input); const labels = container .querySelectorAll('claudian-ask-item-label') .map((el: any) => el.textContent); expect(labels).toContain('Yes'); expect(labels).toContain('No'); }); it('extracts label from object with label property', () => { const input = makeInput([ { question: 'Q', options: [ { label: 'Option A', description: 'desc A' }, { value: 'Option B' }, { text: 'Option C' }, { name: 'Option D' }, ], }, ]); const { container } = renderWidget(input); const labels = container .querySelectorAll('claudian-ask-item-label') .map((el: any) => el.textContent); expect(labels).toContain('Option A'); expect(labels).toContain('Option B'); expect(labels).toContain('Option C'); expect(labels).toContain('Option D'); }); it('shows description when provided', () => { const input = makeInput([ { question: 'Q', options: [{ label: 'A', description: 'Some desc' }] }, ]); const { container } = renderWidget(input); const descEl = container.querySelector('claudian-ask-item-desc'); expect(descEl?.textContent).toBe('Some desc'); }); it('coerces non-string/non-object options to string', () => { const input = makeInput([{ question: 'Q', options: [42] }]); const { container } = renderWidget(input); const labels = container .querySelectorAll('claudian-ask-item-label') .map((el: any) => el.textContent); expect(labels).toContain('42'); }); }); describe('selectOption', () => { it('selects single-select option via click', () => { jest.useFakeTimers(); const input = makeInput([ { question: 'Pick one', options: ['A', 'B'] }, ]); const { container, resolve } = renderWidget(input); // Click first option const items = findItems(container).filter( (i: any) => !i.hasClass('claudian-ask-custom-item'), ); items[0]?.click(); jest.advanceTimersByTime(200); // Auto-advanced to submit tab — now submit const submitItems = container.querySelectorAll('claudian-ask-item'); const submitRow = submitItems.find( (i: any) => !i.hasClass('claudian-ask-custom-item'), ); submitRow?.click(); expect(resolve).toHaveBeenCalledWith({ 'Pick one': 'A' }); jest.useRealTimers(); }); it('toggles multi-select options', () => { const input = makeInput([ { question: 'Pick many', options: ['X', 'Y', 'Z'], multiSelect: true }, ]); const { container } = renderWidget(input); const items = findItems(container).filter( (i: any) => !i.hasClass('claudian-ask-custom-item'), ); // Select X and Y items[0]?.click(); items[1]?.click(); // Check marks for multi-select const checks = container.querySelectorAll('claudian-ask-check'); const checkedCount = checks.filter((c: any) => c.hasClass('is-checked')).length; expect(checkedCount).toBe(2); // Deselect X items[0]?.click(); const checksAfter = container.querySelectorAll('claudian-ask-check'); const checkedAfter = checksAfter.filter((c: any) => c.hasClass('is-checked')).length; expect(checkedAfter).toBe(1); }); }); describe('handleSubmit', () => { it('does not submit when not all questions are answered', () => { const input = makeInput([ { question: 'Q1', options: ['A'] }, { question: 'Q2', options: ['B'] }, ]); const { container, resolve } = renderWidget(input); // Navigate to submit tab without answering const root = findRoot(container); fireKeyDown(root, 'Tab'); // Try to submit fireKeyDown(root, 'Enter'); // Should navigate to submit tab first, not resolve // Eventually press Enter on submit tab fireKeyDown(root, 'Tab'); fireKeyDown(root, 'Enter'); // Still not submitted because not all answered expect(resolve).not.toHaveBeenCalled(); }); it('submits answers with correct question-answer mapping', () => { jest.useFakeTimers(); const input = makeInput([ { question: 'Color?', options: ['Red', 'Blue'] }, { question: 'Size?', options: ['S', 'M', 'L'] }, ]); const { container, resolve } = renderWidget(input); // Select "Red" for Q1 const items = findItems(container).filter( (i: any) => !i.hasClass('claudian-ask-custom-item'), ); items[0]?.click(); jest.advanceTimersByTime(200); // Now on Q2 — select "M" (index 1) const q2Items = findItems(container).filter( (i: any) => !i.hasClass('claudian-ask-custom-item'), ); q2Items[1]?.click(); jest.advanceTimersByTime(200); // Now on submit tab — click submit const submitItems = container.querySelectorAll('claudian-ask-item'); const submitRow = submitItems.find( (i: any) => !i.hasClass('claudian-ask-custom-item'), ); submitRow?.click(); expect(resolve).toHaveBeenCalledWith({ 'Color?': 'Red', 'Size?': 'M', }); jest.useRealTimers(); }); }); describe('abort lifecycle', () => { it('resolves null when signal is aborted', () => { const controller = new AbortController(); const input = makeInput([{ question: 'Q', options: ['A'] }]); const { resolve } = renderWidget(input, controller.signal); expect(resolve).not.toHaveBeenCalled(); controller.abort(); expect(resolve).toHaveBeenCalledWith(null); }); it('does not double-resolve on abort after manual resolve', () => { const controller = new AbortController(); const input = makeInput([{ question: 'Q', options: ['A'] }]); const { container, resolve } = renderWidget(input, controller.signal); // Cancel via Escape const root = findRoot(container); fireKeyDown(root, 'Escape'); expect(resolve).toHaveBeenCalledTimes(1); // Abort should not trigger a second resolve controller.abort(); expect(resolve).toHaveBeenCalledTimes(1); }); it('cleans up abort listener on resolve', () => { const controller = new AbortController(); const input = makeInput([{ question: 'Q', options: ['A'] }]); const { container, resolve } = renderWidget(input, controller.signal); // Cancel via Escape const root = findRoot(container); fireKeyDown(root, 'Escape'); expect(resolve).toHaveBeenCalledTimes(1); expect(resolve).toHaveBeenCalledWith(null); }); }); describe('destroy', () => { it('resolves null on destroy', () => { const input = makeInput([{ question: 'Q', options: ['A'] }]); const { resolve, widget } = renderWidget(input); widget.destroy(); expect(resolve).toHaveBeenCalledWith(null); }); it('does not double-resolve if already resolved', () => { const input = makeInput([{ question: 'Q', options: ['A'] }]); const { container, resolve, widget } = renderWidget(input); const root = findRoot(container); fireKeyDown(root, 'Escape'); expect(resolve).toHaveBeenCalledTimes(1); widget.destroy(); expect(resolve).toHaveBeenCalledTimes(1); }); }); describe('keyboard navigation', () => { it('Escape resolves null', () => { const input = makeInput([{ question: 'Q', options: ['A', 'B'] }]); const { container, resolve } = renderWidget(input); const root = findRoot(container); fireKeyDown(root, 'Escape'); expect(resolve).toHaveBeenCalledWith(null); }); it('ArrowDown moves focus down', () => { const input = makeInput([{ question: 'Q', options: ['A', 'B'] }]); const { container } = renderWidget(input); const root = findRoot(container); // Initially focused on item 0 fireKeyDown(root, 'ArrowDown'); const items = findItems(container); // Item 1 should now be focused expect(items[1]?.hasClass('is-focused')).toBe(true); }); it('ArrowDown clamps at max index', () => { const input = makeInput([{ question: 'Q', options: ['A'] }]); const { container } = renderWidget(input); const root = findRoot(container); // Press ArrowDown many times fireKeyDown(root, 'ArrowDown'); fireKeyDown(root, 'ArrowDown'); fireKeyDown(root, 'ArrowDown'); // Should not crash, max focus is 1 (option A + custom input) const items = findItems(container); // Last item (custom input) should be focused expect(items[items.length - 1]?.hasClass('is-focused')).toBe(true); }); it('ArrowDown clamps at last option when custom input is hidden', () => { const input = makeInput([{ question: 'Q', options: ['A', 'B'] }]); const container = createMockEl(); const resolve = jest.fn(); const widget = new InlineAskUserQuestion(container, input, resolve, undefined, { showCustomInput: false }); widget.render(); const root = findRoot(container); fireKeyDown(root, 'ArrowDown'); fireKeyDown(root, 'ArrowDown'); fireKeyDown(root, 'ArrowDown'); const items = findItems(container); expect(items).toHaveLength(2); expect(items[1]?.hasClass('is-focused')).toBe(true); }); it('ArrowUp moves focus up and clamps at 0', () => { const input = makeInput([{ question: 'Q', options: ['A', 'B'] }]); const { container } = renderWidget(input); const root = findRoot(container); // Move down then back up past 0 fireKeyDown(root, 'ArrowDown'); fireKeyDown(root, 'ArrowUp'); fireKeyDown(root, 'ArrowUp'); const items = findItems(container); expect(items[0]?.hasClass('is-focused')).toBe(true); }); it('Tab navigates to next question tab', () => { const input = makeInput([ { question: 'Q1', options: ['A'] }, { question: 'Q2', options: ['B'] }, ]); const { container } = renderWidget(input); const root = findRoot(container); fireKeyDown(root, 'Tab'); // Should now be on Q2 — check tab bar const tabs = container.querySelectorAll('claudian-ask-tab'); expect(tabs[1]?.hasClass('is-active')).toBe(true); }); it('Shift+Tab navigates to previous tab', () => { const input = makeInput([ { question: 'Q1', options: ['A'] }, { question: 'Q2', options: ['B'] }, ]); const { container } = renderWidget(input); const root = findRoot(container); // Go to Q2 then back fireKeyDown(root, 'Tab'); fireKeyDown(root, 'Tab', { shiftKey: true }); const tabs = container.querySelectorAll('claudian-ask-tab'); expect(tabs[0]?.hasClass('is-active')).toBe(true); }); it('ArrowRight navigates forward on question tab', () => { const input = makeInput([ { question: 'Q1', options: ['A'] }, { question: 'Q2', options: ['B'] }, ]); const { container } = renderWidget(input); const root = findRoot(container); fireKeyDown(root, 'ArrowRight'); const tabs = container.querySelectorAll('claudian-ask-tab'); expect(tabs[1]?.hasClass('is-active')).toBe(true); }); it('Enter on submit tab calls handleSubmit', () => { jest.useFakeTimers(); const input = makeInput([{ question: 'Q', options: ['A'] }]); const { container, resolve } = renderWidget(input); const root = findRoot(container); // Select option A const items = findItems(container).filter( (i: any) => !i.hasClass('claudian-ask-custom-item'), ); items[0]?.click(); jest.advanceTimersByTime(200); // Now on submit tab, Enter should submit fireKeyDown(root, 'Enter'); expect(resolve).toHaveBeenCalledWith({ Q: 'A' }); jest.useRealTimers(); }); it('Enter on cancel row resolves null', () => { jest.useFakeTimers(); const input = makeInput([{ question: 'Q', options: ['A'] }]); const { container, resolve } = renderWidget(input); const root = findRoot(container); // Select A and auto-advance to submit const items = findItems(container).filter( (i: any) => !i.hasClass('claudian-ask-custom-item'), ); items[0]?.click(); jest.advanceTimersByTime(200); // Move focus to cancel row fireKeyDown(root, 'ArrowDown'); fireKeyDown(root, 'Enter'); expect(resolve).toHaveBeenCalledWith(null); jest.useRealTimers(); }); it('Enter on question option selects it', () => { jest.useFakeTimers(); const input = makeInput([{ question: 'Q', options: ['A', 'B'] }]); const { container } = renderWidget(input); const root = findRoot(container); // Focus is on item 0, press Enter to select fireKeyDown(root, 'Enter'); jest.advanceTimersByTime(200); // After auto-advance we should be on submit tab const tabs = container.querySelectorAll('claudian-ask-tab'); const submitTab = tabs[tabs.length - 1]; expect(submitTab?.hasClass('is-active')).toBe(true); jest.useRealTimers(); }); }); }); function renderImmediateWidget( input: Record<string, unknown>, config?: InlineAskQuestionConfig, ): { container: any; resolve: jest.Mock; widget: InlineAskUserQuestion } { const container = createMockEl(); const resolve = jest.fn(); const widget = new InlineAskUserQuestion( container, input, resolve, undefined, { immediateSelect: true, showCustomInput: false, ...config }, ); widget.render(); return { container, resolve, widget }; } describe('InlineAskUserQuestion - immediateSelect mode', () => { describe('multi-question fallback', () => { it('falls back to tab-bar rendering when questions.length !== 1', () => { const input = makeInput([ { question: 'Q1', options: ['A'] }, { question: 'Q2', options: ['B'] }, ]); const { container, resolve } = renderImmediateWidget(input); // Should render tab bar (immediateSelect disabled due to multi-question) const tabBar = container.querySelector('claudian-ask-tab-bar'); expect(tabBar).not.toBeNull(); const tabs = container.querySelectorAll('claudian-ask-tab'); expect(tabs.length).toBeGreaterThan(0); // Should NOT resolve immediately on click (normal multi-tab flow) const items = findItems(container).filter( (i: any) => !i.hasClass('claudian-ask-custom-item'), ); items[0]?.click(); expect(resolve).not.toHaveBeenCalled(); }); }); describe('rendering', () => { it('does not render tab bar', () => { const input = makeInput([{ question: 'Pick', options: ['A', 'B'] }]); const { container } = renderImmediateWidget(input); const tabBar = container.querySelector('claudian-ask-tab-bar'); expect(tabBar).toBeNull(); const tabs = container.querySelectorAll('claudian-ask-tab'); expect(tabs).toHaveLength(0); }); it('does not render custom input row', () => { const input = makeInput([{ question: 'Pick', options: ['A', 'B'] }]); const { container } = renderImmediateWidget(input); const customItems = container.querySelectorAll('claudian-ask-custom-item'); expect(customItems).toHaveLength(0); }); it('uses custom title when provided', () => { const input = makeInput([{ question: 'Pick', options: ['A'] }]); const { container } = renderImmediateWidget(input, { title: 'Permission required' }); const title = container.querySelector('claudian-ask-inline-title'); expect(title?.textContent).toBe('Permission required'); }); it('renders headerEl between title and content', () => { const headerEl = createMockEl('div'); headerEl.addClass('claudian-ask-approval-info'); const input = makeInput([{ question: 'Pick', options: ['A'] }]); const { container } = renderImmediateWidget(input, { headerEl: headerEl as any }); const root = findRoot(container); expect(root.children.some((c: any) => c.hasClass('claudian-ask-approval-info'))).toBe(true); }); }); describe('selection', () => { it('resolves immediately on click', () => { const input = makeInput([{ question: 'Pick', options: ['A', 'B'] }]); const { container, resolve } = renderImmediateWidget(input); const items = findItems(container).filter( (i: any) => !i.hasClass('claudian-ask-custom-item'), ); items[0]?.click(); expect(resolve).toHaveBeenCalledWith({ Pick: 'A' }); }); it('resolves with second option on click', () => { const input = makeInput([{ question: 'Pick', options: ['A', 'B'] }]); const { container, resolve } = renderImmediateWidget(input); const items = findItems(container).filter( (i: any) => !i.hasClass('claudian-ask-custom-item'), ); items[1]?.click(); expect(resolve).toHaveBeenCalledWith({ Pick: 'B' }); }); }); describe('keyboard navigation', () => { it('ArrowDown/Up navigates focus', () => { const input = makeInput([{ question: 'Pick', options: ['A', 'B', 'C'] }]); const { container } = renderImmediateWidget(input); const root = findRoot(container); fireKeyDown(root, 'ArrowDown'); const items = findItems(container); expect(items[1]?.hasClass('is-focused')).toBe(true); fireKeyDown(root, 'ArrowUp'); const items2 = findItems(container); expect(items2[0]?.hasClass('is-focused')).toBe(true); }); it('Enter selects and resolves immediately', () => { const input = makeInput([{ question: 'Pick', options: ['A', 'B'] }]); const { container, resolve } = renderImmediateWidget(input); const root = findRoot(container); // Move to second option and press Enter fireKeyDown(root, 'ArrowDown'); fireKeyDown(root, 'Enter'); expect(resolve).toHaveBeenCalledWith({ Pick: 'B' }); }); it('Escape cancels', () => { const input = makeInput([{ question: 'Pick', options: ['A', 'B'] }]); const { container, resolve } = renderImmediateWidget(input); const root = findRoot(container); fireKeyDown(root, 'Escape'); expect(resolve).toHaveBeenCalledWith(null); }); it('Tab does not switch tabs (no-op in immediateSelect)', () => { const input = makeInput([{ question: 'Pick', options: ['A', 'B'] }]); const { container, resolve } = renderImmediateWidget(input); const root = findRoot(container); fireKeyDown(root, 'Tab'); expect(resolve).not.toHaveBeenCalled(); const items = findItems(container); expect(items.length).toBeGreaterThan(0); }); it('ArrowDown clamps at last option', () => { const input = makeInput([{ question: 'Pick', options: ['A', 'B'] }]); const { container } = renderImmediateWidget(input); const root = findRoot(container); fireKeyDown(root, 'ArrowDown'); fireKeyDown(root, 'ArrowDown'); fireKeyDown(root, 'ArrowDown'); const items = findItems(container); expect(items[1]?.hasClass('is-focused')).toBe(true); }); }); }); ================================================ FILE: tests/unit/features/chat/rendering/InlineExitPlanMode.test.ts ================================================ import { createMockEl } from '@test/helpers/mockElement'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { InlineExitPlanMode } from '@/features/chat/rendering/InlineExitPlanMode'; beforeAll(() => { globalThis.requestAnimationFrame = (cb: FrameRequestCallback) => { cb(0); return 0; }; (globalThis as any).document = { activeElement: null }; }); function fireKeyDown(root: any, key: string): void { root.dispatchEvent({ type: 'keydown', key, preventDefault: jest.fn(), stopPropagation: jest.fn(), }); } function findRoot(container: any): any { return container.querySelector('.claudian-plan-approval-inline'); } function findItems(root: any): any[] { return root.querySelectorAll('claudian-ask-item'); } describe('InlineExitPlanMode', () => { it('resolves with approve-new-session and includes plan content when readable', () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claudian-')); const plansDir = path.join(tmpDir, '.claude', 'plans'); fs.mkdirSync(plansDir, { recursive: true }); const planFilePath = path.join(plansDir, 'plan.md'); fs.writeFileSync(planFilePath, 'Step 1\nStep 2\n', 'utf8'); const container = createMockEl(); const resolve = jest.fn(); const renderContent = jest.fn().mockResolvedValue(undefined); const widget = new InlineExitPlanMode( container, { planFilePath, allowedPrompts: [{ tool: 'Bash', prompt: 'Run bash commands' }], }, resolve, undefined, renderContent, ); widget.render(); const root = findRoot(container); expect(root).toBeTruthy(); expect(root.getEventListenerCount('keydown')).toBe(1); expect(container.querySelector('.claudian-plan-permissions-list')).toBeTruthy(); expect(renderContent).toHaveBeenCalled(); fireKeyDown(root, 'Enter'); expect(resolve).toHaveBeenCalledTimes(1); expect(resolve).toHaveBeenCalledWith({ type: 'approve-new-session', planContent: 'Implement this plan:\n\nStep 1\nStep 2', }); expect(root.getEventListenerCount('keydown')).toBe(0); }); it('shows a read error when plan file cannot be read', () => { const container = createMockEl(); const resolve = jest.fn(); const widget = new InlineExitPlanMode( container, { planFilePath: '/path/does/not/exist.md' }, resolve, ); widget.render(); const root = findRoot(container); expect(root).toBeTruthy(); expect(container.querySelector('.claudian-plan-read-error')).toBeTruthy(); fireKeyDown(root, 'Enter'); expect(resolve).toHaveBeenCalledWith({ type: 'approve-new-session', planContent: 'Implement the approved plan.', }); }); it('rejects plan file paths outside .claude/plans/', () => { const container = createMockEl(); const resolve = jest.fn(); const widget = new InlineExitPlanMode( container, { planFilePath: '/etc/passwd' }, resolve, ); widget.render(); const root = findRoot(container); expect(root).toBeTruthy(); expect(container.querySelector('.claudian-plan-read-error')).toBeTruthy(); fireKeyDown(root, 'Enter'); expect(resolve).toHaveBeenCalledWith({ type: 'approve-new-session', planContent: 'Implement the approved plan.', }); }); it('supports keyboard navigation for approve/current-session', () => { const container = createMockEl(); const resolve = jest.fn(); const widget = new InlineExitPlanMode(container, {}, resolve); widget.render(); const root = findRoot(container); expect(root).toBeTruthy(); fireKeyDown(root, 'ArrowDown'); fireKeyDown(root, 'Enter'); expect(resolve).toHaveBeenCalledWith({ type: 'approve' }); }); it('supports feedback flow and Escape when input is focused', () => { const container = createMockEl(); const resolve = jest.fn(); const widget = new InlineExitPlanMode(container, {}, resolve); widget.render(); const root = findRoot(container); expect(root).toBeTruthy(); fireKeyDown(root, 'ArrowDown'); fireKeyDown(root, 'ArrowDown'); fireKeyDown(root, 'Enter'); const items = findItems(root); const feedbackRow = items[2]; const feedbackInput = feedbackRow.querySelector('claudian-ask-custom-text'); expect(resolve).not.toHaveBeenCalled(); feedbackInput.dispatchEvent('focus'); fireKeyDown(root, 'Escape'); expect(resolve).not.toHaveBeenCalled(); feedbackInput.value = 'Please revise the plan'; feedbackInput.dispatchEvent('focus'); fireKeyDown(root, 'Enter'); expect(resolve).toHaveBeenCalledWith({ type: 'feedback', text: 'Please revise the plan' }); }); it('resolves null on abort and does not resolve twice', () => { const container = createMockEl(); const resolve = jest.fn(); const controller = new AbortController(); const widget = new InlineExitPlanMode(container, {}, resolve, controller.signal); widget.render(); controller.abort(); expect(resolve).toHaveBeenCalledTimes(1); expect(resolve).toHaveBeenCalledWith(null); widget.destroy(); expect(resolve).toHaveBeenCalledTimes(1); }); }); ================================================ FILE: tests/unit/features/chat/rendering/MessageRenderer.test.ts ================================================ import { createMockEl } from '@test/helpers/mockElement'; import { TOOL_AGENT_OUTPUT, TOOL_TASK } from '@/core/tools/toolNames'; import type { ChatMessage, ImageAttachment } from '@/core/types'; import { MessageRenderer } from '@/features/chat/rendering/MessageRenderer'; import { renderStoredAsyncSubagent, renderStoredSubagent } from '@/features/chat/rendering/SubagentRenderer'; import { renderStoredThinkingBlock } from '@/features/chat/rendering/ThinkingBlockRenderer'; import { renderStoredToolCall } from '@/features/chat/rendering/ToolCallRenderer'; import { renderStoredWriteEdit } from '@/features/chat/rendering/WriteEditRenderer'; jest.mock('@/features/chat/rendering/SubagentRenderer', () => ({ renderStoredAsyncSubagent: jest.fn().mockReturnValue({ wrapperEl: {}, cleanup: jest.fn() }), renderStoredSubagent: jest.fn(), })); jest.mock('@/features/chat/rendering/ThinkingBlockRenderer', () => ({ renderStoredThinkingBlock: jest.fn(), })); jest.mock('@/features/chat/rendering/ToolCallRenderer', () => ({ renderStoredToolCall: jest.fn(), })); jest.mock('@/features/chat/rendering/WriteEditRenderer', () => ({ renderStoredWriteEdit: jest.fn(), })); jest.mock('@/utils/imageEmbed', () => ({ replaceImageEmbedsWithHtml: jest.fn().mockImplementation((md: string) => md), })); jest.mock('@/utils/fileLink', () => ({ processFileLinks: jest.fn(), registerFileLinkHandler: jest.fn(), })); function createMockComponent() { return { registerDomEvent: jest.fn(), register: jest.fn(), addChild: jest.fn(), load: jest.fn(), unload: jest.fn(), }; } function createRenderer(messagesEl?: any) { const el = messagesEl ?? createMockEl(); const comp = createMockComponent(); const plugin = { app: {}, settings: { mediaFolder: '' }, }; return { renderer: new MessageRenderer(plugin as any, comp as any, el), messagesEl: el }; } describe('MessageRenderer', () => { beforeEach(() => { jest.clearAllMocks(); }); // ============================================ // renderMessages // ============================================ it('renders welcome element and calls renderStoredMessage for each message', () => { const messagesEl = createMockEl(); const emptySpy = jest.spyOn(messagesEl, 'empty'); const mockComponent = createMockComponent(); const renderer = new MessageRenderer({} as any, mockComponent as any, messagesEl); const renderStoredSpy = jest.spyOn(renderer, 'renderStoredMessage').mockImplementation(() => {}); const messages: ChatMessage[] = [ { id: 'm1', role: 'assistant', content: '', timestamp: Date.now(), toolCalls: [], contentBlocks: [] }, ]; const welcomeEl = renderer.renderMessages(messages, () => 'Hello'); expect(emptySpy).toHaveBeenCalled(); expect(renderStoredSpy).toHaveBeenCalledTimes(1); expect(welcomeEl.hasClass('claudian-welcome')).toBe(true); expect(welcomeEl.children[0].textContent).toBe('Hello'); }); it('renders empty messages list with just welcome element', () => { const { renderer } = createRenderer(); const renderStoredSpy = jest.spyOn(renderer, 'renderStoredMessage').mockImplementation(() => {}); const welcomeEl = renderer.renderMessages([], () => 'Welcome!'); expect(renderStoredSpy).not.toHaveBeenCalled(); expect(welcomeEl.hasClass('claudian-welcome')).toBe(true); }); // ============================================ // renderStoredMessage // ============================================ it('renders interrupt messages with interrupt styling instead of user bubble', () => { const messagesEl = createMockEl(); const mockComponent = createMockComponent(); const renderer = new MessageRenderer({} as any, mockComponent as any, messagesEl); const interruptMsg: ChatMessage = { id: 'interrupt-1', role: 'user', content: '[Request interrupted by user]', timestamp: Date.now(), isInterrupt: true, }; renderer.renderStoredMessage(interruptMsg); // Should create assistant-style message with interrupt content expect(messagesEl.children.length).toBe(1); const msgEl = messagesEl.children[0]; expect(msgEl.hasClass('claudian-message-assistant')).toBe(true); // Check the content contains interrupt styling const contentEl = msgEl.children[0]; const textEl = contentEl.children[0]; expect(textEl.innerHTML).toContain('claudian-interrupted'); expect(textEl.innerHTML).toContain('Interrupted'); }); it('skips rebuilt context messages', () => { const messagesEl = createMockEl(); const { renderer } = createRenderer(messagesEl); const msg: ChatMessage = { id: 'rebuilt-1', role: 'user', content: 'rebuilt context', timestamp: Date.now(), isRebuiltContext: true, }; renderer.renderStoredMessage(msg); expect(messagesEl.children.length).toBe(0); }); it('renders user message with text content', () => { const messagesEl = createMockEl(); const { renderer } = createRenderer(messagesEl); jest.spyOn(renderer, 'renderContent').mockResolvedValue(undefined); const msg: ChatMessage = { id: 'u1', role: 'user', content: 'Hello world', timestamp: Date.now(), }; renderer.renderStoredMessage(msg); expect(messagesEl.children.length).toBe(1); const msgEl = messagesEl.children[0]; expect(msgEl.hasClass('claudian-message-user')).toBe(true); }); it('renders user message with displayContent instead of content', () => { const messagesEl = createMockEl(); const { renderer } = createRenderer(messagesEl); const renderContentSpy = jest.spyOn(renderer, 'renderContent').mockResolvedValue(undefined); const msg: ChatMessage = { id: 'u1', role: 'user', content: 'full prompt with context', displayContent: 'user input only', timestamp: Date.now(), }; renderer.renderStoredMessage(msg); expect(renderContentSpy).toHaveBeenCalledWith(expect.anything(), 'user input only'); }); it('skips empty user message bubble (image-only)', () => { const messagesEl = createMockEl(); const { renderer } = createRenderer(messagesEl); jest.spyOn(renderer, 'renderMessageImages').mockImplementation(() => {}); const msg: ChatMessage = { id: 'u1', role: 'user', content: '', timestamp: Date.now(), images: [{ id: 'img-1', name: 'img.png', mediaType: 'image/png', data: 'abc', size: 100, source: 'paste' as const }], }; renderer.renderStoredMessage(msg); // Images should still be rendered, but no message bubble expect(renderer.renderMessageImages).toHaveBeenCalled(); // Only the images container, no message bubble const bubbles = messagesEl.children.filter( (c: any) => c.hasClass('claudian-message') ); expect(bubbles.length).toBe(0); }); it('renders user message with images above bubble', () => { const messagesEl = createMockEl(); const { renderer } = createRenderer(messagesEl); jest.spyOn(renderer, 'renderContent').mockResolvedValue(undefined); const renderImagesSpy = jest.spyOn(renderer, 'renderMessageImages').mockImplementation(() => {}); const images: ImageAttachment[] = [ { id: 'img-1', name: 'photo.png', mediaType: 'image/png', data: 'base64data', size: 200, source: 'file' }, ]; const msg: ChatMessage = { id: 'u1', role: 'user', content: 'Check this image', timestamp: Date.now(), images, }; renderer.renderStoredMessage(msg); expect(renderImagesSpy).toHaveBeenCalledWith(messagesEl, images); }); it('adds a rewind button for eligible stored user messages', () => { const messagesEl = createMockEl(); const rewindCallback = jest.fn().mockResolvedValue(undefined); const renderer = new MessageRenderer({ app: {}, settings: { mediaFolder: '' } } as any, createMockComponent() as any, messagesEl, rewindCallback); jest.spyOn(renderer, 'renderContent').mockResolvedValue(undefined); const allMessages: ChatMessage[] = [ { id: 'a1', role: 'assistant', content: '', timestamp: 1, sdkAssistantUuid: 'prev-a' }, { id: 'u1', role: 'user', content: 'hello', timestamp: 2, sdkUserUuid: 'user-u' }, { id: 'a2', role: 'assistant', content: '', timestamp: 3, sdkAssistantUuid: 'resp-a' }, ]; renderer.renderStoredMessage(allMessages[1], allMessages, 1); expect(messagesEl.querySelector('.claudian-message-rewind-btn')).not.toBeNull(); }); it('does not add a rewind button when stored render is called without context', () => { const messagesEl = createMockEl(); const rewindCallback = jest.fn().mockResolvedValue(undefined); const renderer = new MessageRenderer({ app: {}, settings: { mediaFolder: '' } } as any, createMockComponent() as any, messagesEl, rewindCallback); jest.spyOn(renderer, 'renderContent').mockResolvedValue(undefined); const msg: ChatMessage = { id: 'u1', role: 'user', content: 'hello', timestamp: 1, sdkUserUuid: 'user-u', }; renderer.renderStoredMessage(msg); expect(messagesEl.querySelector('.claudian-message-rewind-btn')).toBeNull(); }); it('adds a rewind button for eligible streamed user messages via refreshActionButtons', () => { const messagesEl = createMockEl(); const rewindCallback = jest.fn().mockResolvedValue(undefined); const renderer = new MessageRenderer({ app: {}, settings: { mediaFolder: '' } } as any, createMockComponent() as any, messagesEl, rewindCallback); jest.spyOn(renderer, 'renderContent').mockResolvedValue(undefined); const userMsg: ChatMessage = { id: 'u1', role: 'user', content: 'hello', timestamp: 2, sdkUserUuid: 'user-u', }; renderer.addMessage(userMsg); const allMessages: ChatMessage[] = [ { id: 'a1', role: 'assistant', content: '', timestamp: 1, sdkAssistantUuid: 'prev-a' }, userMsg, { id: 'a2', role: 'assistant', content: '', timestamp: 3, sdkAssistantUuid: 'resp-a' }, ]; renderer.refreshActionButtons(userMsg, allMessages, 1); const btn = messagesEl.querySelector('.claudian-message-rewind-btn'); expect(btn).not.toBeNull(); btn!.click(); expect(rewindCallback).toHaveBeenCalledWith('u1'); }); // ============================================ // renderAssistantContent // ============================================ it('renders assistant content blocks using specialized renderers', () => { const messagesEl = createMockEl(); const mockComponent = createMockComponent(); const renderer = new MessageRenderer({} as any, mockComponent as any, messagesEl); const renderContentSpy = jest.spyOn(renderer, 'renderContent').mockResolvedValue(undefined); const msg: ChatMessage = { id: 'm1', role: 'assistant', content: '', timestamp: Date.now(), toolCalls: [ { id: 'todo', name: 'TodoWrite', input: { items: [] } } as any, { id: 'edit', name: 'Edit', input: { file_path: 'notes/test.md' } } as any, { id: 'read', name: 'Read', input: { file_path: 'notes/test.md' } } as any, { id: 'sub-1', name: TOOL_TASK, input: { description: 'Async subagent' }, status: 'running', subagent: { id: 'sub-1', mode: 'async', status: 'running', toolCalls: [], isExpanded: false }, } as any, { id: 'sub-2', name: TOOL_TASK, input: { description: 'Sync subagent' }, status: 'running', subagent: { id: 'sub-2', mode: 'sync', status: 'running', toolCalls: [], isExpanded: false }, } as any, ], contentBlocks: [ { type: 'thinking', content: 'thinking', durationSeconds: 2 } as any, { type: 'text', content: 'Text block' } as any, { type: 'tool_use', toolId: 'todo' } as any, { type: 'tool_use', toolId: 'edit' } as any, { type: 'tool_use', toolId: 'read' } as any, { type: 'subagent', subagentId: 'sub-1', mode: 'async' } as any, { type: 'subagent', subagentId: 'sub-2' } as any, ], }; renderer.renderStoredMessage(msg); expect(renderStoredThinkingBlock).toHaveBeenCalled(); expect(renderContentSpy).toHaveBeenCalledWith(expect.anything(), 'Text block'); // TodoWrite is not rendered inline - only in bottom panel expect(renderStoredWriteEdit).toHaveBeenCalled(); expect(renderStoredToolCall).toHaveBeenCalled(); expect(renderStoredAsyncSubagent).toHaveBeenCalled(); expect(renderStoredSubagent).toHaveBeenCalled(); }); it('skips empty or whitespace-only text blocks', () => { const messagesEl = createMockEl(); const { renderer } = createRenderer(messagesEl); const renderContentSpy = jest.spyOn(renderer, 'renderContent').mockResolvedValue(undefined); const msg: ChatMessage = { id: 'm1', role: 'assistant', content: '', timestamp: Date.now(), contentBlocks: [ { type: 'text', content: '' } as any, { type: 'text', content: ' ' } as any, { type: 'text', content: 'Real content' } as any, ], }; renderer.renderStoredMessage(msg); // Only the non-empty text block should trigger renderContent expect(renderContentSpy).toHaveBeenCalledTimes(1); expect(renderContentSpy).toHaveBeenCalledWith(expect.anything(), 'Real content'); }); it('renders response duration footer when durationSeconds is present', () => { const messagesEl = createMockEl(); const { renderer } = createRenderer(messagesEl); jest.spyOn(renderer, 'renderContent').mockResolvedValue(undefined); const msg: ChatMessage = { id: 'm1', role: 'assistant', content: '', timestamp: Date.now(), contentBlocks: [ { type: 'text', content: 'Response text' } as any, ], durationSeconds: 65, durationFlavorWord: 'Baked', }; renderer.renderStoredMessage(msg); // Find the footer element const msgEl = messagesEl.children[0]; const contentEl = msgEl.children[0]; // claudian-message-content const footerEl = contentEl.children.find((c: any) => c.hasClass('claudian-response-footer')); expect(footerEl).toBeDefined(); const durationSpan = footerEl!.children[0]; expect(durationSpan.textContent).toContain('Baked'); expect(durationSpan.textContent).toContain('1m 5s'); }); it('does not render footer when durationSeconds is 0', () => { const messagesEl = createMockEl(); const { renderer } = createRenderer(messagesEl); jest.spyOn(renderer, 'renderContent').mockResolvedValue(undefined); const msg: ChatMessage = { id: 'm1', role: 'assistant', content: '', timestamp: Date.now(), contentBlocks: [ { type: 'text', content: 'Response' } as any, ], durationSeconds: 0, }; renderer.renderStoredMessage(msg); const msgEl = messagesEl.children[0]; const contentEl = msgEl.children[0]; const footerEl = contentEl.children.find((c: any) => c.hasClass('claudian-response-footer')); expect(footerEl).toBeUndefined(); }); it('uses default flavor word "Baked" when durationFlavorWord is not set', () => { const messagesEl = createMockEl(); const { renderer } = createRenderer(messagesEl); jest.spyOn(renderer, 'renderContent').mockResolvedValue(undefined); const msg: ChatMessage = { id: 'm1', role: 'assistant', content: '', timestamp: Date.now(), contentBlocks: [ { type: 'text', content: 'Response' } as any, ], durationSeconds: 30, }; renderer.renderStoredMessage(msg); const msgEl = messagesEl.children[0]; const contentEl = msgEl.children[0]; const footerEl = contentEl.children.find((c: any) => c.hasClass('claudian-response-footer')); expect(footerEl).toBeDefined(); expect(footerEl!.children[0].textContent).toContain('Baked'); }); it('renders fallback content for old conversations without contentBlocks', () => { const messagesEl = createMockEl(); const { renderer } = createRenderer(messagesEl); const renderContentSpy = jest.spyOn(renderer, 'renderContent').mockResolvedValue(undefined); const addCopySpy = jest.spyOn(renderer, 'addTextCopyButton').mockImplementation(() => {}); const msg: ChatMessage = { id: 'm1', role: 'assistant', content: 'Legacy response text', timestamp: Date.now(), toolCalls: [ { id: 'read-1', name: 'Read', input: { file_path: 'test.md' }, status: 'completed' } as any, ], }; renderer.renderStoredMessage(msg); // Should render content text expect(renderContentSpy).toHaveBeenCalledWith(expect.anything(), 'Legacy response text'); // Should add copy button for fallback text expect(addCopySpy).toHaveBeenCalledWith(expect.anything(), 'Legacy response text'); // Should render tool call expect(renderStoredToolCall).toHaveBeenCalled(); }); it('renders unreferenced tool calls when contentBlocks miss tool_use blocks', () => { const messagesEl = createMockEl(); const { renderer } = createRenderer(messagesEl); const renderContentSpy = jest.spyOn(renderer, 'renderContent').mockResolvedValue(undefined); (renderStoredToolCall as jest.Mock).mockClear(); const msg: ChatMessage = { id: 'm-unreferenced-tool', role: 'assistant', content: '', timestamp: Date.now(), toolCalls: [ { id: 'read-1', name: 'Read', input: { file_path: 'a.md' }, status: 'completed' } as any, ], contentBlocks: [ { type: 'text', content: 'Only text block persisted' } as any, ], }; renderer.renderStoredMessage(msg); expect(renderContentSpy).toHaveBeenCalledWith(expect.anything(), 'Only text block persisted'); expect(renderStoredToolCall).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ id: 'read-1', name: 'Read' }) ); }); it('renders Task tool calls as subagents for backward compatibility', () => { const messagesEl = createMockEl(); const { renderer } = createRenderer(messagesEl); (renderStoredSubagent as jest.Mock).mockClear(); const msg: ChatMessage = { id: 'm1', role: 'assistant', content: '', timestamp: Date.now(), toolCalls: [ { id: 'task-1', name: TOOL_TASK, input: { description: 'Run tests' }, status: 'completed', result: 'All passed', } as any, ], contentBlocks: [ { type: 'tool_use', toolId: 'task-1' } as any, ], }; renderer.renderStoredMessage(msg); expect(renderStoredSubagent).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ id: 'task-1', description: 'Run tests', status: 'completed', result: 'All passed', }) ); }); it('renders Task tool as async subagent when linked subagent mode is async', () => { const messagesEl = createMockEl(); const { renderer } = createRenderer(messagesEl); (renderStoredAsyncSubagent as jest.Mock).mockClear(); (renderStoredSubagent as jest.Mock).mockClear(); const msg: ChatMessage = { id: 'm-task-async', role: 'assistant', content: '', timestamp: Date.now(), toolCalls: [ { id: 'task-async-1', name: TOOL_TASK, input: { description: 'Background task', run_in_background: true }, status: 'completed', result: 'Task running', subagent: { id: 'task-async-1', description: 'Background task', mode: 'async', asyncStatus: 'running', status: 'running', toolCalls: [], isExpanded: false, }, } as any, ], contentBlocks: [ { type: 'tool_use', toolId: 'task-async-1' } as any, ], }; renderer.renderStoredMessage(msg); expect(renderStoredAsyncSubagent).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ id: 'task-async-1', mode: 'async', asyncStatus: 'running', }) ); expect(renderStoredSubagent).not.toHaveBeenCalled(); }); it('uses subagent block mode hint when linked subagent mode is missing', () => { const messagesEl = createMockEl(); const { renderer } = createRenderer(messagesEl); (renderStoredAsyncSubagent as jest.Mock).mockClear(); (renderStoredSubagent as jest.Mock).mockClear(); const msg: ChatMessage = { id: 'm-task-mode-hint', role: 'assistant', content: '', timestamp: Date.now(), toolCalls: [ { id: 'task-hint-1', name: TOOL_TASK, input: { description: 'Background task from block hint' }, status: 'running', subagent: { id: 'task-hint-1', description: 'Background task from block hint', status: 'running', toolCalls: [], isExpanded: false, }, } as any, ], contentBlocks: [ { type: 'subagent', subagentId: 'task-hint-1', mode: 'async' } as any, ], }; renderer.renderStoredMessage(msg); expect(renderStoredAsyncSubagent).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ id: 'task-hint-1', mode: 'async', }) ); expect(renderStoredSubagent).not.toHaveBeenCalled(); }); // ============================================ // TaskOutput skipping // ============================================ it('should skip TaskOutput tool calls (internal async subagent communication)', () => { const messagesEl = createMockEl(); const mockComponent = createMockComponent(); const renderer = new MessageRenderer({} as any, mockComponent as any, messagesEl); (renderStoredToolCall as jest.Mock).mockClear(); const msg: ChatMessage = { id: 'm1', role: 'assistant', content: '', timestamp: Date.now(), toolCalls: [ { id: 'agent-output-1', name: TOOL_AGENT_OUTPUT, input: { task_id: 'abc', block: true } } as any, ], contentBlocks: [ { type: 'tool_use', toolId: 'agent-output-1' } as any, ], }; renderer.renderStoredMessage(msg); expect(renderStoredToolCall).not.toHaveBeenCalled(); }); it('should render other tool calls but skip TaskOutput when mixed', () => { const messagesEl = createMockEl(); const mockComponent = createMockComponent(); const renderer = new MessageRenderer({} as any, mockComponent as any, messagesEl); (renderStoredToolCall as jest.Mock).mockClear(); const msg: ChatMessage = { id: 'm1', role: 'assistant', content: '', timestamp: Date.now(), toolCalls: [ { id: 'read-1', name: 'Read', input: { file_path: 'test.md' }, status: 'completed' } as any, { id: 'agent-output-1', name: TOOL_AGENT_OUTPUT, input: { task_id: 'abc' } } as any, { id: 'grep-1', name: 'Grep', input: { pattern: 'test' }, status: 'completed' } as any, ], contentBlocks: [ { type: 'tool_use', toolId: 'read-1' } as any, { type: 'tool_use', toolId: 'agent-output-1' } as any, { type: 'tool_use', toolId: 'grep-1' } as any, ], }; renderer.renderStoredMessage(msg); expect(renderStoredToolCall).toHaveBeenCalledTimes(2); expect(renderStoredToolCall).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ id: 'read-1', name: 'Read' }) ); expect(renderStoredToolCall).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ id: 'grep-1', name: 'Grep' }) ); }); // ============================================ // addMessage (streaming) // ============================================ it('addMessage creates user message bubble with text', () => { const messagesEl = createMockEl(); const { renderer } = createRenderer(messagesEl); jest.spyOn(renderer, 'renderContent').mockResolvedValue(undefined); const msg: ChatMessage = { id: 'u1', role: 'user', content: 'Hello', timestamp: Date.now(), }; const msgEl = renderer.addMessage(msg); expect(msgEl.hasClass('claudian-message-user')).toBe(true); }); it('addMessage renders images for user messages', () => { const messagesEl = createMockEl(); const { renderer } = createRenderer(messagesEl); jest.spyOn(renderer, 'renderContent').mockResolvedValue(undefined); const renderImagesSpy = jest.spyOn(renderer, 'renderMessageImages').mockImplementation(() => {}); const images: ImageAttachment[] = [ { id: 'img-1', name: 'photo.png', mediaType: 'image/png', data: 'base64data', size: 200, source: 'file' }, ]; const msg: ChatMessage = { id: 'u1', role: 'user', content: 'Look at this', timestamp: Date.now(), images, }; renderer.addMessage(msg); expect(renderImagesSpy).toHaveBeenCalledWith(messagesEl, images); }); it('addMessage skips empty bubble for image-only user messages', () => { const messagesEl = createMockEl(); const { renderer } = createRenderer(messagesEl); jest.spyOn(renderer, 'renderMessageImages').mockImplementation(() => {}); const scrollSpy = jest.spyOn(renderer, 'scrollToBottom').mockImplementation(() => {}); const msg: ChatMessage = { id: 'u1', role: 'user', content: '', timestamp: Date.now(), images: [{ id: 'img-1', name: 'img.png', mediaType: 'image/png', data: 'abc', size: 100, source: 'paste' as const }], }; const result = renderer.addMessage(msg); // Should still return an element (last child or messagesEl) expect(result).toBeDefined(); expect(scrollSpy).toHaveBeenCalled(); }); it('addMessage creates assistant message element without user-specific rendering', () => { const messagesEl = createMockEl(); const { renderer } = createRenderer(messagesEl); const msg: ChatMessage = { id: 'a1', role: 'assistant', content: '', timestamp: Date.now(), }; const msgEl = renderer.addMessage(msg); expect(msgEl.hasClass('claudian-message-assistant')).toBe(true); }); // ============================================ // setMessagesEl // ============================================ it('setMessagesEl updates the container element', () => { const messagesEl = createMockEl(); const { renderer } = createRenderer(messagesEl); const newEl = createMockEl(); renderer.setMessagesEl(newEl); // Verify by using scrollToBottom which references messagesEl renderer.scrollToBottom(); // The new element should have been used (scrollTop set) expect(newEl.scrollTop).toBe(newEl.scrollHeight); }); // ============================================ // Image rendering // ============================================ it('renderMessageImages creates image elements', () => { const containerEl = createMockEl(); const { renderer } = createRenderer(); jest.spyOn(renderer, 'setImageSrc').mockImplementation(() => {}); const images: ImageAttachment[] = [ { id: 'img-1', name: 'photo.png', mediaType: 'image/png', data: 'base64data1', size: 200, source: 'file' }, { id: 'img-2', name: 'avatar.jpg', mediaType: 'image/jpeg', data: 'base64data2', size: 300, source: 'file' }, ]; renderer.renderMessageImages(containerEl, images); // Should create images container with 2 image wrappers expect(containerEl.children.length).toBe(1); const imagesContainer = containerEl.children[0]; expect(imagesContainer.hasClass('claudian-message-images')).toBe(true); expect(imagesContainer.children.length).toBe(2); }); it('setImageSrc sets data URI on image element', () => { const { renderer } = createRenderer(); const imgEl = createMockEl('img'); const image: ImageAttachment = { id: 'img-1', name: 'test.png', mediaType: 'image/png', data: 'abc123', size: 100, source: 'file', }; renderer.setImageSrc(imgEl as any, image); expect(imgEl.getAttribute('src')).toBe('data:image/png;base64,abc123'); }); it('showFullImage creates overlay with image', () => { const { renderer } = createRenderer(); const image: ImageAttachment = { id: 'img-1', name: 'test.png', mediaType: 'image/png', data: 'abc123', size: 100, source: 'file', }; // Mock document.body.createDiv (document may not exist in node env) const overlayEl = createMockEl(); const mockBody = { createDiv: jest.fn().mockReturnValue(overlayEl) }; const origDocument = globalThis.document; (globalThis as any).document = { body: mockBody, addEventListener: jest.fn(), removeEventListener: jest.fn() }; try { renderer.showFullImage(image); expect(mockBody.createDiv).toHaveBeenCalledWith({ cls: 'claudian-image-modal-overlay' }); } finally { (globalThis as any).document = origDocument; } }); // ============================================ // Copy button // ============================================ it('addTextCopyButton adds a copy button element', () => { const textEl = createMockEl(); const { renderer } = createRenderer(); renderer.addTextCopyButton(textEl, 'some markdown'); expect(textEl.children.length).toBe(1); const copyBtn = textEl.children[0]; expect(copyBtn.hasClass('claudian-text-copy-btn')).toBe(true); }); // ============================================ // Scroll utilities // ============================================ it('scrollToBottom sets scrollTop to scrollHeight', () => { const messagesEl = createMockEl(); messagesEl.scrollHeight = 1000; const { renderer } = createRenderer(messagesEl); renderer.scrollToBottom(); expect(messagesEl.scrollTop).toBe(1000); }); it('scrollToBottomIfNeeded scrolls when near bottom', () => { const messagesEl = createMockEl(); messagesEl.scrollHeight = 1000; messagesEl.scrollTop = 950; Object.defineProperty(messagesEl, 'clientHeight', { value: 0, configurable: true }); const { renderer } = createRenderer(messagesEl); // Mock requestAnimationFrame const origRAF = globalThis.requestAnimationFrame; (globalThis as any).requestAnimationFrame = (cb: () => void) => { cb(); return 0; }; try { renderer.scrollToBottomIfNeeded(); // Near bottom (1000 - 950 - 0 = 50, < 100 threshold) → scrolls expect(messagesEl.scrollTop).toBe(1000); } finally { (globalThis as any).requestAnimationFrame = origRAF; } }); it('scrollToBottomIfNeeded does not scroll when far from bottom', () => { const messagesEl = createMockEl(); messagesEl.scrollHeight = 1000; messagesEl.scrollTop = 100; Object.defineProperty(messagesEl, 'clientHeight', { value: 0, configurable: true }); const { renderer } = createRenderer(messagesEl); const originalScrollTop = messagesEl.scrollTop; renderer.scrollToBottomIfNeeded(); // scrollTop should not change (900 > 100 threshold) expect(messagesEl.scrollTop).toBe(originalScrollTop); }); // ============================================ // renderContent // ============================================ it('renderContent should not throw on valid markdown', async () => { const { renderer } = createRenderer(); const el = createMockEl(); // Should not throw even if internal rendering fails (graceful error handling) await expect(renderer.renderContent(el, '**Hello** world')).resolves.not.toThrow(); }); it('renderContent should empty the element before rendering', async () => { const { renderer } = createRenderer(); const el = createMockEl(); el.createDiv({ text: 'old content' }); expect(el.children.length).toBe(1); await renderer.renderContent(el, 'new content'); // After render, old content should be gone (empty() was called before rendering) expect(el.children.length).toBe(0); }); // ============================================ // addTextCopyButton - click behavior // ============================================ describe('addTextCopyButton - click behavior', () => { let originalNavigator: Navigator; beforeEach(() => { originalNavigator = globalThis.navigator; jest.useFakeTimers(); }); afterEach(() => { jest.useRealTimers(); Object.defineProperty(globalThis, 'navigator', { value: originalNavigator, writable: true, configurable: true, }); }); it('click should copy and show feedback', async () => { const { renderer } = createRenderer(); const textEl = createMockEl(); const writeTextMock = jest.fn().mockResolvedValue(undefined); Object.defineProperty(globalThis, 'navigator', { value: { clipboard: { writeText: writeTextMock } }, writable: true, configurable: true, }); renderer.addTextCopyButton(textEl, 'markdown content'); const copyBtn = textEl.children[0]; expect(copyBtn.hasClass('claudian-text-copy-btn')).toBe(true); // Simulate click const clickHandlers = copyBtn._eventListeners.get('click'); expect(clickHandlers).toBeDefined(); await clickHandlers![0]({ stopPropagation: jest.fn() }); expect(writeTextMock).toHaveBeenCalledWith('markdown content'); expect(copyBtn.textContent).toBe('copied!'); expect(copyBtn.classList.contains('copied')).toBe(true); }); it('should handle clipboard API failure gracefully', async () => { const { renderer } = createRenderer(); const textEl = createMockEl(); const writeTextMock = jest.fn().mockRejectedValue(new Error('not allowed')); Object.defineProperty(globalThis, 'navigator', { value: { clipboard: { writeText: writeTextMock } }, writable: true, configurable: true, }); renderer.addTextCopyButton(textEl, 'content'); const copyBtn = textEl.children[0]; const clickHandlers = copyBtn._eventListeners.get('click'); // Should not throw await clickHandlers![0]({ stopPropagation: jest.fn() }); // Should not show feedback on error expect(copyBtn.textContent).not.toBe('copied!'); }); }); // ============================================ // renderMessages (entry point) // ============================================ it('renderMessages should render stored messages and return welcome element', () => { const messagesEl = createMockEl(); const { renderer } = createRenderer(messagesEl); jest.spyOn(renderer, 'renderContent').mockResolvedValue(undefined); jest.spyOn(renderer, 'renderMessageImages').mockImplementation(() => {}); const messages: ChatMessage[] = [ { id: 'u1', role: 'user', content: 'Hello', timestamp: Date.now() }, { id: 'a1', role: 'assistant', content: 'Hi there', timestamp: Date.now(), contentBlocks: [{ type: 'text', content: 'Hi there' }] as any }, ]; const welcomeEl = renderer.renderMessages(messages, () => 'Good morning!'); expect(welcomeEl).toBeDefined(); expect(welcomeEl!.hasClass('claudian-welcome')).toBe(true); }); it('renderMessages should hide welcome when messages exist', () => { const messagesEl = createMockEl(); const { renderer } = createRenderer(messagesEl); jest.spyOn(renderer, 'renderContent').mockResolvedValue(undefined); jest.spyOn(renderer, 'renderMessageImages').mockImplementation(() => {}); const messages: ChatMessage[] = [ { id: 'u1', role: 'user', content: 'Hello', timestamp: Date.now() }, ]; const welcomeEl = renderer.renderMessages(messages, () => 'Hello'); // When messages exist, welcome should be hidden expect(welcomeEl).toBeDefined(); }); it('renderMessages should return welcome element when no messages', () => { const messagesEl = createMockEl(); const { renderer } = createRenderer(messagesEl); const welcomeEl = renderer.renderMessages([], () => 'Welcome'); expect(welcomeEl).toBeDefined(); expect(welcomeEl!.hasClass('claudian-welcome')).toBe(true); }); // ============================================ // Task tool rendering - error and running status // ============================================ describe('Task tool rendering - error and running status', () => { it('renders Task tool with error status as subagent with status error', () => { const messagesEl = createMockEl(); const { renderer } = createRenderer(messagesEl); (renderStoredSubagent as jest.Mock).mockClear(); const msg: ChatMessage = { id: 'm1', role: 'assistant', content: '', timestamp: Date.now(), toolCalls: [ { id: 'task-err', name: TOOL_TASK, input: { description: 'Failing task' }, status: 'error', result: 'Something went wrong', } as any, ], contentBlocks: [ { type: 'tool_use', toolId: 'task-err' } as any, ], }; renderer.renderStoredMessage(msg); expect(renderStoredSubagent).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ id: 'task-err', description: 'Failing task', status: 'error', result: 'Something went wrong', }) ); }); it('renders Task tool with running status (default case in switch)', () => { const messagesEl = createMockEl(); const { renderer } = createRenderer(messagesEl); (renderStoredSubagent as jest.Mock).mockClear(); const msg: ChatMessage = { id: 'm1', role: 'assistant', content: '', timestamp: Date.now(), toolCalls: [ { id: 'task-run', name: TOOL_TASK, input: { description: 'Running task' }, status: 'pending', } as any, ], contentBlocks: [ { type: 'tool_use', toolId: 'task-run' } as any, ], }; renderer.renderStoredMessage(msg); expect(renderStoredSubagent).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ id: 'task-run', description: 'Running task', status: 'running', }) ); }); it('renders Task tool with no description uses fallback Subagent task', () => { const messagesEl = createMockEl(); const { renderer } = createRenderer(messagesEl); (renderStoredSubagent as jest.Mock).mockClear(); const msg: ChatMessage = { id: 'm1', role: 'assistant', content: '', timestamp: Date.now(), toolCalls: [ { id: 'task-no-desc', name: TOOL_TASK, input: {}, status: 'completed', result: 'Done', } as any, ], contentBlocks: [ { type: 'tool_use', toolId: 'task-no-desc' } as any, ], }; renderer.renderStoredMessage(msg); expect(renderStoredSubagent).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ id: 'task-no-desc', description: 'Subagent task', status: 'completed', }) ); }); }); // ============================================ // showFullImage - close behaviors // ============================================ describe('showFullImage - close behaviors', () => { const image: ImageAttachment = { id: 'img-1', name: 'test.png', mediaType: 'image/png', data: 'abc123', size: 100, source: 'file', }; function setupDocumentMock() { const overlayEl = createMockEl(); const mockBody = { createDiv: jest.fn().mockReturnValue(overlayEl) }; const docListeners = new Map<string, ((...args: any[]) => void)[]>(); const origDocument = globalThis.document; (globalThis as any).document = { body: mockBody, addEventListener: jest.fn((event: string, handler: (...args: any[]) => void) => { if (!docListeners.has(event)) docListeners.set(event, []); docListeners.get(event)!.push(handler); }), removeEventListener: jest.fn((event: string, handler: (...args: any[]) => void) => { const handlers = docListeners.get(event); if (handlers) { const idx = handlers.indexOf(handler); if (idx !== -1) handlers.splice(idx, 1); } }), }; return { overlayEl, docListeners, origDocument }; } it('closeBtn click removes overlay', () => { const { renderer } = createRenderer(); const { overlayEl, origDocument } = setupDocumentMock(); try { renderer.showFullImage(image); // The overlay has a modal child, which has a close button child const modalEl = overlayEl.children[0]; // claudian-image-modal // Children: img (index 0), closeBtn (index 1) const closeBtn = modalEl.children[1]; expect(closeBtn.hasClass('claudian-image-modal-close')).toBe(true); const removeSpy = jest.spyOn(overlayEl, 'remove'); closeBtn.click(); expect(removeSpy).toHaveBeenCalled(); } finally { (globalThis as any).document = origDocument; } }); it('clicking overlay background removes overlay', () => { const { renderer } = createRenderer(); const { overlayEl, origDocument } = setupDocumentMock(); try { renderer.showFullImage(image); const removeSpy = jest.spyOn(overlayEl, 'remove'); // Simulate click on the overlay itself (e.target === overlay) const clickHandlers = overlayEl._eventListeners.get('click'); expect(clickHandlers).toBeDefined(); clickHandlers![0]({ target: overlayEl }); expect(removeSpy).toHaveBeenCalled(); } finally { (globalThis as any).document = origDocument; } }); it('ESC key removes overlay', () => { const { renderer } = createRenderer(); const { overlayEl, docListeners, origDocument } = setupDocumentMock(); try { renderer.showFullImage(image); const removeSpy = jest.spyOn(overlayEl, 'remove'); // Simulate ESC key press via the document keydown listener const keydownHandlers = docListeners.get('keydown'); expect(keydownHandlers).toBeDefined(); expect(keydownHandlers!.length).toBeGreaterThan(0); keydownHandlers![0]({ key: 'Escape' }); expect(removeSpy).toHaveBeenCalled(); // After close, the keydown handler should be removed expect(document.removeEventListener).toHaveBeenCalledWith('keydown', expect.any(Function)); } finally { (globalThis as any).document = origDocument; } }); }); // ============================================ // renderContent - code block wrapping (error path) // ============================================ describe('renderContent - error handling', () => { it('renderContent shows error div when MarkdownRenderer throws', async () => { const { MarkdownRenderer } = await import('obsidian'); (MarkdownRenderer.renderMarkdown as jest.Mock).mockRejectedValueOnce( new Error('Render failed') ); const { renderer } = createRenderer(); const el = createMockEl(); await renderer.renderContent(el, '**broken markdown**'); const errorDiv = el.children.find( (c: any) => c.hasClass('claudian-render-error') ); expect(errorDiv).toBeDefined(); expect(errorDiv!.textContent).toBe('Failed to render message content.'); }); }); // ============================================ // addTextCopyButton - rapid click handling // ============================================ describe('addTextCopyButton - rapid click handling', () => { let originalNavigator: Navigator; beforeEach(() => { originalNavigator = globalThis.navigator; jest.useFakeTimers(); Object.defineProperty(globalThis, 'navigator', { value: { clipboard: { writeText: jest.fn().mockResolvedValue(undefined) } }, writable: true, configurable: true, }); }); afterEach(() => { jest.useRealTimers(); Object.defineProperty(globalThis, 'navigator', { value: originalNavigator, writable: true, configurable: true, }); }); it('rapid clicks clear previous timeout', async () => { const { renderer } = createRenderer(); const textEl = createMockEl(); const clearTimeoutSpy = jest.spyOn(globalThis, 'clearTimeout'); renderer.addTextCopyButton(textEl, 'content to copy'); const copyBtn = textEl.children[0]; const clickHandlers = copyBtn._eventListeners.get('click'); expect(clickHandlers).toBeDefined(); // First click await clickHandlers![0]({ stopPropagation: jest.fn() }); expect(copyBtn.textContent).toBe('copied!'); // Second rapid click before timeout expires await clickHandlers![0]({ stopPropagation: jest.fn() }); // clearTimeout should have been called for the first pending timeout expect(clearTimeoutSpy).toHaveBeenCalled(); expect(copyBtn.textContent).toBe('copied!'); clearTimeoutSpy.mockRestore(); }); it('feedback timeout restores icon after delay', async () => { const { renderer } = createRenderer(); const textEl = createMockEl(); renderer.addTextCopyButton(textEl, 'content to copy'); const copyBtn = textEl.children[0]; const originalInnerHTML = copyBtn.innerHTML; const clickHandlers = copyBtn._eventListeners.get('click'); // Click to copy await clickHandlers![0]({ stopPropagation: jest.fn() }); expect(copyBtn.textContent).toBe('copied!'); expect(copyBtn.classList.contains('copied')).toBe(true); // Advance timers by 1500ms (the feedback duration) jest.advanceTimersByTime(1500); // Icon should be restored and copied class removed expect(copyBtn.innerHTML).toBe(originalInnerHTML); expect(copyBtn.classList.contains('copied')).toBe(false); }); }); // ============================================ // renderContent - code block wrapping // ============================================ describe('renderContent - code block wrapping', () => { it('should wrap pre elements in code wrapper divs', async () => { const { MarkdownRenderer } = await import('obsidian'); const { renderer } = createRenderer(); const el = createMockEl(); // Mock renderMarkdown to create a pre element in the container (MarkdownRenderer.renderMarkdown as jest.Mock).mockImplementationOnce( async (_md: string, container: any) => { const pre = container.createEl('pre'); pre.createEl('code', { text: 'console.log("hello")' }); } ); await renderer.renderContent(el, '```js\nconsole.log("hello")\n```'); // The pre should be wrapped in a claudian-code-wrapper // Due to mock limitations, check that querySelectorAll was called on el // The actual wrapping logic runs on real DOM, but the mock captures calls expect(MarkdownRenderer.renderMarkdown).toHaveBeenCalled(); }); it('should skip wrapping already-wrapped pre elements', async () => { const { MarkdownRenderer } = await import('obsidian'); const { renderer } = createRenderer(); const el = createMockEl(); // Mock renderMarkdown to create an already-wrapped pre element (MarkdownRenderer.renderMarkdown as jest.Mock).mockImplementationOnce( async (_md: string, container: any) => { const wrapper = container.createDiv({ cls: 'claudian-code-wrapper' }); wrapper.createEl('pre'); } ); await renderer.renderContent(el, '```\nalready wrapped\n```'); // Should not throw and should complete normally expect(MarkdownRenderer.renderMarkdown).toHaveBeenCalled(); }); }); // ============================================ // renderMessageImages - click handler // ============================================ describe('renderMessageImages - click handler', () => { it('should add click handler on image elements', () => { const containerEl = createMockEl(); const { renderer } = createRenderer(); const showFullImageSpy = jest.spyOn(renderer, 'showFullImage').mockImplementation(() => {}); jest.spyOn(renderer, 'setImageSrc').mockImplementation(() => {}); const images: ImageAttachment[] = [ { id: 'img-1', name: 'photo.png', mediaType: 'image/png', data: 'base64data', size: 200, source: 'file' }, ]; renderer.renderMessageImages(containerEl, images); // Find the img element and check for click handler const imagesContainer = containerEl.children[0]; const wrapper = imagesContainer.children[0]; const imgEl = wrapper.children[0]; // The img element // Check click handler is registered const clickHandlers = imgEl._eventListeners?.get('click'); expect(clickHandlers).toBeDefined(); expect(clickHandlers!.length).toBe(1); // Trigger click and verify showFullImage is called clickHandlers![0](); expect(showFullImageSpy).toHaveBeenCalledWith(images[0]); }); }); // ============================================ // renderContent - code block wrapping with language labels // ============================================ describe('renderContent - language label and copy', () => { it('should add language label when code block has language class', async () => { const { MarkdownRenderer } = await import('obsidian'); const { renderer } = createRenderer(); const el = createMockEl(); (MarkdownRenderer.renderMarkdown as jest.Mock).mockImplementationOnce( async (_md: string, container: any) => { const pre = container.createEl('pre'); const code = pre.createEl('code'); code.className = 'language-typescript'; code.textContent = 'const x = 1;'; } ); await renderer.renderContent(el, '```typescript\nconst x = 1;\n```'); expect(MarkdownRenderer.renderMarkdown).toHaveBeenCalled(); }); it('should move copy-code-button outside pre into wrapper', async () => { const { MarkdownRenderer } = await import('obsidian'); const { renderer } = createRenderer(); const el = createMockEl(); (MarkdownRenderer.renderMarkdown as jest.Mock).mockImplementationOnce( async (_md: string, container: any) => { const pre = container.createEl('pre'); pre.createEl('code', { text: 'some code' }); const copyBtn = pre.createEl('button'); copyBtn.className = 'copy-code-button'; } ); await renderer.renderContent(el, '```\nsome code\n```'); expect(MarkdownRenderer.renderMarkdown).toHaveBeenCalled(); }); }); // ============================================ // addMessage - displayContent for user messages // ============================================ it('addMessage renders displayContent instead of content when available', () => { const messagesEl = createMockEl(); const { renderer } = createRenderer(messagesEl); const renderContentSpy = jest.spyOn(renderer, 'renderContent').mockResolvedValue(undefined); const msg: ChatMessage = { id: 'u1', role: 'user', content: 'full prompt with context', displayContent: 'user input only', timestamp: Date.now(), }; renderer.addMessage(msg); expect(renderContentSpy).toHaveBeenCalledWith(expect.anything(), 'user input only'); }); // ============================================ // renderStoredThinkingBlock - durationSeconds parameter // ============================================ describe('renderStoredThinkingBlock - durationSeconds parameter', () => { it('should pass durationSeconds to renderStoredThinkingBlock', () => { const messagesEl = createMockEl(); const { renderer } = createRenderer(messagesEl); jest.spyOn(renderer, 'renderContent').mockResolvedValue(undefined); (renderStoredThinkingBlock as jest.Mock).mockClear(); const msg: ChatMessage = { id: 'm1', role: 'assistant', content: '', timestamp: Date.now(), contentBlocks: [ { type: 'thinking', content: 'deep thought', durationSeconds: 42 } as any, ], }; renderer.renderStoredMessage(msg); expect(renderStoredThinkingBlock).toHaveBeenCalledWith( expect.anything(), 'deep thought', 42, expect.any(Function) ); }); it('should pass undefined durationSeconds when not set', () => { const messagesEl = createMockEl(); const { renderer } = createRenderer(messagesEl); jest.spyOn(renderer, 'renderContent').mockResolvedValue(undefined); (renderStoredThinkingBlock as jest.Mock).mockClear(); const msg: ChatMessage = { id: 'm1', role: 'assistant', content: '', timestamp: Date.now(), contentBlocks: [ { type: 'thinking', content: 'thought without duration' } as any, ], }; renderer.renderStoredMessage(msg); expect(renderStoredThinkingBlock).toHaveBeenCalledWith( expect.anything(), 'thought without duration', undefined, expect.any(Function) ); }); }); }); ================================================ FILE: tests/unit/features/chat/rendering/SubagentRenderer.test.ts ================================================ import { createMockEl, type MockElement } from '@test/helpers/mockElement'; import { setIcon } from 'obsidian'; import type { SubagentInfo, ToolCallInfo } from '@/core/types'; import { addSubagentToolCall, createAsyncSubagentBlock, createSubagentBlock, finalizeAsyncSubagent, finalizeSubagentBlock, markAsyncSubagentOrphaned, renderStoredAsyncSubagent, renderStoredSubagent, updateAsyncSubagentRunning, updateSubagentToolResult, } from '@/features/chat/rendering/SubagentRenderer'; const getTextByClass = (el: MockElement, cls: string): string[] => { const results: string[] = []; const visit = (node: MockElement) => { if (node.hasClass(cls)) { results.push(node.textContent); } node.children.forEach(visit); }; visit(el); return results; }; describe('Sync Subagent Renderer', () => { let parentEl: MockElement; beforeEach(() => { jest.clearAllMocks(); parentEl = createMockEl('div'); }); describe('createSubagentBlock', () => { it('should start collapsed by default', () => { const state = createSubagentBlock(parentEl as any, 'task-1', { description: 'Test task' }); expect(state.info.isExpanded).toBe(false); expect((state.wrapperEl as any).hasClass('expanded')).toBe(false); }); it('should set aria-expanded to false by default', () => { const state = createSubagentBlock(parentEl as any, 'task-1', { description: 'Test task' }); expect(state.headerEl.getAttribute('aria-expanded')).toBe('false'); }); it('should hide content by default', () => { const state = createSubagentBlock(parentEl as any, 'task-1', { description: 'Test task' }); expect((state.contentEl as any).style.display).toBe('none'); }); it('should set correct ARIA attributes for accessibility', () => { const state = createSubagentBlock(parentEl as any, 'task-1', { description: 'Test task' }); expect(state.headerEl.getAttribute('role')).toBe('button'); expect(state.headerEl.getAttribute('tabindex')).toBe('0'); expect(state.headerEl.getAttribute('aria-expanded')).toBe('false'); expect(state.headerEl.getAttribute('aria-label')).toContain('click to expand'); }); it('should toggle expand/collapse on header click', () => { const state = createSubagentBlock(parentEl as any, 'task-1', { description: 'Test task' }); // Initially collapsed expect(state.info.isExpanded).toBe(false); expect((state.wrapperEl as any).hasClass('expanded')).toBe(false); expect((state.contentEl as any).style.display).toBe('none'); // Trigger click (state.headerEl as any).click(); // Should be expanded expect(state.info.isExpanded).toBe(true); expect((state.wrapperEl as any).hasClass('expanded')).toBe(true); expect((state.contentEl as any).style.display).toBe('block'); // Click again to collapse (state.headerEl as any).click(); expect(state.info.isExpanded).toBe(false); expect((state.wrapperEl as any).hasClass('expanded')).toBe(false); expect((state.contentEl as any).style.display).toBe('none'); }); it('should update aria-expanded on toggle', () => { const state = createSubagentBlock(parentEl as any, 'task-1', { description: 'Test task' }); // Initially collapsed expect(state.headerEl.getAttribute('aria-expanded')).toBe('false'); // Expand (state.headerEl as any).click(); expect(state.headerEl.getAttribute('aria-expanded')).toBe('true'); // Collapse (state.headerEl as any).click(); expect(state.headerEl.getAttribute('aria-expanded')).toBe('false'); }); it('should show description in label', () => { const state = createSubagentBlock(parentEl as any, 'task-1', { description: 'My task description' }); expect(state.labelEl.textContent).toBe('My task description'); }); it('should show tool count badge', () => { const state = createSubagentBlock(parentEl as any, 'task-1', { description: 'Test task' }); expect(state.countEl.textContent).toBe('0 tool uses'); }); }); describe('renderStoredSubagent', () => { it('should start collapsed by default', () => { const subagent: SubagentInfo = { id: 'task-1', description: 'Test task', status: 'completed', toolCalls: [], isExpanded: false, }; const wrapperEl = renderStoredSubagent(parentEl as any, subagent); expect((wrapperEl as any).hasClass('expanded')).toBe(false); }); it('should set aria-expanded to false by default', () => { const subagent: SubagentInfo = { id: 'task-1', description: 'Test task', status: 'completed', toolCalls: [], isExpanded: false, }; const wrapperEl = renderStoredSubagent(parentEl as any, subagent); const headerEl = (wrapperEl as any).children[0]; expect(headerEl.getAttribute('aria-expanded')).toBe('false'); }); it('should hide content by default', () => { const subagent: SubagentInfo = { id: 'task-1', description: 'Test task', status: 'completed', toolCalls: [], isExpanded: false, }; const wrapperEl = renderStoredSubagent(parentEl as any, subagent); const contentEl = (wrapperEl as any).children[1]; expect(contentEl.style.display).toBe('none'); }); it('should toggle expand/collapse on click', () => { const subagent: SubagentInfo = { id: 'task-1', description: 'Test task', status: 'completed', toolCalls: [], isExpanded: false, }; const wrapperEl = renderStoredSubagent(parentEl as any, subagent); const headerEl = (wrapperEl as any).children[0]; const contentEl = (wrapperEl as any).children[1]; // Initially collapsed expect((wrapperEl as any).hasClass('expanded')).toBe(false); expect(contentEl.style.display).toBe('none'); // Click to expand headerEl.click(); expect((wrapperEl as any).hasClass('expanded')).toBe(true); expect(contentEl.style.display).toBe('block'); expect(headerEl.getAttribute('aria-expanded')).toBe('true'); // Click to collapse headerEl.click(); expect((wrapperEl as any).hasClass('expanded')).toBe(false); expect(contentEl.style.display).toBe('none'); expect(headerEl.getAttribute('aria-expanded')).toBe('false'); }); }); }); describe('keyboard navigation', () => { let parentEl: MockElement; beforeEach(() => { jest.clearAllMocks(); parentEl = createMockEl('div'); }); it('should support keyboard navigation (Enter/Space) on createSubagentBlock', () => { const state = createSubagentBlock(parentEl as any, 'task-1', { description: 'Test task' }); // Simulate keydown event const keydownHandlers: Array<(e: any) => void> = []; const originalAddEventListener = state.headerEl.addEventListener; state.headerEl.addEventListener = (event: string, handler: (e: any) => void) => { if (event === 'keydown') { keydownHandlers.push(handler); } originalAddEventListener.call(state.headerEl, event, handler); }; // Re-check - the handler should already be registered // We need to dispatch a keydown event const enterEvent = { key: 'Enter', preventDefault: jest.fn() }; (state.headerEl as any).dispatchEvent({ type: 'keydown', ...enterEvent }); // The handler should have been called and expanded expect(state.info.isExpanded).toBe(true); expect((state.wrapperEl as any).hasClass('expanded')).toBe(true); // Space to collapse const spaceEvent = { key: ' ', preventDefault: jest.fn() }; (state.headerEl as any).dispatchEvent({ type: 'keydown', ...spaceEvent }); expect(state.info.isExpanded).toBe(false); expect((state.wrapperEl as any).hasClass('expanded')).toBe(false); }); it('should support keyboard navigation (Enter/Space) on renderStoredSubagent', () => { const subagent: SubagentInfo = { id: 'task-1', description: 'Test task', status: 'completed', toolCalls: [], isExpanded: false, }; const wrapperEl = renderStoredSubagent(parentEl as any, subagent); const headerEl = (wrapperEl as any).children[0]; // Simulate Enter key const enterEvent = { key: 'Enter', preventDefault: jest.fn() }; headerEl.dispatchEvent({ type: 'keydown', ...enterEvent }); expect((wrapperEl as any).hasClass('expanded')).toBe(true); // Simulate Space key to collapse const spaceEvent = { key: ' ', preventDefault: jest.fn() }; headerEl.dispatchEvent({ type: 'keydown', ...spaceEvent }); expect((wrapperEl as any).hasClass('expanded')).toBe(false); }); }); describe('Async Subagent Renderer', () => { let parentEl: MockElement; beforeEach(() => { jest.clearAllMocks(); parentEl = createMockEl('div'); }); describe('inline display behavior', () => { it('should start collapsed', () => { const state = createAsyncSubagentBlock(parentEl as any, 'task-1', { description: 'Test task' }); expect(state.info.isExpanded).toBe(false); expect((state.wrapperEl as any).hasClass('expanded')).toBe(false); }); it('should have aria-label indicating expand action', () => { const state = createAsyncSubagentBlock(parentEl as any, 'task-1', { description: 'Test task' }); expect(state.headerEl.getAttribute('aria-label')).toContain('click to expand'); }); it('should expand content when header is clicked', () => { const state = createAsyncSubagentBlock(parentEl as any, 'task-1', { description: 'Test task' }); // Initially collapsed expect(state.info.isExpanded).toBe(false); // Trigger click to expand (state.headerEl as any).click(); expect(state.info.isExpanded).toBe(true); expect((state.wrapperEl as any).hasClass('expanded')).toBe(true); }); it('should toggle expansion on repeated clicks', () => { const state = createAsyncSubagentBlock(parentEl as any, 'task-1', { description: 'Test task' }); // Click to expand (state.headerEl as any).click(); expect(state.info.isExpanded).toBe(true); // Click to collapse (state.headerEl as any).click(); expect(state.info.isExpanded).toBe(false); }); it('should expand when Enter key is pressed', () => { const state = createAsyncSubagentBlock(parentEl as any, 'task-1', { description: 'Test' }); const enterEvent = { key: 'Enter', preventDefault: jest.fn() }; (state.headerEl as any).dispatchEvent({ type: 'keydown', ...enterEvent }); expect(state.info.isExpanded).toBe(true); }); it('should expand when Space key is pressed', () => { const state = createAsyncSubagentBlock(parentEl as any, 'task-1', { description: 'Test' }); const spaceEvent = { key: ' ', preventDefault: jest.fn() }; (state.headerEl as any).dispatchEvent({ type: 'keydown', ...spaceEvent }); expect(state.info.isExpanded).toBe(true); }); }); it('shows label immediately and initializing status text', () => { const state = createAsyncSubagentBlock(parentEl as any, 'task-1', { description: 'Background job' }); expect(state.labelEl.textContent).toBe('Background job'); expect(state.statusTextEl.textContent).toBe('Initializing'); expect((state.wrapperEl as any).getClasses()).toEqual(expect.arrayContaining(['async', 'pending'])); }); it('shows prompt in content and keeps label visible while running', () => { const state = createAsyncSubagentBlock(parentEl as any, 'task-2', { description: 'Background job', prompt: 'Do the work' }); updateAsyncSubagentRunning(state, 'agent-xyz'); expect(state.labelEl.textContent).toBe('Background job'); expect(state.statusTextEl.textContent).toBe('Running in background'); const contentText = getTextByClass(state.contentEl as any, 'claudian-subagent-prompt-text')[0]; expect(contentText).toContain('Do the work'); expect((state.wrapperEl as any).getClasses()).toEqual(expect.arrayContaining(['running', 'async'])); }); it('finalizes to completed and reveals description', () => { const state = createAsyncSubagentBlock(parentEl as any, 'task-3', { description: 'Background job' }); state.info.toolCalls.push( { id: 'tool-1', name: 'Read', input: { file_path: 'a.md' }, status: 'completed', result: 'A', isExpanded: false, }, { id: 'tool-2', name: 'Grep', input: { pattern: 'x' }, status: 'completed', result: 'B', isExpanded: false, } ); updateAsyncSubagentRunning(state, 'agent-complete'); (setIcon as jest.Mock).mockClear(); finalizeAsyncSubagent(state, 'all done', false); expect(state.labelEl.textContent).toBe('Background job'); expect(state.statusTextEl.textContent).toBe(''); expect((state.wrapperEl as any).hasClass('done')).toBe(true); const contentText = getTextByClass(state.contentEl as any, 'claudian-subagent-result-output')[0]; expect(contentText).toBe('all done'); const lastIcon = (setIcon as jest.Mock).mock.calls.pop(); expect(lastIcon?.[1]).toBe('check'); }); it('finalizes to error and truncates error message', () => { const state = createAsyncSubagentBlock(parentEl as any, 'task-4', { description: 'Background job' }); updateAsyncSubagentRunning(state, 'agent-error'); (setIcon as jest.Mock).mockClear(); finalizeAsyncSubagent(state, 'failure happened', true); expect(state.statusTextEl.textContent).toBe('Error'); expect((state.wrapperEl as any).hasClass('error')).toBe(true); const contentText = getTextByClass(state.contentEl as any, 'claudian-subagent-result-output')[0]; expect(contentText).toBe('failure happened'); const lastIcon = (setIcon as jest.Mock).mock.calls.pop(); expect(lastIcon?.[1]).toBe('x'); }); it('marks async subagent as orphaned', () => { const state = createAsyncSubagentBlock(parentEl as any, 'task-5', { description: 'Background job' }); markAsyncSubagentOrphaned(state); expect(state.statusTextEl.textContent).toBe('Orphaned'); expect((state.wrapperEl as any).hasClass('orphaned')).toBe(true); const contentText = getTextByClass(state.contentEl as any, 'claudian-subagent-result-output')[0]; expect(contentText).toContain('Conversation ended before task completed'); }); describe('renderStoredAsyncSubagent', () => { it('should return wrapper element', () => { const subagent: SubagentInfo = { id: 'task-1', description: 'Test task', status: 'completed', toolCalls: [], isExpanded: false, mode: 'async', asyncStatus: 'completed', }; const wrapperEl = renderStoredAsyncSubagent(parentEl as any, subagent); expect(wrapperEl).toBeDefined(); expect((wrapperEl as any).hasClass('claudian-subagent-list')).toBe(true); }); it('should expand content when header is clicked', () => { const subagent: SubagentInfo = { id: 'task-1', description: 'Test task', status: 'completed', toolCalls: [], isExpanded: false, mode: 'async', asyncStatus: 'completed', }; const wrapperEl = renderStoredAsyncSubagent(parentEl as any, subagent); const headerEl = (wrapperEl as any).children[0]; // Click to expand headerEl.click(); expect((wrapperEl as any).hasClass('expanded')).toBe(true); }); it('should expand on Enter key', () => { const subagent: SubagentInfo = { id: 'task-1', description: 'Test task', status: 'completed', toolCalls: [], isExpanded: false, mode: 'async', asyncStatus: 'completed', }; const wrapperEl = renderStoredAsyncSubagent(parentEl as any, subagent); const headerEl = (wrapperEl as any).children[0]; const enterEvent = { key: 'Enter', preventDefault: jest.fn() }; headerEl.dispatchEvent({ type: 'keydown', ...enterEvent }); expect((wrapperEl as any).hasClass('expanded')).toBe(true); }); it('should have aria-label indicating expand action', () => { const subagent: SubagentInfo = { id: 'task-1', description: 'Test task', status: 'completed', toolCalls: [], isExpanded: false, mode: 'async', asyncStatus: 'completed', }; const wrapperEl = renderStoredAsyncSubagent(parentEl as any, subagent); const headerEl = (wrapperEl as any).children[0]; expect(headerEl.getAttribute('aria-label')).toContain('click to expand'); }); it('should toggle expansion on repeated clicks', () => { const subagent: SubagentInfo = { id: 'task-1', description: 'Test task', status: 'completed', toolCalls: [], isExpanded: false, mode: 'async', asyncStatus: 'completed', }; const wrapperEl = renderStoredAsyncSubagent(parentEl as any, subagent); const headerEl = (wrapperEl as any).children[0]; // Click to expand headerEl.click(); expect((wrapperEl as any).hasClass('expanded')).toBe(true); // Click to collapse headerEl.click(); expect((wrapperEl as any).hasClass('expanded')).toBe(false); }); it('renders error status correctly', () => { const subagent: SubagentInfo = { id: 'task-1', description: 'Failed task', status: 'error', toolCalls: [], isExpanded: false, mode: 'async', asyncStatus: 'error', }; const wrapperEl = renderStoredAsyncSubagent(parentEl as any, subagent); expect((wrapperEl as any).hasClass('error')).toBe(true); const contentText = getTextByClass(wrapperEl as any, 'claudian-subagent-result-output')[0]; expect(contentText).toBe('ERROR'); }); it('renders orphaned status correctly', () => { const subagent: SubagentInfo = { id: 'task-1', description: 'Lost task', status: 'error', toolCalls: [], isExpanded: false, mode: 'async', asyncStatus: 'orphaned', }; (setIcon as jest.Mock).mockClear(); const wrapperEl = renderStoredAsyncSubagent(parentEl as any, subagent); expect((wrapperEl as any).hasClass('error')).toBe(true); expect((wrapperEl as any).hasClass('orphaned')).toBe(true); const contentText = getTextByClass(wrapperEl as any, 'claudian-subagent-result-output')[0]; expect(contentText).toContain('Conversation ended before task completed'); // Should use alert-circle icon expect(setIcon).toHaveBeenCalledWith(expect.anything(), 'alert-circle'); }); it('renders running status with prompt', () => { const subagent: SubagentInfo = { id: 'task-1', description: 'Running task', status: 'running', toolCalls: [], isExpanded: false, mode: 'async', asyncStatus: 'running', prompt: 'Do some work', }; const wrapperEl = renderStoredAsyncSubagent(parentEl as any, subagent); expect((wrapperEl as any).hasClass('running')).toBe(true); const contentText = getTextByClass(wrapperEl as any, 'claudian-subagent-prompt-text')[0]; expect(contentText).toContain('Do some work'); }); it('renders pending status as running', () => { const subagent: SubagentInfo = { id: 'task-1', description: 'Pending task', status: 'running', toolCalls: [], isExpanded: false, mode: 'async', asyncStatus: 'pending', }; const wrapperEl = renderStoredAsyncSubagent(parentEl as any, subagent); // pending maps to running display status expect((wrapperEl as any).hasClass('running')).toBe(true); }); }); }); describe('addSubagentToolCall', () => { let parentEl: MockElement; beforeEach(() => { jest.clearAllMocks(); parentEl = createMockEl('div'); }); it('adds tool call to state and updates count', () => { const state = createSubagentBlock(parentEl as any, 'task-1', { description: 'Test task' }); const toolCall: ToolCallInfo = { id: 'tool-1', name: 'Read', input: { file_path: 'test.md' }, status: 'running', isExpanded: false, }; addSubagentToolCall(state, toolCall); expect(state.info.toolCalls).toHaveLength(1); expect(state.countEl.textContent).toBe('1 tool uses'); }); it('clears previous content and renders new tool item', () => { const state = createSubagentBlock(parentEl as any, 'task-1', { description: 'Test task' }); const toolCall1: ToolCallInfo = { id: 'tool-1', name: 'Read', input: { file_path: 'test.md' }, status: 'running', isExpanded: false, }; addSubagentToolCall(state, toolCall1); const toolCall2: ToolCallInfo = { id: 'tool-2', name: 'Grep', input: { pattern: 'test' }, status: 'running', isExpanded: false, }; addSubagentToolCall(state, toolCall2); expect(state.info.toolCalls).toHaveLength(2); expect(state.countEl.textContent).toBe('2 tool uses'); }); }); describe('updateSubagentToolResult', () => { let parentEl: MockElement; beforeEach(() => { jest.clearAllMocks(); parentEl = createMockEl('div'); }); it('updates tool call status in state', () => { const state = createSubagentBlock(parentEl as any, 'task-1', { description: 'Test task' }); const toolCall: ToolCallInfo = { id: 'tool-1', name: 'Read', input: { file_path: 'test.md' }, status: 'running', isExpanded: false, }; addSubagentToolCall(state, toolCall); const updatedToolCall: ToolCallInfo = { ...toolCall, status: 'completed', result: 'File contents here', }; updateSubagentToolResult(state, 'tool-1', updatedToolCall); expect(state.info.toolCalls[0].status).toBe('completed'); }); it('does not update tool call for non-matching tool ID', () => { const state = createSubagentBlock(parentEl as any, 'task-1', { description: 'Test task' }); const toolCall: ToolCallInfo = { id: 'tool-1', name: 'Read', input: { file_path: 'test.md' }, status: 'running', isExpanded: false, }; addSubagentToolCall(state, toolCall); updateSubagentToolResult(state, 'tool-999', { ...toolCall, id: 'tool-999', status: 'completed' }); expect(state.info.toolCalls[0].status).toBe('running'); }); }); describe('finalizeSubagentBlock', () => { let parentEl: MockElement; beforeEach(() => { jest.clearAllMocks(); parentEl = createMockEl('div'); }); it('sets status to completed and adds done class', () => { const state = createSubagentBlock(parentEl as any, 'task-1', { description: 'Test task' }); (setIcon as jest.Mock).mockClear(); finalizeSubagentBlock(state, 'All done', false); expect(state.info.status).toBe('completed'); expect(state.info.result).toBe('All done'); expect((state.wrapperEl as any).hasClass('done')).toBe(true); expect(setIcon).toHaveBeenCalledWith(expect.anything(), 'check'); }); it('sets status to error and adds error class', () => { const state = createSubagentBlock(parentEl as any, 'task-1', { description: 'Test task' }); (setIcon as jest.Mock).mockClear(); finalizeSubagentBlock(state, 'Something failed', true); expect(state.info.status).toBe('error'); expect(state.info.result).toBe('Something failed'); expect((state.wrapperEl as any).hasClass('error')).toBe(true); expect(setIcon).toHaveBeenCalledWith(expect.anything(), 'x'); }); it('keeps tool history and shows result section text', () => { const state = createSubagentBlock(parentEl as any, 'task-1', { description: 'Test task' }); // Add a tool call first to populate content addSubagentToolCall(state, { id: 'tool-1', name: 'Read', input: { file_path: 'test.md' }, status: 'running', isExpanded: false, }); finalizeSubagentBlock(state, 'Done', false); const doneText = getTextByClass(state.contentEl as any, 'claudian-subagent-result-output')[0]; expect(doneText).toBe('Done'); }); it('shows ERROR text when isError is true', () => { const state = createSubagentBlock(parentEl as any, 'task-1', { description: 'Test task' }); finalizeSubagentBlock(state, 'Error occurred', true); const errorText = getTextByClass(state.contentEl as any, 'claudian-subagent-result-output')[0]; expect(errorText).toBe('Error occurred'); }); it('updates tool count badge after finalization', () => { const state = createSubagentBlock(parentEl as any, 'task-1', { description: 'Test task' }); addSubagentToolCall(state, { id: 'tool-1', name: 'Read', input: {}, status: 'running', isExpanded: false, }); addSubagentToolCall(state, { id: 'tool-2', name: 'Grep', input: {}, status: 'running', isExpanded: false, }); finalizeSubagentBlock(state, 'Done', false); expect(state.countEl.textContent).toBe('2 tool uses'); }); }); describe('renderStoredSubagent status variants', () => { let parentEl: MockElement; beforeEach(() => { jest.clearAllMocks(); parentEl = createMockEl('div'); }); it('renders completed subagent with done class and check icon', () => { const subagent: SubagentInfo = { id: 'task-1', description: 'Completed task', status: 'completed', toolCalls: [], isExpanded: false, }; (setIcon as jest.Mock).mockClear(); const wrapperEl = renderStoredSubagent(parentEl as any, subagent); expect((wrapperEl as any).hasClass('done')).toBe(true); expect(setIcon).toHaveBeenCalledWith(expect.anything(), 'check'); const doneText = getTextByClass(wrapperEl as any, 'claudian-subagent-result-output')[0]; expect(doneText).toBe('DONE'); }); it('renders error subagent with error class and x icon', () => { const subagent: SubagentInfo = { id: 'task-1', description: 'Failed task', status: 'error', toolCalls: [], isExpanded: false, }; (setIcon as jest.Mock).mockClear(); const wrapperEl = renderStoredSubagent(parentEl as any, subagent); expect((wrapperEl as any).hasClass('error')).toBe(true); expect(setIcon).toHaveBeenCalledWith(expect.anything(), 'x'); const errorText = getTextByClass(wrapperEl as any, 'claudian-subagent-result-output')[0]; expect(errorText).toBe('ERROR'); }); it('renders running subagent with tool list', () => { const subagent: SubagentInfo = { id: 'task-1', description: 'Running task', status: 'running', toolCalls: [ { id: 'tool-1', name: 'Read', input: { file_path: 'test.md' }, status: 'completed', isExpanded: false }, { id: 'tool-2', name: 'Grep', input: { pattern: 'test' }, status: 'running', isExpanded: false }, ], isExpanded: false, }; const wrapperEl = renderStoredSubagent(parentEl as any, subagent); // Should not have done or error class expect((wrapperEl as any).hasClass('done')).toBe(false); expect((wrapperEl as any).hasClass('error')).toBe(false); }); it('renders running subagent tool call with expanded-style result', () => { const subagent: SubagentInfo = { id: 'task-1', description: 'Running task', status: 'running', toolCalls: [ { id: 'tool-1', name: 'Read', input: { file_path: 'test.md' }, status: 'completed', result: 'File contents here', isExpanded: false, }, ], isExpanded: false, }; const wrapperEl = renderStoredSubagent(parentEl as any, subagent); const contentEl = (wrapperEl as any).children[1]; // content area // Should show result text const resultTexts = getTextByClass(contentEl, 'claudian-tool-line'); expect(resultTexts.length).toBe(1); expect(resultTexts[0]).toContain('File contents here'); }); it('shows correct tool count in badge', () => { const subagent: SubagentInfo = { id: 'task-1', description: 'Task with tools', status: 'completed', toolCalls: [ { id: 'tool-1', name: 'Read', input: {}, status: 'completed', isExpanded: false }, { id: 'tool-2', name: 'Grep', input: {}, status: 'completed', isExpanded: false }, { id: 'tool-3', name: 'Edit', input: {}, status: 'completed', isExpanded: false }, ], isExpanded: false, }; const wrapperEl = renderStoredSubagent(parentEl as any, subagent); const countTexts = getTextByClass(wrapperEl as any, 'claudian-subagent-count'); expect(countTexts[0]).toBe('3 tool uses'); }); it('truncates long descriptions', () => { const longDesc = 'A'.repeat(50); const subagent: SubagentInfo = { id: 'task-1', description: longDesc, status: 'completed', toolCalls: [], isExpanded: false, }; const wrapperEl = renderStoredSubagent(parentEl as any, subagent); const labelTexts = getTextByClass(wrapperEl as any, 'claudian-subagent-label'); expect(labelTexts[0]).toBe('A'.repeat(40) + '...'); }); }); ================================================ FILE: tests/unit/features/chat/rendering/ThinkingBlockRenderer.test.ts ================================================ import { createMockEl } from '@test/helpers/mockElement'; import { createThinkingBlock, finalizeThinkingBlock, renderStoredThinkingBlock, } from '@/features/chat/rendering/ThinkingBlockRenderer'; // Mock renderContent function const mockRenderContent = jest.fn().mockResolvedValue(undefined); describe('ThinkingBlockRenderer', () => { beforeEach(() => { jest.useFakeTimers(); jest.clearAllMocks(); }); afterEach(() => { jest.useRealTimers(); }); describe('createThinkingBlock', () => { it('should show timer label', () => { const parentEl = createMockEl(); const state = createThinkingBlock(parentEl, mockRenderContent); expect(state.labelEl.textContent).toContain('Thinking'); }); it('should clean up timer on finalize', () => { const parentEl = createMockEl(); const state = createThinkingBlock(parentEl, mockRenderContent); expect(state.timerInterval).not.toBeNull(); finalizeThinkingBlock(state); expect(state.timerInterval).toBeNull(); }); }); describe('finalizeThinkingBlock', () => { it('should collapse the block when finalized', () => { const parentEl = createMockEl(); const state = createThinkingBlock(parentEl, mockRenderContent); // Manually expand first state.wrapperEl.addClass('expanded'); state.contentEl.style.display = 'block'; finalizeThinkingBlock(state); expect(state.wrapperEl.hasClass('expanded')).toBe(false); expect(state.contentEl.style.display).toBe('none'); }); it('should update label with final duration', () => { const parentEl = createMockEl(); const state = createThinkingBlock(parentEl, mockRenderContent); // Advance time by 5 seconds jest.advanceTimersByTime(5000); const duration = finalizeThinkingBlock(state); expect(duration).toBeGreaterThanOrEqual(5); expect(state.labelEl.textContent).toContain('Thought for'); }); it('should sync isExpanded state so toggle works correctly after finalize', () => { const parentEl = createMockEl(); const state = createThinkingBlock(parentEl, mockRenderContent); const header = (state.wrapperEl as any)._children[0]; // Expand the block const clickHandlers = header._eventListeners.get('click') || []; clickHandlers[0](); expect(state.isExpanded).toBe(true); expect((state.wrapperEl as any).hasClass('expanded')).toBe(true); // Finalize (which collapses) finalizeThinkingBlock(state); expect(state.isExpanded).toBe(false); expect((state.wrapperEl as any).hasClass('expanded')).toBe(false); // Now click once - should expand (not require two clicks) clickHandlers[0](); expect(state.isExpanded).toBe(true); expect((state.wrapperEl as any).hasClass('expanded')).toBe(true); expect((state.contentEl as any).style.display).toBe('block'); }); it('should update aria-expanded on finalize', () => { const parentEl = createMockEl(); const state = createThinkingBlock(parentEl, mockRenderContent); const header = (state.wrapperEl as any)._children[0]; // Expand first const clickHandlers = header._eventListeners.get('click') || []; clickHandlers[0](); expect(header.getAttribute('aria-expanded')).toBe('true'); // Finalize finalizeThinkingBlock(state); expect(header.getAttribute('aria-expanded')).toBe('false'); }); }); describe('renderStoredThinkingBlock', () => { it('should render stored block with duration label', () => { const parentEl = createMockEl(); const wrapperEl = renderStoredThinkingBlock(parentEl, 'thinking content', 10, mockRenderContent); expect(wrapperEl).toBeDefined(); }); }); }); ================================================ FILE: tests/unit/features/chat/rendering/TodoListRenderer.test.ts ================================================ import { extractLastTodosFromMessages, parseTodoInput } from '@/features/chat/rendering/TodoListRenderer'; describe('TodoListRenderer', () => { beforeEach(() => { jest.clearAllMocks(); }); describe('parseTodoInput', () => { it('should parse valid todo input', () => { const input = { todos: [ { content: 'Task 1', status: 'pending', activeForm: 'Doing Task 1' }, { content: 'Task 2', status: 'completed', activeForm: 'Doing Task 2' }, ], }; const result = parseTodoInput(input); expect(result).toHaveLength(2); expect(result![0].content).toBe('Task 1'); expect(result![1].status).toBe('completed'); }); it('should return null for invalid input', () => { expect(parseTodoInput({})).toBeNull(); expect(parseTodoInput({ todos: 'not an array' })).toBeNull(); }); it('should filter out invalid todo items', () => { const input = { todos: [ { content: 'Valid', status: 'pending', activeForm: 'Doing' }, { content: 'Invalid status', status: 'unknown' }, { status: 'pending' }, ], }; const result = parseTodoInput(input); expect(result).toHaveLength(1); expect(result![0].content).toBe('Valid'); }); it('should filter out items with empty strings', () => { const input = { todos: [ { content: '', status: 'pending', activeForm: 'Doing' }, { content: 'Valid', status: 'pending', activeForm: '' }, { content: 'Also valid', status: 'completed', activeForm: 'Done' }, ], }; const result = parseTodoInput(input); expect(result).toHaveLength(1); expect(result![0].content).toBe('Also valid'); }); }); describe('extractLastTodosFromMessages', () => { it('should return the most recent TodoWrite from conversation', () => { const messages = [ { role: 'assistant', toolCalls: [{ name: 'TodoWrite', input: { todos: [{ content: 'Old task', status: 'completed', activeForm: 'Old' }] }, }], }, { role: 'user' }, { role: 'assistant', toolCalls: [{ name: 'TodoWrite', input: { todos: [{ content: 'New task', status: 'pending', activeForm: 'New' }] }, }], }, ]; const result = extractLastTodosFromMessages(messages); expect(result).not.toBeNull(); expect(result![0].content).toBe('New task'); expect(result![0].status).toBe('pending'); }); it('should return null when no TodoWrite exists', () => { const messages = [ { role: 'assistant', toolCalls: [{ name: 'Read', input: {} }] }, { role: 'user' }, ]; expect(extractLastTodosFromMessages(messages)).toBeNull(); }); it('should return null for empty messages array', () => { expect(extractLastTodosFromMessages([])).toBeNull(); }); it('should handle messages without toolCalls', () => { const messages = [ { role: 'assistant' }, { role: 'user' }, ]; expect(extractLastTodosFromMessages(messages)).toBeNull(); }); it('should ignore user messages with toolCalls', () => { const messages = [ { role: 'user', toolCalls: [{ name: 'TodoWrite', input: { todos: [{ content: 'User task', status: 'pending', activeForm: 'Task' }] }, }], }, ]; expect(extractLastTodosFromMessages(messages)).toBeNull(); }); it('should find TodoWrite among other tool calls', () => { const messages = [ { role: 'assistant', toolCalls: [ { name: 'Read', input: {} }, { name: 'TodoWrite', input: { todos: [{ content: 'Task', status: 'in_progress', activeForm: 'Doing' }] } }, { name: 'Write', input: {} }, ], }, ]; const result = extractLastTodosFromMessages(messages); expect(result).not.toBeNull(); expect(result![0].content).toBe('Task'); }); it('should return the last TodoWrite in a message with multiple TodoWrites', () => { const messages = [ { role: 'assistant', toolCalls: [ { name: 'TodoWrite', input: { todos: [{ content: 'First', status: 'pending', activeForm: 'First' }] } }, { name: 'TodoWrite', input: { todos: [{ content: 'Last', status: 'pending', activeForm: 'Last' }] } }, ], }, ]; const result = extractLastTodosFromMessages(messages); expect(result).not.toBeNull(); expect(result![0].content).toBe('Last'); }); it('should return null when TodoWrite parsing fails', () => { const messages = [ { role: 'assistant', toolCalls: [ { name: 'TodoWrite', input: { todos: 'invalid' } }, // Invalid: todos should be array ], }, ]; const result = extractLastTodosFromMessages(messages); expect(result).toBeNull(); }); }); }); ================================================ FILE: tests/unit/features/chat/rendering/ToolCallRenderer.test.ts ================================================ import { createMockEl } from '@test/helpers/mockElement'; import { setIcon } from 'obsidian'; import type { ToolCallInfo } from '@/core/types'; import { getToolLabel, getToolName, getToolSummary, isBlockedToolResult, renderStoredToolCall, renderTodoWriteResult, renderToolCall, setToolIcon, updateToolCallResult, } from '@/features/chat/rendering/ToolCallRenderer'; // Mock obsidian jest.mock('obsidian', () => ({ setIcon: jest.fn(), })); // Helper to create a basic tool call function createToolCall(overrides: Partial<ToolCallInfo> = {}): ToolCallInfo { return { id: 'tool-123', name: 'Read', input: { file_path: '/test/file.md' }, status: 'running', ...overrides, }; } describe('ToolCallRenderer', () => { beforeEach(() => { jest.clearAllMocks(); }); describe('renderToolCall', () => { it('should store element in toolCallElements map', () => { const parentEl = createMockEl(); const toolCall = createToolCall({ id: 'test-id' }); const toolCallElements = new Map<string, HTMLElement>(); const toolEl = renderToolCall(parentEl, toolCall, toolCallElements); expect(toolCallElements.get('test-id')).toBe(toolEl); }); it('should set data-tool-id on element', () => { const parentEl = createMockEl(); const toolCall = createToolCall({ id: 'my-tool-id' }); const toolCallElements = new Map<string, HTMLElement>(); const toolEl = renderToolCall(parentEl, toolCall, toolCallElements); expect(toolEl.dataset.toolId).toBe('my-tool-id'); }); it('should set correct ARIA attributes for accessibility', () => { const parentEl = createMockEl(); const toolCall = createToolCall(); const toolCallElements = new Map<string, HTMLElement>(); const toolEl = renderToolCall(parentEl, toolCall, toolCallElements); const header = (toolEl as any)._children[0]; expect(header.getAttribute('role')).toBe('button'); expect(header.getAttribute('tabindex')).toBe('0'); }); it('should track isExpanded on toolCall object', () => { const parentEl = createMockEl(); const toolCall = createToolCall(); const toolCallElements = new Map<string, HTMLElement>(); renderToolCall(parentEl, toolCall, toolCallElements); expect(toolCall.isExpanded).toBe(false); }); }); describe('renderStoredToolCall', () => { it('should show completed status icon', () => { const parentEl = createMockEl(); const toolCall = createToolCall({ status: 'completed' }); renderStoredToolCall(parentEl, toolCall); expect(setIcon).toHaveBeenCalledWith(expect.anything(), 'check'); }); it('should show error status icon', () => { const parentEl = createMockEl(); const toolCall = createToolCall({ status: 'error' }); renderStoredToolCall(parentEl, toolCall); expect(setIcon).toHaveBeenCalledWith(expect.anything(), 'x'); }); it('renders AskUserQuestion answers from result text when resolvedAnswers is missing', () => { const parentEl = createMockEl(); const toolCall = createToolCall({ name: 'AskUserQuestion', status: 'completed', input: { questions: [{ question: 'Color?' }] }, result: '"Color?"="Blue"', }); const toolEl = renderStoredToolCall(parentEl, toolCall); const answerEls = toolEl.querySelectorAll('.claudian-ask-review-a-text'); expect(answerEls).toHaveLength(1); expect(answerEls[0].textContent).toBe('Blue'); }); }); describe('updateToolCallResult', () => { it('should update status indicator', () => { const parentEl = createMockEl(); const toolCall = createToolCall({ id: 'tool-1' }); const toolCallElements = new Map<string, HTMLElement>(); const toolEl = renderToolCall(parentEl, toolCall, toolCallElements); // Update with completed result toolCall.status = 'completed'; toolCall.result = 'Success'; updateToolCallResult('tool-1', toolCall, toolCallElements); const statusEl = toolEl.querySelector('.claudian-tool-status'); expect(statusEl?.hasClass('status-completed')).toBe(true); }); it('shows raw AskUserQuestion result when answers cannot be parsed', () => { const parentEl = createMockEl(); const toolCall = createToolCall({ id: 'ask-1', name: 'AskUserQuestion', input: { questions: [{ question: 'Color?' }] }, }); const toolCallElements = new Map<string, HTMLElement>(); const toolEl = renderToolCall(parentEl, toolCall, toolCallElements); toolCall.status = 'completed'; toolCall.result = 'Answer submitted successfully.'; updateToolCallResult('ask-1', toolCall, toolCallElements); const resultText = toolEl.querySelector('.claudian-tool-result-text'); expect(resultText?.textContent).toBe('Answer submitted successfully.'); }); }); describe('setToolIcon', () => { it('should call setIcon with the resolved icon name', () => { const el = createMockEl() as unknown as HTMLElement; setToolIcon(el, 'Read'); expect(setIcon).toHaveBeenCalledWith(el, expect.any(String)); }); it('should set MCP SVG for MCP tools', () => { const el = createMockEl(); setToolIcon(el as unknown as HTMLElement, 'mcp__server__tool'); // MCP tools get innerHTML set with the SVG expect(el.innerHTML).toContain('svg'); }); }); describe('getToolLabel', () => { it('should label Read tool with shortened path', () => { expect(getToolLabel('Read', { file_path: '/a/b/c/d/e.ts' })).toBe('Read: .../d/e.ts'); }); it('should label Read with fallback for missing path', () => { expect(getToolLabel('Read', {})).toBe('Read: file'); }); it('should label Write tool with path', () => { expect(getToolLabel('Write', { file_path: 'short.ts' })).toBe('Write: short.ts'); }); it('should label Edit tool with path', () => { expect(getToolLabel('Edit', { file_path: 'file.ts' })).toBe('Edit: file.ts'); }); it('should label Bash tool and truncate long commands', () => { const shortCmd = 'npm test'; expect(getToolLabel('Bash', { command: shortCmd })).toBe('Bash: npm test'); const longCmd = 'a'.repeat(50); expect(getToolLabel('Bash', { command: longCmd })).toBe(`Bash: ${'a'.repeat(40)}...`); }); it('should label Bash with fallback for missing command', () => { expect(getToolLabel('Bash', {})).toBe('Bash: command'); }); it('should label Glob tool', () => { expect(getToolLabel('Glob', { pattern: '**/*.ts' })).toBe('Glob: **/*.ts'); }); it('should label Glob with fallback', () => { expect(getToolLabel('Glob', {})).toBe('Glob: files'); }); it('should label Grep tool', () => { expect(getToolLabel('Grep', { pattern: 'TODO' })).toBe('Grep: TODO'); }); it('should label WebSearch and truncate long queries', () => { expect(getToolLabel('WebSearch', { query: 'short' })).toBe('WebSearch: short'); const longQuery = 'q'.repeat(50); expect(getToolLabel('WebSearch', { query: longQuery })).toBe(`WebSearch: ${'q'.repeat(40)}...`); }); it('should label WebFetch and truncate long URLs', () => { expect(getToolLabel('WebFetch', { url: 'https://x.com' })).toBe('WebFetch: https://x.com'); const longUrl = 'https://' + 'x'.repeat(50); expect(getToolLabel('WebFetch', { url: longUrl })).toBe(`WebFetch: ${longUrl.substring(0, 40)}...`); }); it('should label LS tool with path', () => { expect(getToolLabel('LS', { path: '/src' })).toBe('LS: /src'); }); it('should label LS with fallback', () => { expect(getToolLabel('LS', {})).toBe('LS: .'); }); it('should label TodoWrite with completion count', () => { const todos = [ { status: 'completed' }, { status: 'completed' }, { status: 'pending' }, ]; expect(getToolLabel('TodoWrite', { todos })).toBe('Tasks (2/3)'); }); it('should label TodoWrite without array', () => { expect(getToolLabel('TodoWrite', {})).toBe('Tasks'); }); it('should label Skill tool', () => { expect(getToolLabel('Skill', { skill: 'commit' })).toBe('Skill: commit'); }); it('should label Skill with fallback', () => { expect(getToolLabel('Skill', {})).toBe('Skill: skill'); }); it('should label ToolSearch with tool names', () => { expect(getToolLabel('ToolSearch', { query: 'select:Read,Glob' })).toBe('ToolSearch: Read, Glob'); }); it('should label ToolSearch with fallback for missing query', () => { expect(getToolLabel('ToolSearch', {})).toBe('ToolSearch: tools'); }); it('should return raw name for unknown tools', () => { expect(getToolLabel('CustomTool', {})).toBe('CustomTool'); }); }); describe('getToolName', () => { it('should return tool name for standard tools', () => { expect(getToolName('Read', {})).toBe('Read'); expect(getToolName('Write', {})).toBe('Write'); expect(getToolName('Bash', {})).toBe('Bash'); expect(getToolName('Glob', {})).toBe('Glob'); }); it('should return Tasks with count for TodoWrite', () => { const todos = [ { status: 'completed' }, { status: 'completed' }, { status: 'pending' }, ]; expect(getToolName('TodoWrite', { todos })).toBe('Tasks 2/3'); expect(getToolName('TodoWrite', {})).toBe('Tasks'); }); it('should return plan mode labels', () => { expect(getToolName('EnterPlanMode', {})).toBe('Entering plan mode'); expect(getToolName('ExitPlanMode', {})).toBe('Plan complete'); }); }); describe('getToolSummary', () => { it('should return filename-only for file tools', () => { expect(getToolSummary('Read', { file_path: '/a/b/c/file.ts' })).toBe('file.ts'); expect(getToolSummary('Write', { file_path: '/src/index.ts' })).toBe('index.ts'); expect(getToolSummary('Edit', { file_path: 'simple.md' })).toBe('simple.md'); }); it('should return empty for file tools with no path', () => { expect(getToolSummary('Read', {})).toBe(''); }); it('should return command for Bash', () => { expect(getToolSummary('Bash', { command: 'npm test' })).toBe('npm test'); }); it('should truncate long Bash commands', () => { const longCmd = 'a'.repeat(70); expect(getToolSummary('Bash', { command: longCmd })).toBe('a'.repeat(60) + '...'); }); it('should return pattern for Glob/Grep', () => { expect(getToolSummary('Glob', { pattern: '**/*.ts' })).toBe('**/*.ts'); expect(getToolSummary('Grep', { pattern: 'TODO' })).toBe('TODO'); }); it('should return query for WebSearch', () => { expect(getToolSummary('WebSearch', { query: 'test query' })).toBe('test query'); }); it('should return url for WebFetch', () => { expect(getToolSummary('WebFetch', { url: 'https://x.com' })).toBe('https://x.com'); }); it('should return filename for LS', () => { expect(getToolSummary('LS', { path: '/src/components' })).toBe('components'); }); it('should return skill name for Skill', () => { expect(getToolSummary('Skill', { skill: 'commit' })).toBe('commit'); }); it('should return empty for TodoWrite', () => { const todos = [ { status: 'completed', activeForm: 'Done' }, { status: 'in_progress', activeForm: 'Working on it' }, ]; expect(getToolSummary('TodoWrite', { todos })).toBe(''); expect(getToolSummary('TodoWrite', {})).toBe(''); }); it('should return empty for AskUserQuestion', () => { expect(getToolSummary('AskUserQuestion', { questions: [{ question: 'Q1' }] })).toBe(''); expect(getToolSummary('AskUserQuestion', { questions: [{ question: 'Q1' }, { question: 'Q2' }] })).toBe(''); }); it('should return parsed tool names for ToolSearch', () => { expect(getToolSummary('ToolSearch', { query: 'select:Read,Glob' })).toBe('Read, Glob'); expect(getToolSummary('ToolSearch', { query: 'select:Bash' })).toBe('Bash'); }); it('should return empty for ToolSearch with missing query', () => { expect(getToolSummary('ToolSearch', {})).toBe(''); }); it('should return empty for unknown tools', () => { expect(getToolSummary('CustomTool', {})).toBe(''); }); }); describe('isBlockedToolResult', () => { it.each([ 'Blocked by blocklist: /etc/passwd', 'Path is outside the vault', 'Access Denied for this file', 'User denied the action', 'Requires approval from user', ])('should detect blocked result: %s', (result) => { expect(isBlockedToolResult(result)).toBe(true); }); it('should detect "deny" only when isError is true', () => { expect(isBlockedToolResult('deny permission', true)).toBe(true); expect(isBlockedToolResult('deny permission', false)).toBe(false); expect(isBlockedToolResult('deny permission')).toBe(false); }); it('should return false for normal results', () => { expect(isBlockedToolResult('File content here')).toBe(false); }); }); describe('renderTodoWriteResult', () => { it('should render todo items', () => { const container = createMockEl(); const input = { todos: [ { status: 'completed', content: 'Task 1', activeForm: 'Task 1' }, { status: 'pending', content: 'Task 2', activeForm: 'Task 2' }, ], }; renderTodoWriteResult(container as unknown as HTMLElement, input); expect(container.hasClass('claudian-todo-panel-content')).toBe(true); expect(container.hasClass('claudian-todo-list-container')).toBe(true); }); it('should show fallback text when no todos array', () => { const container = createMockEl(); renderTodoWriteResult(container as unknown as HTMLElement, {}); expect(container._children[0].textContent).toBe('Tasks updated'); }); it('should show fallback text for non-array todos', () => { const container = createMockEl(); renderTodoWriteResult(container as unknown as HTMLElement, { todos: 'invalid' }); expect(container._children[0].textContent).toBe('Tasks updated'); }); }); describe('updateToolCallResult for TodoWrite', () => { it('should update todo status and content', () => { const parentEl = createMockEl(); const toolCall = createToolCall({ id: 'todo-1', name: 'TodoWrite', input: { todos: [ { status: 'in_progress', content: 'Task 1', activeForm: 'Working' }, ], }, }); const toolCallElements = new Map<string, HTMLElement>(); renderToolCall(parentEl, toolCall, toolCallElements); // Update with all completed toolCall.input = { todos: [ { status: 'completed', content: 'Task 1', activeForm: 'Done' }, ], }; updateToolCallResult('todo-1', toolCall, toolCallElements); const statusEl = parentEl.querySelector('.claudian-tool-status'); expect(statusEl?.hasClass('status-completed')).toBe(true); }); it('should do nothing for non-existent tool id', () => { const toolCallElements = new Map<string, HTMLElement>(); updateToolCallResult('nonexistent', createToolCall(), toolCallElements); expect(toolCallElements.size).toBe(0); }); }); describe('renderStoredToolCall for TodoWrite', () => { it('should render stored TodoWrite with status', () => { const parentEl = createMockEl(); const toolCall = createToolCall({ name: 'TodoWrite', status: 'completed', input: { todos: [ { status: 'completed', content: 'Task 1', activeForm: 'Done' }, ], }, }); const toolEl = renderStoredToolCall(parentEl, toolCall); expect(toolEl).toBeDefined(); }); }); }); ================================================ FILE: tests/unit/features/chat/rendering/WriteEditRenderer.test.ts ================================================ import { createMockEl } from '@test/helpers/mockElement'; import type { ToolCallInfo, ToolDiffData } from '@/core/types'; import { createWriteEditBlock, finalizeWriteEditBlock, renderStoredWriteEdit, updateWriteEditWithDiff, } from '@/features/chat/rendering/WriteEditRenderer'; // Helper to create a basic tool call function createToolCall(overrides: Partial<ToolCallInfo> = {}): ToolCallInfo { return { id: 'tool-123', name: 'Write', input: { file_path: '/test/vault/notes/test.md', content: 'new content' }, status: 'running', ...overrides, }; } // Helper to create pre-computed diff data function createDiffData(overrides: Partial<ToolDiffData> = {}): ToolDiffData { return { filePath: 'test.md', diffLines: [ { type: 'equal', text: 'line1', oldLineNum: 1, newLineNum: 1 }, { type: 'delete', text: 'old', oldLineNum: 2 }, { type: 'insert', text: 'new', newLineNum: 2 }, ], stats: { added: 1, removed: 1 }, ...overrides, }; } describe('WriteEditRenderer', () => { describe('createWriteEditBlock', () => { it('should create a block with correct structure', () => { const parentEl = createMockEl(); const toolCall = createToolCall(); const state = createWriteEditBlock(parentEl, toolCall); expect(state.wrapperEl).toBeDefined(); expect(state.headerEl).toBeDefined(); expect(state.nameEl).toBeDefined(); expect(state.summaryEl).toBeDefined(); expect(state.statsEl).toBeDefined(); expect(state.statusEl).toBeDefined(); expect(state.contentEl).toBeDefined(); expect(state.toolCall).toBe(toolCall); }); it('should set data-tool-id on wrapper', () => { const parentEl = createMockEl(); const toolCall = createToolCall({ id: 'my-tool-id' }); const state = createWriteEditBlock(parentEl, toolCall); expect(state.wrapperEl.dataset.toolId).toBe('my-tool-id'); }); it('should display tool name and filename in two-part header', () => { const parentEl = createMockEl(); const toolCall = createToolCall({ name: 'Edit', input: { file_path: 'notes/test.md' }, }); const state = createWriteEditBlock(parentEl, toolCall); expect(state.nameEl.textContent).toBe('Edit'); expect(state.summaryEl.textContent).toBe('test.md'); }); it('should show spinner status while running', () => { const parentEl = createMockEl(); const toolCall = createToolCall(); const state = createWriteEditBlock(parentEl, toolCall); expect(state.statusEl.hasClass('status-running')).toBe(true); }); it('should show filename only in summary', () => { const parentEl = createMockEl(); const toolCall = createToolCall({ input: { file_path: '/very/long/path/to/some/deeply/nested/file.md' }, }); const state = createWriteEditBlock(parentEl, toolCall); // Summary should show just the filename expect(state.summaryEl.textContent).toBe('file.md'); }); it('should handle missing file_path gracefully', () => { const parentEl = createMockEl(); const toolCall = createToolCall({ input: {} }); const state = createWriteEditBlock(parentEl, toolCall); expect(state.summaryEl.textContent).toBe('file'); }); }); describe('updateWriteEditWithDiff', () => { it('should render diff stats when diff data is provided', () => { const parentEl = createMockEl(); const toolCall = createToolCall(); const state = createWriteEditBlock(parentEl, toolCall); const diffData = createDiffData({ stats: { added: 1, removed: 0 }, }); updateWriteEditWithDiff(state, diffData); // Should show +1 for added line expect((state.statsEl as any)._children.length).toBeGreaterThan(0); }); it('should store diffLines in state', () => { const parentEl = createMockEl(); const toolCall = createToolCall(); const state = createWriteEditBlock(parentEl, toolCall); const diffData = createDiffData(); updateWriteEditWithDiff(state, diffData); expect(state.diffLines).toBeDefined(); expect(state.diffLines!.length).toBeGreaterThan(0); }); it('should show both added and removed counts', () => { const parentEl = createMockEl(); const toolCall = createToolCall(); const state = createWriteEditBlock(parentEl, toolCall); const diffData = createDiffData({ stats: { added: 3, removed: 2 }, }); updateWriteEditWithDiff(state, diffData); // Should have stats children expect((state.statsEl as any)._children.length).toBeGreaterThan(0); }); it('should handle empty diffLines with zero stats', () => { const parentEl = createMockEl(); const toolCall = createToolCall(); const state = createWriteEditBlock(parentEl, toolCall); const diffData = createDiffData({ diffLines: [], stats: { added: 0, removed: 0 }, }); updateWriteEditWithDiff(state, diffData); // Should not have stats children when no changes expect((state.statsEl as any)._children.length).toBe(0); }); }); describe('finalizeWriteEditBlock', () => { it('should update status to done on success', () => { const parentEl = createMockEl(); const toolCall = createToolCall(); const state = createWriteEditBlock(parentEl, toolCall); // Add diff data first updateWriteEditWithDiff(state, createDiffData()); finalizeWriteEditBlock(state, false); expect(state.wrapperEl.hasClass('done')).toBe(true); expect(state.statusEl.hasClass('status-running')).toBe(false); }); it('should update status to error on failure', () => { const parentEl = createMockEl(); const toolCall = createToolCall({ result: 'Error: file not found' }); const state = createWriteEditBlock(parentEl, toolCall); finalizeWriteEditBlock(state, true); expect(state.wrapperEl.hasClass('error')).toBe(true); expect(state.statusEl.hasClass('status-error')).toBe(true); }); it('should show error message in content when no diff', () => { const parentEl = createMockEl(); const toolCall = createToolCall({ result: 'Permission denied' }); const state = createWriteEditBlock(parentEl, toolCall); finalizeWriteEditBlock(state, true); const contentText = getTextContent(state.contentEl); expect(contentText).toContain('Permission denied'); }); it('should clear spinner status on finalize', () => { const parentEl = createMockEl(); const toolCall = createToolCall(); const state = createWriteEditBlock(parentEl, toolCall); finalizeWriteEditBlock(state, false); expect(state.statusEl.hasClass('status-running')).toBe(false); expect((state.statusEl as any)._children.length).toBe(0); }); }); describe('renderStoredWriteEdit', () => { it('should show done state for completed status', () => { const parentEl = createMockEl(); const toolCall = createToolCall({ status: 'completed' }); const block = renderStoredWriteEdit(parentEl, toolCall); expect(block.hasClass('done')).toBe(true); }); it('should show error state for error status', () => { const parentEl = createMockEl(); const toolCall = createToolCall({ status: 'error' }); const block = renderStoredWriteEdit(parentEl, toolCall); expect(block.hasClass('error')).toBe(true); }); it('should show error state for blocked status', () => { const parentEl = createMockEl(); const toolCall = createToolCall({ status: 'blocked' }); const block = renderStoredWriteEdit(parentEl, toolCall); expect(block.hasClass('error')).toBe(true); }); it('should render diff stats from stored diffData', () => { const parentEl = createMockEl(); const toolCall = createToolCall({ status: 'completed', diffData: createDiffData({ stats: { added: 2, removed: 1 }, }), }); const block = renderStoredWriteEdit(parentEl, toolCall); // Block should be created successfully with stats expect(block.dataset.toolId).toBe('tool-123'); }); it('should handle stored block with empty diffLines', () => { const parentEl = createMockEl(); const toolCall = createToolCall({ status: 'completed', diffData: createDiffData({ diffLines: [], stats: { added: 0, removed: 0 }, }), }); const block = renderStoredWriteEdit(parentEl, toolCall); expect(block).toBeDefined(); }); it('should show error message when no diffData and error', () => { const parentEl = createMockEl(); const toolCall = createToolCall({ status: 'error', result: 'File not found', }); const block = renderStoredWriteEdit(parentEl, toolCall); expect(block.hasClass('error')).toBe(true); }); it('should use correct icon for Edit tool', () => { const parentEl = createMockEl(); const toolCall = createToolCall({ name: 'Edit' }); const block = renderStoredWriteEdit(parentEl, toolCall); // Block should render for Edit tool expect(block).toBeDefined(); }); }); describe('filename extraction', () => { it('should extract filename from path', () => { const parentEl = createMockEl(); const toolCall = createToolCall({ input: { file_path: 'notes/test.md' }, }); const state = createWriteEditBlock(parentEl, toolCall); expect(state.summaryEl.textContent).toBe('test.md'); }); it('should extract filename from long path', () => { const parentEl = createMockEl(); const longPath = 'src/components/features/auth/modals/confirmation/ConfirmationDialog.tsx'; const toolCall = createToolCall({ input: { file_path: longPath }, }); const state = createWriteEditBlock(parentEl, toolCall); expect(state.summaryEl.textContent).toBe('ConfirmationDialog.tsx'); }); it('should handle paths with only filename', () => { const parentEl = createMockEl(); const toolCall = createToolCall({ input: { file_path: 'README.md' }, }); const state = createWriteEditBlock(parentEl, toolCall); expect(state.summaryEl.textContent).toBe('README.md'); }); }); describe('diff rendering', () => { it('should render new file correctly (all inserts)', () => { const parentEl = createMockEl(); const toolCall = createToolCall(); const state = createWriteEditBlock(parentEl, toolCall); const diffData: ToolDiffData = { filePath: 'test.md', diffLines: [ { type: 'insert', text: 'new content', newLineNum: 1 }, { type: 'insert', text: 'line 2', newLineNum: 2 }, ], stats: { added: 2, removed: 0 }, }; updateWriteEditWithDiff(state, diffData); // Should show +2 for two new lines expect(state.diffLines).toBeDefined(); expect(state.diffLines!.filter(l => l.type === 'insert').length).toBe(2); }); it('should handle file deletion (all deletes)', () => { const parentEl = createMockEl(); const toolCall = createToolCall(); const state = createWriteEditBlock(parentEl, toolCall); const diffData: ToolDiffData = { filePath: 'test.md', diffLines: [ { type: 'delete', text: 'content', oldLineNum: 1 }, ], stats: { added: 0, removed: 1 }, }; updateWriteEditWithDiff(state, diffData); expect(state.diffLines).toBeDefined(); expect(state.diffLines!.filter(l => l.type === 'delete').length).toBe(1); }); it('should handle mixed changes', () => { const parentEl = createMockEl(); const toolCall = createToolCall(); const state = createWriteEditBlock(parentEl, toolCall); const diffData: ToolDiffData = { filePath: 'test.md', diffLines: [ { type: 'equal', text: 'line1', oldLineNum: 1, newLineNum: 1 }, { type: 'delete', text: 'old', oldLineNum: 2 }, { type: 'insert', text: 'new1', newLineNum: 2 }, { type: 'insert', text: 'new2', newLineNum: 3 }, { type: 'equal', text: 'line3', oldLineNum: 3, newLineNum: 4 }, ], stats: { added: 2, removed: 1 }, }; updateWriteEditWithDiff(state, diffData); expect(state.diffLines).toBeDefined(); const types = state.diffLines!.reduce( (acc, l) => { acc[l.type] = (acc[l.type] || 0) + 1; return acc; }, {} as Record<string, number> ); expect(types.delete).toBe(1); expect(types.insert).toBe(2); expect(types.equal).toBe(2); }); }); }); // Helper to get text content recursively function getTextContent(element: any): string { let text = element.textContent || ''; if (element._children) { for (const child of element._children) { text += getTextContent(child); } } return text; } ================================================ FILE: tests/unit/features/chat/rendering/collapsible.test.ts ================================================ import { createMockEl } from '@test/helpers/mockElement'; import { collapseElement, type CollapsibleState, setupCollapsible, } from '@/features/chat/rendering/collapsible'; describe('collapsible', () => { let wrapper: ReturnType<typeof createMockEl>; let header: ReturnType<typeof createMockEl>; let content: ReturnType<typeof createMockEl>; let state: CollapsibleState; beforeEach(() => { wrapper = createMockEl(); header = createMockEl(); content = createMockEl(); state = { isExpanded: false }; }); describe('setupCollapsible', () => { it('should start collapsed by default', () => { setupCollapsible(wrapper, header, content, state); expect(state.isExpanded).toBe(false); expect(content.style.display).toBe('none'); expect(header.getAttribute('aria-expanded')).toBe('false'); expect(wrapper.hasClass('expanded')).toBe(false); }); it('should start expanded when initiallyExpanded is true', () => { setupCollapsible(wrapper, header, content, state, { initiallyExpanded: true }); expect(state.isExpanded).toBe(true); expect(content.style.display).toBe('block'); expect(header.getAttribute('aria-expanded')).toBe('true'); expect(wrapper.hasClass('expanded')).toBe(true); }); it('should toggle on click', () => { setupCollapsible(wrapper, header, content, state); const clickHandlers = header._eventListeners.get('click') || []; expect(clickHandlers.length).toBe(1); // Expand clickHandlers[0](); expect(state.isExpanded).toBe(true); expect(wrapper.hasClass('expanded')).toBe(true); expect(content.style.display).toBe('block'); expect(header.getAttribute('aria-expanded')).toBe('true'); // Collapse clickHandlers[0](); expect(state.isExpanded).toBe(false); expect(wrapper.hasClass('expanded')).toBe(false); expect(content.style.display).toBe('none'); expect(header.getAttribute('aria-expanded')).toBe('false'); }); it('should toggle on Enter key', () => { setupCollapsible(wrapper, header, content, state); const keydownHandlers = header._eventListeners.get('keydown') || []; const event = { key: 'Enter', preventDefault: jest.fn() }; keydownHandlers[0](event); expect(event.preventDefault).toHaveBeenCalled(); expect(state.isExpanded).toBe(true); }); it('should toggle on Space key', () => { setupCollapsible(wrapper, header, content, state); const keydownHandlers = header._eventListeners.get('keydown') || []; const event = { key: ' ', preventDefault: jest.fn() }; keydownHandlers[0](event); expect(event.preventDefault).toHaveBeenCalled(); expect(state.isExpanded).toBe(true); }); it('should not toggle on other keys', () => { setupCollapsible(wrapper, header, content, state); const keydownHandlers = header._eventListeners.get('keydown') || []; const event = { key: 'Tab', preventDefault: jest.fn() }; keydownHandlers[0](event); expect(event.preventDefault).not.toHaveBeenCalled(); expect(state.isExpanded).toBe(false); }); it('should call onToggle callback with new state', () => { const onToggle = jest.fn(); setupCollapsible(wrapper, header, content, state, { onToggle }); const clickHandlers = header._eventListeners.get('click') || []; clickHandlers[0](); expect(onToggle).toHaveBeenCalledWith(true); clickHandlers[0](); expect(onToggle).toHaveBeenCalledWith(false); }); it('should set aria-label with baseAriaLabel', () => { setupCollapsible(wrapper, header, content, state, { baseAriaLabel: 'Read: file.ts' }); expect(header.getAttribute('aria-label')).toBe('Read: file.ts - click to expand'); // Expand and check label changes const clickHandlers = header._eventListeners.get('click') || []; clickHandlers[0](); expect(header.getAttribute('aria-label')).toBe('Read: file.ts - click to collapse'); }); it('should set aria-label for initially expanded with baseAriaLabel', () => { setupCollapsible(wrapper, header, content, state, { initiallyExpanded: true, baseAriaLabel: 'Tool', }); expect(header.getAttribute('aria-label')).toBe('Tool - click to collapse'); }); it('should not set aria-label without baseAriaLabel', () => { setupCollapsible(wrapper, header, content, state); expect(header.getAttribute('aria-label')).toBeNull(); }); }); describe('collapseElement', () => { it('should collapse an expanded element', () => { state.isExpanded = true; wrapper.addClass('expanded'); content.style.display = 'block'; header.setAttribute('aria-expanded', 'true'); collapseElement(wrapper, header, content, state); expect(state.isExpanded).toBe(false); expect(wrapper.hasClass('expanded')).toBe(false); expect(content.style.display).toBe('none'); expect(header.getAttribute('aria-expanded')).toBe('false'); }); it('should be safe to call on already collapsed element', () => { collapseElement(wrapper, header, content, state); expect(state.isExpanded).toBe(false); expect(content.style.display).toBe('none'); }); }); }); ================================================ FILE: tests/unit/features/chat/rendering/todoUtils.test.ts ================================================ import { createMockEl } from '@test/helpers/mockElement'; import { setIcon } from 'obsidian'; import type { TodoItem } from '@/core/tools'; import { getTodoDisplayText, getTodoStatusIcon, renderTodoItems, } from '@/features/chat/rendering/todoUtils'; jest.mock('obsidian', () => ({ setIcon: jest.fn(), })); describe('todoUtils', () => { beforeEach(() => { jest.clearAllMocks(); }); describe('getTodoStatusIcon', () => { it('should return "check" for completed', () => { expect(getTodoStatusIcon('completed')).toBe('check'); }); it('should return "dot" for pending', () => { expect(getTodoStatusIcon('pending')).toBe('dot'); }); it('should return "dot" for in_progress', () => { expect(getTodoStatusIcon('in_progress')).toBe('dot'); }); }); describe('getTodoDisplayText', () => { it('should return activeForm for in_progress', () => { const todo: TodoItem = { status: 'in_progress', content: 'Fix bug', activeForm: 'Fixing bug' }; expect(getTodoDisplayText(todo)).toBe('Fixing bug'); }); it('should return content for completed', () => { const todo: TodoItem = { status: 'completed', content: 'Fix bug', activeForm: 'Fixing bug' }; expect(getTodoDisplayText(todo)).toBe('Fix bug'); }); it('should return content for pending', () => { const todo: TodoItem = { status: 'pending', content: 'Fix bug', activeForm: 'Fixing bug' }; expect(getTodoDisplayText(todo)).toBe('Fix bug'); }); }); describe('renderTodoItems', () => { it('should render todo items with status icons and text', () => { const container = createMockEl(); const todos: TodoItem[] = [ { status: 'completed', content: 'Task 1', activeForm: 'Doing Task 1' }, { status: 'in_progress', content: 'Task 2', activeForm: 'Doing Task 2' }, { status: 'pending', content: 'Task 3', activeForm: 'Doing Task 3' }, ]; renderTodoItems(container as unknown as HTMLElement, todos); expect(container._children.length).toBe(3); expect(setIcon).toHaveBeenCalledTimes(3); // First item: completed expect(container._children[0].hasClass('claudian-todo-completed')).toBe(true); expect(setIcon).toHaveBeenCalledWith(expect.anything(), 'check'); // Second item: in_progress shows activeForm expect(container._children[1].hasClass('claudian-todo-in_progress')).toBe(true); // Third item: pending expect(container._children[2].hasClass('claudian-todo-pending')).toBe(true); }); it('should clear container before rendering', () => { const container = createMockEl(); container.createDiv({ text: 'old content' }); renderTodoItems(container as unknown as HTMLElement, [ { status: 'completed', content: 'New', activeForm: 'New' }, ]); // Should have exactly 1 child (old cleared, new added) expect(container._children.length).toBe(1); }); it('should handle empty todos array', () => { const container = createMockEl(); renderTodoItems(container as unknown as HTMLElement, []); expect(container._children.length).toBe(0); }); it('should set aria-hidden on status icon', () => { const container = createMockEl(); renderTodoItems(container as unknown as HTMLElement, [ { status: 'completed', content: 'Task', activeForm: 'Task' }, ]); const item = container._children[0]; const icon = item._children[0]; expect(icon.getAttribute('aria-hidden')).toBe('true'); }); }); }); ================================================ FILE: tests/unit/features/chat/rewind.test.ts ================================================ import type { ChatMessage } from '@/core/types'; import { findRewindContext } from '@/features/chat/rewind'; describe('findRewindContext', () => { it('finds the nearest previous assistant UUID and detects a following response UUID', () => { const messages: ChatMessage[] = [ { id: 'a0', role: 'assistant', content: 'no uuid', timestamp: 1 }, { id: 'a1', role: 'assistant', content: 'prev', timestamp: 2, sdkAssistantUuid: 'prev-a' }, { id: 'u1', role: 'user', content: 'user', timestamp: 3, sdkUserUuid: 'user-u' }, { id: 'a2', role: 'assistant', content: 'no uuid', timestamp: 4 }, { id: 'a3', role: 'assistant', content: 'resp', timestamp: 5, sdkAssistantUuid: 'resp-a' }, ]; const ctx = findRewindContext(messages, 2); expect(ctx.prevAssistantUuid).toBe('prev-a'); expect(ctx.hasResponse).toBe(true); }); it('does not treat assistants after the next user message as a response', () => { const messages: ChatMessage[] = [ { id: 'a1', role: 'assistant', content: 'prev', timestamp: 1, sdkAssistantUuid: 'prev-a' }, { id: 'u1', role: 'user', content: 'user', timestamp: 2, sdkUserUuid: 'user-u' }, { id: 'a2', role: 'assistant', content: 'no uuid', timestamp: 3 }, { id: 'u2', role: 'user', content: 'next user', timestamp: 4, sdkUserUuid: 'user-u2' }, { id: 'a3', role: 'assistant', content: 'later resp', timestamp: 5, sdkAssistantUuid: 'resp-a' }, ]; const ctx = findRewindContext(messages, 1); expect(ctx.prevAssistantUuid).toBe('prev-a'); expect(ctx.hasResponse).toBe(false); }); it('returns prevAssistantUuid as undefined when no prior assistant UUID exists', () => { const messages: ChatMessage[] = [ { id: 'u1', role: 'user', content: 'user', timestamp: 1, sdkUserUuid: 'user-u' }, { id: 'a1', role: 'assistant', content: 'resp', timestamp: 2, sdkAssistantUuid: 'resp-a' }, ]; const ctx = findRewindContext(messages, 0); expect(ctx.prevAssistantUuid).toBeUndefined(); expect(ctx.hasResponse).toBe(true); }); it('returns hasResponse as false when no following assistant UUID exists', () => { const messages: ChatMessage[] = [ { id: 'a1', role: 'assistant', content: 'prev', timestamp: 1, sdkAssistantUuid: 'prev-a' }, { id: 'u1', role: 'user', content: 'user', timestamp: 2, sdkUserUuid: 'user-u' }, { id: 'a2', role: 'assistant', content: 'no uuid', timestamp: 3 }, ]; const ctx = findRewindContext(messages, 1); expect(ctx.prevAssistantUuid).toBe('prev-a'); expect(ctx.hasResponse).toBe(false); }); }); ================================================ FILE: tests/unit/features/chat/services/BangBashService.test.ts ================================================ import { exec } from 'child_process'; import { BangBashService } from '@/features/chat/services/BangBashService'; jest.mock('child_process', () => ({ exec: jest.fn(), })); const execMock = exec as jest.MockedFunction<typeof exec>; describe('BangBashService', () => { let service: BangBashService; beforeEach(() => { service = new BangBashService('/test/dir', '/usr/bin'); }); afterEach(() => { jest.resetAllMocks(); }); it('should pass correct exec options', async () => { execMock.mockImplementation((_cmd: any, _opts: any, cb: any) => { cb(null, '', ''); return undefined as any; }); await service.execute('echo hello'); expect(execMock).toHaveBeenCalledWith( 'echo hello', expect.objectContaining({ cwd: '/test/dir', env: expect.objectContaining({ PATH: '/usr/bin' }), timeout: 30_000, maxBuffer: 1024 * 1024, shell: process.platform === 'win32' ? 'cmd.exe' : '/bin/bash', }), expect.any(Function) ); }); it('should return stdout for a successful command', async () => { execMock.mockImplementation((_cmd: any, _opts: any, cb: any) => { cb(null, 'hello\n', ''); return undefined as any; }); const result = await service.execute('echo hello'); expect(result.command).toBe('echo hello'); expect(result.stdout.trim()).toBe('hello'); expect(result.exitCode).toBe(0); expect(result.error).toBeUndefined(); }); it('should return non-zero exit code for a failing command', async () => { const error = Object.assign(new Error('Command failed'), { code: 2 }); execMock.mockImplementation((_cmd: any, _opts: any, cb: any) => { cb(error, '', 'No such file'); return undefined as any; }); const result = await service.execute('ls /nonexistent'); expect(result.exitCode).toBe(2); expect(result.stderr).toBe('No such file'); }); it('should return exit code 1 when error has non-numeric code', async () => { const error = Object.assign(new Error('Command failed'), { code: 'ENOENT' }); execMock.mockImplementation((_cmd: any, _opts: any, cb: any) => { cb(error, '', 'command not found'); return undefined as any; }); const result = await service.execute('nonexistent_cmd'); expect(result.exitCode).toBe(1); expect(typeof result.exitCode).toBe('number'); }); it('should capture both stdout and stderr', async () => { execMock.mockImplementation((_cmd: any, _opts: any, cb: any) => { cb(null, 'out\n', 'err\n'); return undefined as any; }); const result = await service.execute('echo out && echo err >&2'); expect(result.stdout.trim()).toBe('out'); expect(result.stderr.trim()).toBe('err'); }); it('should handle timeout (killed process)', async () => { const error = Object.assign(new Error('Timed out'), { killed: true }); execMock.mockImplementation((_cmd: any, _opts: any, cb: any) => { cb(error, '', ''); return undefined as any; }); const result = await service.execute('sleep 999'); expect(result.exitCode).toBe(124); expect(result.error).toContain('timed out'); }); it('should handle maxBuffer exceeded (killed process with ERR_CHILD_PROCESS_STDIO_MAXBUFFER)', async () => { const error = Object.assign(new Error('maxBuffer'), { killed: true, code: 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER', }); execMock.mockImplementation((_cmd: any, _opts: any, cb: any) => { cb(error, 'partial output', ''); return undefined as any; }); const result = await service.execute('cat /dev/urandom'); expect(result.exitCode).toBe(124); expect(result.error).toContain('maximum buffer size'); expect(result.stdout).toBe('partial output'); }); it('should not surface redundant error.message for non-zero exit', async () => { const error = Object.assign(new Error('Command failed: exit 1'), { code: 1 }); execMock.mockImplementation((_cmd: any, _opts: any, cb: any) => { cb(error, '', ''); return undefined as any; }); const result = await service.execute('exit 1'); expect(result.exitCode).toBe(1); expect(result.error).toBeUndefined(); }); it('should handle null stdout/stderr gracefully', async () => { execMock.mockImplementation((_cmd: any, _opts: any, cb: any) => { cb(null, null, null); return undefined as any; }); const result = await service.execute('test'); expect(result.stdout).toBe(''); expect(result.stderr).toBe(''); expect(result.exitCode).toBe(0); }); }); ================================================ FILE: tests/unit/features/chat/services/InstructionRefineService.test.ts ================================================ // eslint-disable-next-line jest/no-mocks-import import { getLastOptions, resetMockMessages, setMockMessages, } from '@test/__mocks__/claude-agent-sdk'; // Import after mocks are set up import { InstructionRefineService } from '@/features/chat/services/InstructionRefineService'; function createMockPlugin(settings = {}) { return { settings: { model: 'sonnet', thinkingBudget: 'off', systemPrompt: '', ...settings, }, app: { vault: { adapter: { basePath: '/test/vault/path', }, }, }, getActiveEnvironmentVariables: jest.fn().mockReturnValue(''), getResolvedClaudeCliPath: jest.fn().mockReturnValue('/fake/claude'), } as any; } describe('InstructionRefineService', () => { let service: InstructionRefineService; let mockPlugin: any; beforeEach(() => { jest.clearAllMocks(); resetMockMessages(); mockPlugin = createMockPlugin(); service = new InstructionRefineService(mockPlugin); }); describe('refineInstruction', () => { it('should use no tools (text-only refinement)', async () => { setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, { type: 'assistant', message: { content: [{ type: 'text', text: '<instruction>- Be concise.</instruction>' }], }, }, { type: 'result' }, ]); const result = await service.refineInstruction('be concise', ''); expect(result.success).toBe(true); const options = getLastOptions(); expect(options?.tools).toEqual([]); expect(options?.permissionMode).toBe('bypassPermissions'); expect(options?.allowDangerouslySkipPermissions).toBe(true); }); it('should set settingSources to project only when loadUserClaudeSettings is false', async () => { mockPlugin.settings.loadUserClaudeSettings = false; setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, { type: 'assistant', message: { content: [{ type: 'text', text: '<instruction>- Be concise.</instruction>' }], }, }, { type: 'result' }, ]); await service.refineInstruction('be concise', ''); const options = getLastOptions(); expect(options?.settingSources).toEqual(['project']); }); it('should set settingSources to include user when loadUserClaudeSettings is true', async () => { mockPlugin.settings.loadUserClaudeSettings = true; setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, { type: 'assistant', message: { content: [{ type: 'text', text: '<instruction>- Be concise.</instruction>' }], }, }, { type: 'result' }, ]); await service.refineInstruction('be concise', ''); const options = getLastOptions(); expect(options?.settingSources).toEqual(['user', 'project']); }); it('should include existing instructions and allow markdown blocks', async () => { setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, { type: 'assistant', message: { content: [ { type: 'text', text: '<instruction>\n## Coding Style\n\n- Use TypeScript.\n- Prefer small diffs.\n</instruction>', }, ], }, }, { type: 'result' }, ]); const existing = '## Existing\n\n- Keep it short.'; const result = await service.refineInstruction('coding style', existing); expect(result.success).toBe(true); expect(result.refinedInstruction).toBe('## Coding Style\n\n- Use TypeScript.\n- Prefer small diffs.'); const options = getLastOptions(); expect(options?.systemPrompt).toContain('EXISTING INSTRUCTIONS'); expect(options?.systemPrompt).toContain(existing); expect(options?.systemPrompt).toContain('Consider how it fits with existing instructions'); expect(options?.systemPrompt).toContain('Match the format of existing instructions'); }); it('should return clarification when no instruction tag in response', async () => { setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, { type: 'assistant', message: { content: [{ type: 'text', text: 'Could you clarify what you mean by concise?' }], }, }, { type: 'result' }, ]); const result = await service.refineInstruction('be concise', ''); expect(result.success).toBe(true); expect(result.clarification).toBe('Could you clarify what you mean by concise?'); expect(result.refinedInstruction).toBeUndefined(); }); it('should return error for empty response', async () => { setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, { type: 'assistant', message: { content: [{ type: 'text', text: '' }], }, }, { type: 'result' }, ]); const result = await service.refineInstruction('be concise', ''); expect(result.success).toBe(false); expect(result.error).toBe('Empty response'); }); it('should call onProgress during streaming', async () => { setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, { type: 'assistant', message: { content: [{ type: 'text', text: '<instruction>- Be brief.</instruction>' }], }, }, { type: 'result' }, ]); const onProgress = jest.fn(); await service.refineInstruction('be concise', '', onProgress); expect(onProgress).toHaveBeenCalled(); }); it('should set adaptive thinking for Claude models', async () => { mockPlugin.settings.model = 'sonnet'; setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, { type: 'assistant', message: { content: [{ type: 'text', text: '<instruction>ok</instruction>' }], }, }, { type: 'result' }, ]); await service.refineInstruction('test', ''); const options = getLastOptions(); expect(options?.thinking).toEqual({ type: 'adaptive' }); expect(options?.maxThinkingTokens).toBeUndefined(); }); it('should set thinking budget for custom models', async () => { mockPlugin.settings.model = 'custom-model'; mockPlugin.settings.thinkingBudget = 'medium'; setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, { type: 'assistant', message: { content: [{ type: 'text', text: '<instruction>ok</instruction>' }], }, }, { type: 'result' }, ]); await service.refineInstruction('test', ''); const options = getLastOptions(); expect(options?.maxThinkingTokens).toBeGreaterThan(0); expect(options?.thinking).toBeUndefined(); }); it('should ignore non-text content blocks', async () => { setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, { type: 'assistant', message: { content: [ { type: 'tool_use', name: 'test' }, { type: 'text', text: '<instruction>result</instruction>' }, ], }, }, { type: 'result' }, ]); const result = await service.refineInstruction('test', ''); expect(result.success).toBe(true); expect(result.refinedInstruction).toBe('result'); }); it('should skip messages without content', async () => { setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, { type: 'assistant' }, { type: 'assistant', message: { content: [{ type: 'text', text: '<instruction>ok</instruction>' }], }, }, { type: 'result' }, ]); const result = await service.refineInstruction('test', ''); expect(result.success).toBe(true); }); }); describe('continueConversation', () => { it('should return error when no active session', async () => { const result = await service.continueConversation('follow up'); expect(result.success).toBe(false); expect(result.error).toBe('No active conversation to continue'); }); it('should continue with session id after initial refinement', async () => { setMockMessages([ { type: 'system', subtype: 'init', session_id: 'session-abc' }, { type: 'assistant', message: { content: [{ type: 'text', text: 'What do you mean?' }], }, }, { type: 'result' }, ]); // First call establishes a session await service.refineInstruction('test', ''); // Set up messages for the continuation resetMockMessages(); setMockMessages([ { type: 'assistant', message: { content: [{ type: 'text', text: '<instruction>- Be concise and clear.</instruction>' }], }, }, { type: 'result' }, ]); const result = await service.continueConversation('I mean short answers'); expect(result.success).toBe(true); expect(result.refinedInstruction).toBe('- Be concise and clear.'); const options = getLastOptions(); expect(options?.resume).toBe('session-abc'); }); }); describe('resetConversation', () => { it('should clear session so continueConversation fails', async () => { setMockMessages([ { type: 'system', subtype: 'init', session_id: 'session-abc' }, { type: 'assistant', message: { content: [{ type: 'text', text: 'clarification' }], }, }, { type: 'result' }, ]); await service.refineInstruction('test', ''); service.resetConversation(); const result = await service.continueConversation('follow up'); expect(result.success).toBe(false); expect(result.error).toBe('No active conversation to continue'); }); }); describe('cancel', () => { it('should abort the current request', async () => { setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, { type: 'assistant', message: { content: [{ type: 'text', text: '<instruction>ok</instruction>' }], }, }, { type: 'result' }, ]); const promise = service.refineInstruction('test', ''); service.cancel(); const result = await promise; expect(result).toBeDefined(); }); it('should be safe to cancel when nothing is running', () => { service.cancel(); // Verify service is still usable after cancelling with no active request expect(service).toBeDefined(); }); }); describe('error handling', () => { it('should return error when vault path cannot be determined', async () => { mockPlugin.app.vault.adapter.basePath = undefined; const result = await service.refineInstruction('test', ''); expect(result.success).toBe(false); expect(result.error).toBe('Could not determine vault path'); }); it('should return error when Claude CLI is not found', async () => { mockPlugin.getResolvedClaudeCliPath.mockReturnValue(null); const result = await service.refineInstruction('test', ''); expect(result.success).toBe(false); expect(result.error).toBe('Claude CLI not found. Please install Claude Code CLI.'); }); }); }); ================================================ FILE: tests/unit/features/chat/services/SubagentManager.test.ts ================================================ import { mkdtempSync, rmSync, writeFileSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; import type { SubagentInfo, ToolCallInfo } from '@/core/types'; import { SubagentManager } from '@/features/chat/services/SubagentManager'; jest.mock('@/features/chat/rendering', () => ({ createSubagentBlock: jest.fn().mockImplementation((_parentEl: any, toolId: string, input: any) => ({ wrapperEl: { querySelector: jest.fn().mockReturnValue(null) }, contentEl: {}, info: { id: toolId, description: input?.description || 'Task', prompt: input?.prompt || '', mode: 'sync', isExpanded: false, status: 'running', toolCalls: [], }, toolCallStates: new Map(), })), createAsyncSubagentBlock: jest.fn().mockImplementation((_parentEl: any, toolId: string, input: any) => ({ wrapperEl: { querySelector: jest.fn().mockReturnValue(null) }, info: { id: toolId, description: input?.description || 'Background task', prompt: input?.prompt || '', mode: 'async', isExpanded: false, status: 'running', toolCalls: [], asyncStatus: 'pending', }, statusEl: {}, })), addSubagentToolCall: jest.fn(), updateSubagentToolResult: jest.fn(), finalizeSubagentBlock: jest.fn(), updateAsyncSubagentRunning: jest.fn(), finalizeAsyncSubagent: jest.fn(), markAsyncSubagentOrphaned: jest.fn(), })); const createManager = () => { const updates: SubagentInfo[] = []; const manager = new SubagentManager((subagent) => { updates.push({ ...subagent }); }); return { manager, updates }; }; const createMockEl = () => ({ createDiv: jest.fn(), appendChild: jest.fn() } as any); describe('SubagentManager', () => { beforeEach(() => { jest.clearAllMocks(); }); // ============================================ // Async Lifecycle Tests (migrated from AsyncSubagentManager) // ============================================ describe('async lifecycle', () => { it('transitions from pending to running when agent_id is parsed', () => { const { manager, updates } = createManager(); const parentEl = createMockEl(); manager.handleTaskToolUse('task-1', { description: 'Background', run_in_background: true }, parentEl); expect(manager.getByTaskId('task-1')?.asyncStatus).toBe('pending'); manager.handleTaskToolResult('task-1', JSON.stringify({ agent_id: 'agent-123' })); const running = manager.getByTaskId('task-1'); expect(running?.asyncStatus).toBe('running'); expect(running?.agentId).toBe('agent-123'); expect(updates[updates.length - 1].agentId).toBe('agent-123'); expect(manager.isPendingAsyncTask('task-1')).toBe(false); }); it('transitions from pending to running when agent_id exists only in toolUseResult', () => { const { manager, updates } = createManager(); const parentEl = createMockEl(); manager.handleTaskToolUse('task-structured', { description: 'Background', run_in_background: true }, parentEl); manager.handleTaskToolResult( 'task-structured', 'Task launched', false, { data: { agent_id: 'agent-structured-1' } } ); const running = manager.getByTaskId('task-structured'); expect(running?.asyncStatus).toBe('running'); expect(running?.agentId).toBe('agent-structured-1'); expect(updates[updates.length - 1].agentId).toBe('agent-structured-1'); expect(manager.isPendingAsyncTask('task-structured')).toBe(false); }); it('moves to error when Task tool_result parsing fails', () => { const { manager, updates } = createManager(); const parentEl = createMockEl(); manager.handleTaskToolUse('task-parse-fail', { description: 'No id', run_in_background: true }, parentEl); manager.handleTaskToolResult('task-parse-fail', 'no agent id present'); expect(manager.getByTaskId('task-parse-fail')).toBeUndefined(); const last = updates[updates.length - 1]; expect(last.asyncStatus).toBe('error'); expect(last.result).toContain('Failed to parse agent_id'); }); it('moves to error when Task tool_result itself is an error', () => { const { manager, updates } = createManager(); const parentEl = createMockEl(); manager.handleTaskToolUse('task-error', { description: 'Will fail', run_in_background: true }, parentEl); manager.handleTaskToolResult('task-error', 'launch failed', true); expect(manager.getByTaskId('task-error')).toBeUndefined(); const last = updates[updates.length - 1]; expect(last.asyncStatus).toBe('error'); expect(last.result).toBe('launch failed'); }); it('stays running when AgentOutputTool reports not_ready', () => { const { manager } = createManager(); const parentEl = createMockEl(); manager.handleTaskToolUse('task-running', { description: 'Background', run_in_background: true }, parentEl); manager.handleTaskToolResult('task-running', JSON.stringify({ agent_id: 'agent-abc' })); const toolCall: ToolCallInfo = { id: 'output-not-ready', name: 'AgentOutputTool', input: { agent_id: 'agent-abc' }, status: 'running', isExpanded: false, }; manager.handleAgentOutputToolUse(toolCall); const stillRunning = manager.handleAgentOutputToolResult( 'output-not-ready', JSON.stringify({ retrieval_status: 'not_ready', agents: {} }), false ); expect(stillRunning?.asyncStatus).toBe('running'); expect(manager.getByTaskId('task-running')?.asyncStatus).toBe('running'); }); it('ignores unrelated tool_result when async subagent is active', () => { const { manager } = createManager(); const parentEl = createMockEl(); manager.handleTaskToolUse('task-standalone', { description: 'Background', run_in_background: true }, parentEl); manager.handleTaskToolResult('task-standalone', JSON.stringify({ agent_id: 'agent-standalone' })); const unrelated = manager.handleAgentOutputToolResult( 'non-agent-output', 'regular tool output', false ); expect(unrelated).toBeUndefined(); expect(manager.getByTaskId('task-standalone')?.asyncStatus).toBe('running'); }); it('finalizes to completed when AgentOutputTool succeeds and extracts result', () => { const { manager, updates } = createManager(); const parentEl = createMockEl(); manager.handleTaskToolUse('task-complete', { description: 'Background', run_in_background: true }, parentEl); manager.handleTaskToolResult('task-complete', JSON.stringify({ agent_id: 'agent-complete' })); const toolCall: ToolCallInfo = { id: 'output-success', name: 'AgentOutputTool', input: { agent_id: 'agent-complete' }, status: 'running', isExpanded: false, }; manager.handleAgentOutputToolUse(toolCall); const completed = manager.handleAgentOutputToolResult( 'output-success', JSON.stringify({ retrieval_status: 'success', agents: { 'agent-complete': { status: 'completed', result: 'done!' } }, }), false ); expect(completed?.asyncStatus).toBe('completed'); expect(completed?.result).toBe('done!'); expect(updates[updates.length - 1].asyncStatus).toBe('completed'); expect(manager.getByTaskId('task-complete')).toBeUndefined(); }); it('finalizes to error when AgentOutputTool result has isError=true', () => { const { manager, updates } = createManager(); const parentEl = createMockEl(); manager.handleTaskToolUse('task-err', { description: 'Background', run_in_background: true }, parentEl); manager.handleTaskToolResult('task-err', JSON.stringify({ agent_id: 'agent-err' })); const toolCall: ToolCallInfo = { id: 'output-err', name: 'AgentOutputTool', input: { agent_id: 'agent-err' }, status: 'running', isExpanded: false, }; manager.handleAgentOutputToolUse(toolCall); const errored = manager.handleAgentOutputToolResult( 'output-err', 'agent crashed', true ); expect(errored?.asyncStatus).toBe('error'); expect(errored?.status).toBe('error'); expect(errored?.result).toBe('agent crashed'); expect(updates[updates.length - 1].asyncStatus).toBe('error'); expect(manager.getByTaskId('task-err')).toBeUndefined(); }); it('marks pending and running async subagents as orphaned', () => { const { manager } = createManager(); const parentEl = createMockEl(); manager.handleTaskToolUse('pending-task', { description: 'Pending task', run_in_background: true }, parentEl); manager.handleTaskToolUse('running-task', { description: 'Running task', run_in_background: true }, parentEl); manager.handleTaskToolResult('running-task', JSON.stringify({ agent_id: 'agent-running' })); const orphaned = manager.orphanAllActive(); expect(orphaned).toHaveLength(2); orphaned.forEach((subagent) => { expect(subagent.asyncStatus).toBe('orphaned'); expect(subagent.result).toContain('Conversation ended'); }); expect(manager.getByTaskId('pending-task')).toBeUndefined(); expect(manager.getByTaskId('running-task')).toBeUndefined(); }); it('ignores Task results for unknown tasks', () => { const { manager } = createManager(); manager.handleTaskToolResult('missing-task', 'agent_id: x'); expect(manager.getByTaskId('missing-task')).toBeUndefined(); }); it('ignores AgentOutputTool when missing agentId', () => { const { manager } = createManager(); manager.handleAgentOutputToolUse({ id: 'output-1', name: 'AgentOutputTool', input: {}, status: 'running', isExpanded: false, }); expect(manager.isLinkedAgentOutputTool('output-1')).toBe(false); }); it('ignores AgentOutputTool when referencing unknown agent', () => { const { manager } = createManager(); manager.handleAgentOutputToolUse({ id: 'output-unknown', name: 'AgentOutputTool', input: { agent_id: 'agent-x' }, status: 'running', isExpanded: false, }); expect(manager.isLinkedAgentOutputTool('output-unknown')).toBe(false); }); it('handles TaskOutput with task_id parameter (SDK format)', () => { const { manager, updates } = createManager(); const parentEl = createMockEl(); manager.handleTaskToolUse('task-sdk', { description: 'SDK test', run_in_background: true }, parentEl); manager.handleTaskToolResult('task-sdk', JSON.stringify({ agent_id: 'agent-sdk-123' })); const toolCall: ToolCallInfo = { id: 'taskoutput-1', name: 'TaskOutput', input: { task_id: 'agent-sdk-123' }, status: 'running', isExpanded: false, }; manager.handleAgentOutputToolUse(toolCall); expect(manager.isLinkedAgentOutputTool('taskoutput-1')).toBe(true); const completed = manager.handleAgentOutputToolResult( 'taskoutput-1', JSON.stringify({ retrieval_status: 'success', agents: { 'agent-sdk-123': { status: 'completed', result: 'task_id works!' } }, }), false ); expect(completed?.asyncStatus).toBe('completed'); expect(completed?.result).toBe('task_id works!'); expect(updates[updates.length - 1].asyncStatus).toBe('completed'); }); it('returns undefined on invalid AgentOutputTool state transition', () => { const { manager } = createManager(); const parentEl = createMockEl(); manager.handleTaskToolUse('task-done', { description: 'Background', run_in_background: true }, parentEl); manager.handleTaskToolResult('task-done', JSON.stringify({ agent_id: 'agent-done' })); manager.handleAgentOutputToolUse({ id: 'output-any', name: 'AgentOutputTool', input: { agent_id: 'agent-done' }, status: 'running', isExpanded: false, }); // Manually mark completed to force invalid transition const sub = manager.getByTaskId('task-done')!; sub.asyncStatus = 'completed'; const res = manager.handleAgentOutputToolResult('output-any', '{"retrieval_status":"success"}', false); expect(res).toBeUndefined(); }); it('treats plain text not_ready as still running', () => { const { manager } = createManager(); const parentEl = createMockEl(); manager.handleTaskToolUse('task-plain', { description: 'Background', run_in_background: true }, parentEl); manager.handleTaskToolResult('task-plain', JSON.stringify({ agent_id: 'agent-plain' })); const toolCall: ToolCallInfo = { id: 'output-plain', name: 'AgentOutputTool', input: { agent_id: 'agent-plain' }, status: 'running', isExpanded: false, }; manager.handleAgentOutputToolUse(toolCall); const running = manager.handleAgentOutputToolResult('output-plain', 'not ready', false); expect(running?.asyncStatus).toBe('running'); }); it('treats XML-style status running as still running', () => { const { manager } = createManager(); const parentEl = createMockEl(); manager.handleTaskToolUse('task-xml', { description: 'Background', run_in_background: true }, parentEl); manager.handleTaskToolResult('task-xml', JSON.stringify({ agent_id: 'agent-xml' })); const toolCall: ToolCallInfo = { id: 'output-xml', name: 'AgentOutputTool', input: { agent_id: 'agent-xml' }, status: 'running', isExpanded: false, }; manager.handleAgentOutputToolUse(toolCall); const xmlResult = `<retrieval_status>not_ready</retrieval_status> <task_id>agent-xml</task_id> <task_type>local_agent</task_type> <status>running</status>`; const running = manager.handleAgentOutputToolResult('output-xml', xmlResult, false); expect(running?.asyncStatus).toBe('running'); }); it('extracts first agent result when agentId is missing', () => { const { manager } = createManager(); const parentEl = createMockEl(); manager.handleTaskToolUse('task-first', { description: 'Background', run_in_background: true }, parentEl); manager.handleTaskToolResult('task-first', JSON.stringify({ agent_id: 'agent-first' })); const toolCall: ToolCallInfo = { id: 'output-first', name: 'AgentOutputTool', input: { agent_id: 'agent-first' }, status: 'running', isExpanded: false, }; manager.handleAgentOutputToolUse(toolCall); const completed = manager.handleAgentOutputToolResult( 'output-first', JSON.stringify({ retrieval_status: 'success', agents: { other: { status: 'completed', result: 'ok' } } }), false ); expect(completed?.result).toBe('ok'); }); it('infers agentId from AgentOutputTool result when not linked', () => { const { manager } = createManager(); const parentEl = createMockEl(); manager.handleTaskToolUse('task-infer', { description: 'Background', run_in_background: true }, parentEl); manager.handleTaskToolResult('task-infer', JSON.stringify({ agent_id: 'agent-infer' })); const result = JSON.stringify({ retrieval_status: 'success', agents: { 'agent-infer': { status: 'completed', result: 'ok' } }, }); const completed = manager.handleAgentOutputToolResult('unlinked', result, false); expect(completed?.asyncStatus).toBe('completed'); expect(completed?.result).toBe('ok'); }); it('gets running subagent by task id after transition', () => { const { manager } = createManager(); const parentEl = createMockEl(); manager.handleTaskToolUse('task-map', { description: 'Background', run_in_background: true }, parentEl); manager.handleTaskToolResult('task-map', JSON.stringify({ agent_id: 'agent-map' })); expect(manager.getByTaskId('task-map')?.agentId).toBe('agent-map'); }); }); // ============================================ // Async Parsing Edge Cases (via public API) // ============================================ describe('async parsing edge cases', () => { const setupLinkedAgentOutput = ( manager: ReturnType<typeof createManager>['manager'], taskId: string, agentId: string, outputToolId: string ) => { const parentEl = createMockEl(); manager.handleTaskToolUse(taskId, { description: 'Background', run_in_background: true }, parentEl); manager.handleTaskToolResult(taskId, JSON.stringify({ agent_id: agentId })); manager.handleAgentOutputToolUse({ id: outputToolId, name: 'AgentOutputTool', input: { agent_id: agentId }, status: 'running', isExpanded: false, }); }; // ---- still-running detection with envelope forms ---- it('stays running with array envelope containing not_ready', () => { const { manager } = createManager(); setupLinkedAgentOutput(manager, 'task-1', 'agent-1', 'out-1'); const arrayEnvelope = JSON.stringify([ { text: JSON.stringify({ retrieval_status: 'not_ready', agents: {} }) }, ]); const result = manager.handleAgentOutputToolResult('out-1', arrayEnvelope, false); expect(result?.asyncStatus).toBe('running'); }); it('stays running with object envelope containing running status', () => { const { manager } = createManager(); setupLinkedAgentOutput(manager, 'task-1', 'agent-1', 'out-1'); const objectEnvelope = JSON.stringify({ text: JSON.stringify({ retrieval_status: 'running', agents: {} }), }); const result = manager.handleAgentOutputToolResult('out-1', objectEnvelope, false); expect(result?.asyncStatus).toBe('running'); }); it('finalizes when result is whitespace-only', () => { const { manager } = createManager(); setupLinkedAgentOutput(manager, 'task-1', 'agent-1', 'out-1'); const result = manager.handleAgentOutputToolResult('out-1', ' ', false); expect(result?.asyncStatus).toBe('completed'); }); it('finalizes to error when isError is true regardless of content', () => { const { manager } = createManager(); setupLinkedAgentOutput(manager, 'task-1', 'agent-1', 'out-1'); const result = manager.handleAgentOutputToolResult('out-1', 'whatever', true); expect(result?.asyncStatus).toBe('error'); }); it('finalizes when retrieval_status is success without agents', () => { const { manager } = createManager(); setupLinkedAgentOutput(manager, 'task-1', 'agent-1', 'out-1'); const result = manager.handleAgentOutputToolResult( 'out-1', JSON.stringify({ retrieval_status: 'success' }), false ); expect(result?.asyncStatus).toBe('completed'); }); it('finalizes when retrieval_status is unknown', () => { const { manager } = createManager(); setupLinkedAgentOutput(manager, 'task-1', 'agent-1', 'out-1'); const result = manager.handleAgentOutputToolResult( 'out-1', JSON.stringify({ retrieval_status: 'unknown' }), false ); expect(result?.asyncStatus).toBe('completed'); }); it('marks error when toolUseResult has retrieval_status error', () => { const { manager } = createManager(); setupLinkedAgentOutput(manager, 'task-1', 'agent-1', 'out-1'); const toolUseResult = { retrieval_status: 'error', error: 'Agent process crashed', }; const result = manager.handleAgentOutputToolResult( 'out-1', '{}', false, toolUseResult ); expect(result?.asyncStatus).toBe('error'); expect(result?.result).toBe('Error: Agent process crashed'); }); it('marks error when retrieval_status is error without error field', () => { const { manager } = createManager(); setupLinkedAgentOutput(manager, 'task-1', 'agent-1', 'out-1'); const toolUseResult = { retrieval_status: 'error', }; const result = manager.handleAgentOutputToolResult( 'out-1', '{}', false, toolUseResult ); expect(result?.asyncStatus).toBe('error'); expect(result?.result).toBe('Error: Task retrieval failed'); }); it('finalizes with plain text as result when no running indicators', () => { const { manager } = createManager(); setupLinkedAgentOutput(manager, 'task-1', 'agent-1', 'out-1'); const result = manager.handleAgentOutputToolResult('out-1', 'plain output', false); expect(result?.asyncStatus).toBe('completed'); expect(result?.result).toBe('plain output'); }); // ---- result extraction with envelope forms ---- it('extracts result from array envelope', () => { const { manager } = createManager(); setupLinkedAgentOutput(manager, 'task-1', 'a', 'out-1'); const payloadArray = JSON.stringify([ { text: JSON.stringify({ retrieval_status: 'success', agents: { a: { result: 'R' } } }) }, ]); const result = manager.handleAgentOutputToolResult('out-1', payloadArray, false); expect(result?.result).toBe('R'); }); it('extracts result from object envelope', () => { const { manager } = createManager(); setupLinkedAgentOutput(manager, 'task-1', 'a', 'out-1'); const payloadObject = JSON.stringify({ text: JSON.stringify({ retrieval_status: 'success', agents: { a: { status: 'completed' } } }), }); const result = manager.handleAgentOutputToolResult('out-1', payloadObject, false); expect(result?.result).toContain('completed'); }); it('extracts only final assistant result from XML output payload', () => { const { manager } = createManager(); setupLinkedAgentOutput(manager, 'task-1', 'a6ac482', 'out-1'); const outputLines = [ JSON.stringify({ type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text: 'I will search first.' }], }, }), JSON.stringify({ type: 'assistant', message: { role: 'assistant', content: [{ type: 'tool_use', id: 'tool-1', name: 'Grep', input: { pattern: 'martini' } }], }, }), JSON.stringify({ type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text: 'Final answer: 24 matches across 6 files.' }], }, }), ].join('\n'); const xmlPayload = `<retrieval_status>success</retrieval_status> <task_id>a6ac482</task_id> <status>completed</status> <output> ${outputLines} </output>`; const result = manager.handleAgentOutputToolResult('out-1', xmlPayload, false); expect(result?.result).toBe('Final answer: 24 matches across 6 files.'); }); it('extracts final assistant result from structured toolUseResult.task.result', () => { const { manager } = createManager(); setupLinkedAgentOutput(manager, 'task-1', 'agent-structured', 'out-1'); const outputLines = [ JSON.stringify({ type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text: 'Intermediate step' }], }, }), JSON.stringify({ type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text: 'Final summary from structured result.' }], }, }), ].join('\n'); const structuredToolUseResult = { retrieval_status: 'success', task: { task_id: 'agent-structured', status: 'completed', result: outputLines, output: outputLines, }, }; const result = manager.handleAgentOutputToolResult( 'out-1', '{"retrieval_status":"success"}', false, structuredToolUseResult ); expect(result?.result).toBe('Final summary from structured result.'); }); it('extracts full result from SDK toolUseResult.content array', () => { const { manager } = createManager(); setupLinkedAgentOutput(manager, 'task-1', 'agent-sdk-content', 'out-1'); const fullResult = 'This is the full multi-line result.\n\nLine 2 of the result.\nLine 3 with details.'; const sdkToolUseResult = { status: 'completed', content: [ { type: 'text', text: fullResult }, ], agentId: 'agent-sdk-content', prompt: 'Do something', totalDurationMs: 5000, totalTokens: 1000, totalToolUseCount: 5, }; const result = manager.handleAgentOutputToolResult( 'out-1', '{}', false, sdkToolUseResult ); expect(result?.result).toBe(fullResult); }); it('extracts result from SDK content array with multiple text blocks', () => { const { manager } = createManager(); setupLinkedAgentOutput(manager, 'task-1', 'agent-multi', 'out-1'); const sdkToolUseResult = { status: 'completed', content: [ { type: 'text', text: 'Main result text here.' }, { type: 'text', text: 'agentId: agent-multi\n<usage>total_tokens: 100</usage>' }, ], agentId: 'agent-multi', }; const result = manager.handleAgentOutputToolResult( 'out-1', '{}', false, sdkToolUseResult ); // Should return the first text block (actual result), not the metadata block expect(result?.result).toBe('Main result text here.'); }); it('reads full output file when inline output is truncated', () => { const { manager } = createManager(); setupLinkedAgentOutput(manager, 'task-1', 'agent-full-output', 'out-1'); const tempDir = mkdtempSync(join(tmpdir(), 'subagent-output-')); const fullOutputFile = join(tempDir, 'agent.output'); const fullOutput = [ JSON.stringify({ type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text: 'Recovered final answer from full output file.' }], }, }), ].join('\n'); writeFileSync(fullOutputFile, fullOutput, 'utf-8'); const inlineOutput = `[Truncated. Full output: ${fullOutputFile}]`; const xmlPayload = `<retrieval_status>success</retrieval_status> <task_id>agent-full-output</task_id> <status>completed</status> <output> ${inlineOutput} </output>`; try { const result = manager.handleAgentOutputToolResult('out-1', xmlPayload, false); expect(result?.result).toBe('Recovered final answer from full output file.'); } finally { rmSync(tempDir, { recursive: true, force: true }); } }); it('ignores truncated full output files outside trusted temp roots', () => { const { manager } = createManager(); setupLinkedAgentOutput(manager, 'task-1', 'agent-untrusted-output', 'out-1'); const fullOutputFile = join(process.cwd(), 'agent-untrusted.output'); const fullOutput = [ JSON.stringify({ type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text: 'Should not be loaded from untrusted path.' }], }, }), ].join('\n'); writeFileSync(fullOutputFile, fullOutput, 'utf-8'); const inlineOutput = `[Truncated. Full output: ${fullOutputFile}]`; const xmlPayload = `<retrieval_status>success</retrieval_status> <task_id>agent-untrusted-output</task_id> <status>completed</status> <output> ${inlineOutput} </output>`; try { const result = manager.handleAgentOutputToolResult('out-1', xmlPayload, false); expect(result?.result).toBe(inlineOutput); } finally { rmSync(fullOutputFile, { force: true }); } }); it('extracts direct result tag when present', () => { const { manager } = createManager(); setupLinkedAgentOutput(manager, 'task-1', 'agent-x', 'out-1'); const taggedPayload = `<status>completed</status> <result> Only this is the final result. </result>`; const result = manager.handleAgentOutputToolResult('out-1', taggedPayload, false); expect(result?.result).toBe('Only this is the final result.'); }); it('falls back to first agent when agentId is missing from agents map', () => { const { manager } = createManager(); setupLinkedAgentOutput(manager, 'task-1', 'agent-x', 'out-1'); const fallback = JSON.stringify({ retrieval_status: 'success', agents: { first: { status: 'completed' } }, }); const result = manager.handleAgentOutputToolResult('out-1', fallback, false); expect(result?.result).toContain('completed'); }); it('returns raw payload when no agents key is present', () => { const { manager } = createManager(); setupLinkedAgentOutput(manager, 'task-1', 'agent-x', 'out-1'); const noAgents = JSON.stringify({ foo: 'bar' }); const result = manager.handleAgentOutputToolResult('out-1', noAgents, false); expect(result?.result).toBe(noAgents); }); // ---- agent ID parsing from multiple JSON shapes ---- it('parses camelCase agentId from task result', () => { const { manager } = createManager(); const parentEl = createMockEl(); manager.handleTaskToolUse('task-1', { description: 'Bg', run_in_background: true }, parentEl); manager.handleTaskToolResult('task-1', JSON.stringify({ agentId: 'camel' })); expect(manager.getByTaskId('task-1')).toBeDefined(); expect(manager.getByTaskId('task-1')?.agentId).toBe('camel'); }); it('parses nested data.agent_id from task result', () => { const { manager } = createManager(); const parentEl = createMockEl(); manager.handleTaskToolUse('task-1', { description: 'Bg', run_in_background: true }, parentEl); manager.handleTaskToolResult('task-1', JSON.stringify({ data: { agent_id: 'nested' } })); expect(manager.getByTaskId('task-1')?.agentId).toBe('nested'); }); it('parses id field from task result', () => { const { manager } = createManager(); const parentEl = createMockEl(); manager.handleTaskToolUse('task-1', { description: 'Bg', run_in_background: true }, parentEl); manager.handleTaskToolResult('task-1', JSON.stringify({ id: 'idfield' })); expect(manager.getByTaskId('task-1')?.agentId).toBe('idfield'); }); it('parses unicode-escaped agent_id from task result', () => { const { manager } = createManager(); const parentEl = createMockEl(); manager.handleTaskToolUse('task-1', { description: 'Bg', run_in_background: true }, parentEl); manager.handleTaskToolResult('task-1', '{"agent\\u005fid":"escaped"}'); expect(manager.getByTaskId('task-1')?.agentId).toBe('escaped'); }); it('parses nested unicode-escaped agent_id from task result', () => { const { manager } = createManager(); const parentEl = createMockEl(); manager.handleTaskToolUse('task-1', { description: 'Bg', run_in_background: true }, parentEl); manager.handleTaskToolResult('task-1', '{"data": {"agent\\u005fid": "nested2"}}'); expect(manager.getByTaskId('task-1')?.agentId).toBe('nested2'); }); it('transitions to error when no recognizable agent ID in task result', () => { const { manager, updates } = createManager(); const parentEl = createMockEl(); manager.handleTaskToolUse('task-1', { description: 'Bg', run_in_background: true }, parentEl); manager.handleTaskToolResult('task-1', '{"foo": "bar"}'); const last = updates[updates.length - 1]; expect(last.asyncStatus).toBe('error'); expect(last.result).toContain('Failed to parse agent_id'); }); }); // ============================================ // Unified Task Entry Point // ============================================ describe('handleTaskToolUse', () => { it('buffers task in pendingTasks when currentContentEl is null', () => { const { manager } = createManager(); const result = manager.handleTaskToolUse('task-1', { prompt: 'test' }, null); expect(result.action).toBe('buffered'); expect(manager.hasPendingTask('task-1')).toBe(true); }); it('renders task buffered with null parentEl once contentEl becomes available', () => { const { manager } = createManager(); const parentEl = createMockEl(); // First chunk: no content element manager.handleTaskToolUse('task-1', { prompt: 'test' }, null); expect(manager.hasPendingTask('task-1')).toBe(true); // Second chunk: content element available, run_in_background known const result = manager.handleTaskToolUse('task-1', { run_in_background: false }, parentEl); expect(result.action).toBe('created_sync'); expect(manager.hasPendingTask('task-1')).toBe(false); }); it('returns created_sync for run_in_background=false', () => { const { manager } = createManager(); const parentEl = createMockEl(); const result = manager.handleTaskToolUse( 'task-sync', { prompt: 'test', run_in_background: false }, parentEl ); expect(result.action).toBe('created_sync'); expect((result as any).subagentState.info.id).toBe('task-sync'); }); it('returns created_async for run_in_background=true', () => { const { manager } = createManager(); const parentEl = createMockEl(); const result = manager.handleTaskToolUse( 'task-async', { description: 'Background', run_in_background: true }, parentEl ); expect(result.action).toBe('created_async'); expect((result as any).info.id).toBe('task-async'); expect((result as any).info.asyncStatus).toBe('pending'); }); it('buffers task when run_in_background is missing', () => { const { manager } = createManager(); const parentEl = createMockEl(); const result = manager.handleTaskToolUse( 'task-unknown', { prompt: 'test' }, parentEl ); expect(result.action).toBe('buffered'); expect(manager.hasPendingTask('task-unknown')).toBe(true); }); it('upgrades buffered task to async when run_in_background=true arrives later', () => { const { manager } = createManager(); const parentEl = createMockEl(); const first = manager.handleTaskToolUse( 'task-upgrade', { prompt: 'test' }, parentEl ); expect(first.action).toBe('buffered'); expect(manager.hasPendingTask('task-upgrade')).toBe(true); const second = manager.handleTaskToolUse( 'task-upgrade', { run_in_background: true, description: 'Background' }, parentEl ); expect(second.action).toBe('created_async'); expect((second as any).info.id).toBe('task-upgrade'); expect(manager.hasPendingTask('task-upgrade')).toBe(false); }); it('returns label_updated for already rendered sync subagent', () => { const { manager } = createManager(); const parentEl = createMockEl(); // Create sync manager.handleTaskToolUse('task-1', { run_in_background: false, description: 'Initial' }, parentEl); // Update input const result = manager.handleTaskToolUse('task-1', { description: 'Updated' }, parentEl); expect(result.action).toBe('label_updated'); }); it('returns label_updated for already rendered async subagent', () => { const { manager } = createManager(); const parentEl = createMockEl(); // Create async manager.handleTaskToolUse('task-1', { run_in_background: true, description: 'Initial' }, parentEl); // Update input const result = manager.handleTaskToolUse('task-1', { description: 'Updated' }, parentEl); expect(result.action).toBe('label_updated'); }); it('syncs async label update to canonical SubagentInfo', () => { const { manager } = createManager(); const parentEl = createMockEl(); manager.handleTaskToolUse('task-1', { run_in_background: true, description: 'Initial' }, parentEl); // Canonical info should have initial description expect(manager.getByTaskId('task-1')?.description).toBe('Initial'); // Update label via streaming input manager.handleTaskToolUse('task-1', { description: 'Updated description' }, parentEl); // Canonical info should now reflect the update expect(manager.getByTaskId('task-1')?.description).toBe('Updated description'); }); it('propagates prompt updates in label update', () => { const { manager } = createManager(); const parentEl = createMockEl(); manager.handleTaskToolUse('task-1', { run_in_background: true, description: 'Bg', prompt: 'initial' }, parentEl); // Update prompt via streaming input manager.handleTaskToolUse('task-1', { prompt: 'full prompt text' }, parentEl); // Canonical info should have updated prompt expect(manager.getByTaskId('task-1')?.prompt).toBe('full prompt text'); }); it('merges buffered input and renders once content element becomes available', () => { const { manager } = createManager(); const parentEl = createMockEl(); // First chunk without content target must be buffered. manager.handleTaskToolUse('task-1', { description: 'Initial description' }, null); expect(manager.hasPendingTask('task-1')).toBe(true); // Second chunk arrives with a content target, additional input, and confirmed mode. const result = manager.handleTaskToolUse( 'task-1', { prompt: 'latest prompt', run_in_background: false }, parentEl ); expect(result.action).toBe('created_sync'); expect((result as any).subagentState.info.description).toBe('Initial description'); expect((result as any).subagentState.info.prompt).toBe('latest prompt'); expect(manager.hasPendingTask('task-1')).toBe(false); }); it('increments spawned count when creating sync task', () => { const { manager } = createManager(); const parentEl = createMockEl(); expect(manager.subagentsSpawnedThisStream).toBe(0); manager.handleTaskToolUse('task-1', { run_in_background: false }, parentEl); expect(manager.subagentsSpawnedThisStream).toBe(1); }); it('increments spawned count when creating async task', () => { const { manager } = createManager(); const parentEl = createMockEl(); expect(manager.subagentsSpawnedThisStream).toBe(0); manager.handleTaskToolUse('task-1', { run_in_background: true }, parentEl); expect(manager.subagentsSpawnedThisStream).toBe(1); }); }); // ============================================ // Pending Task Resolution // ============================================ describe('renderPendingTask', () => { it('returns null for unknown tool id', () => { const { manager } = createManager(); const result = manager.renderPendingTask('unknown'); expect(result).toBeNull(); }); it('renders buffered sync task and increments counter', () => { const { manager } = createManager(); const parentEl = createMockEl(); manager.handleTaskToolUse('task-1', { prompt: 'test' }, null); const result = manager.renderPendingTask('task-1', parentEl); expect(result).not.toBeNull(); expect(result?.mode).toBe('sync'); expect(manager.hasPendingTask('task-1')).toBe(false); expect(manager.subagentsSpawnedThisStream).toBe(1); }); it('returns null and keeps task pending when targetEl is null', () => { const { manager } = createManager(); // Buffer with null parentEl (no content element) manager.handleTaskToolUse('task-1', { prompt: 'test' }, null); expect(manager.hasPendingTask('task-1')).toBe(true); // Try to render without override — both parentEl and override are null const result = manager.renderPendingTask('task-1'); expect(result).toBeNull(); expect(manager.hasPendingTask('task-1')).toBe(true); expect(manager.subagentsSpawnedThisStream).toBe(0); }); it('renders buffered async task with parentEl override', () => { const { manager } = createManager(); const overrideEl = createMockEl(); // Buffer with null parentEl so the task stays pending despite run_in_background being known manager.handleTaskToolUse('task-1', { prompt: 'test', run_in_background: true }, null); expect(manager.hasPendingTask('task-1')).toBe(true); const result = manager.renderPendingTask('task-1', overrideEl); expect(result).not.toBeNull(); expect(result?.mode).toBe('async'); }); it('does not increment spawned counter when rendering throws', () => { const { createSubagentBlock } = jest.requireMock('@/features/chat/rendering'); createSubagentBlock.mockImplementationOnce(() => { throw new Error('DOM error'); }); const { manager } = createManager(); const parentEl = createMockEl(); manager.handleTaskToolUse('task-1', { prompt: 'test' }, null); expect(manager.subagentsSpawnedThisStream).toBe(0); const result = manager.renderPendingTask('task-1', parentEl); expect(result).toBeNull(); expect(manager.subagentsSpawnedThisStream).toBe(0); }); }); describe('renderPendingTaskFromTaskResult', () => { it('returns null for unknown tool id', () => { const { manager } = createManager(); const result = manager.renderPendingTaskFromTaskResult('unknown', 'ok', false); expect(result).toBeNull(); }); it('infers async from agent_id markers when mode is still unknown', () => { const { manager } = createManager(); const parentEl = createMockEl(); manager.handleTaskToolUse('task-1', { prompt: 'test' }, parentEl); const result = manager.renderPendingTaskFromTaskResult( 'task-1', '{"agent_id":"agent-123"}', false ); expect(result).not.toBeNull(); expect(result?.mode).toBe('async'); expect(manager.hasPendingTask('task-1')).toBe(false); }); it('falls back to sync when no async evidence is present', () => { const { manager } = createManager(); const parentEl = createMockEl(); manager.handleTaskToolUse('task-1', { prompt: 'test' }, parentEl); const result = manager.renderPendingTaskFromTaskResult( 'task-1', '{"foo":"bar"}', false ); expect(result).not.toBeNull(); expect(result?.mode).toBe('sync'); expect(manager.getSyncSubagent('task-1')).toBeDefined(); }); it('honors explicit async mode from input even without task-result markers', () => { const { manager } = createManager(); const parentEl = createMockEl(); manager.handleTaskToolUse( 'task-1', { prompt: 'test', run_in_background: true }, null ); const result = manager.renderPendingTaskFromTaskResult( 'task-1', '{"foo":"bar"}', false, parentEl ); expect(result).not.toBeNull(); expect(result?.mode).toBe('async'); }); it('infers async from toolUseResult markers when task-result text has no agent id', () => { const { manager } = createManager(); const parentEl = createMockEl(); manager.handleTaskToolUse('task-1', { prompt: 'test' }, parentEl); const result = manager.renderPendingTaskFromTaskResult( 'task-1', 'Launching task...', false, parentEl, { isAsync: true, status: 'async_launched', agentId: 'agent-xyz', } ); expect(result).not.toBeNull(); expect(result?.mode).toBe('async'); }); it('resolves to sync on errored task result when mode is unknown', () => { const { manager } = createManager(); const parentEl = createMockEl(); manager.handleTaskToolUse('task-1', { prompt: 'test' }, parentEl); const result = manager.renderPendingTaskFromTaskResult( 'task-1', '{"agent_id":"agent-123"}', true ); expect(result).not.toBeNull(); expect(result?.mode).toBe('sync'); }); }); // ============================================ // Sync Subagent Operations // ============================================ describe('sync subagent operations', () => { it('creates and retrieves sync subagent', () => { const { manager } = createManager(); const parentEl = createMockEl(); manager.handleTaskToolUse('task-1', { run_in_background: false }, parentEl); const state = manager.getSyncSubagent('task-1'); expect(state).toBeDefined(); expect(state?.info.id).toBe('task-1'); }); it('adds tool call to sync subagent', () => { const { addSubagentToolCall } = jest.requireMock('@/features/chat/rendering'); const { manager } = createManager(); const parentEl = createMockEl(); manager.handleTaskToolUse('task-1', { run_in_background: false }, parentEl); const toolCall: ToolCallInfo = { id: 'read-1', name: 'Read', input: { file_path: 'test.md' }, status: 'running', isExpanded: false, }; manager.addSyncToolCall('task-1', toolCall); expect(addSubagentToolCall).toHaveBeenCalled(); }); it('updates tool result in sync subagent', () => { const { updateSubagentToolResult } = jest.requireMock('@/features/chat/rendering'); const { manager } = createManager(); const parentEl = createMockEl(); manager.handleTaskToolUse('task-1', { run_in_background: false }, parentEl); const toolCall: ToolCallInfo = { id: 'read-1', name: 'Read', input: {}, status: 'completed', isExpanded: false, result: 'file content', }; manager.updateSyncToolResult('task-1', 'read-1', toolCall); expect(updateSubagentToolResult).toHaveBeenCalled(); }); it('finalizes sync subagent and removes from map', () => { const { finalizeSubagentBlock } = jest.requireMock('@/features/chat/rendering'); const { manager } = createManager(); const parentEl = createMockEl(); manager.handleTaskToolUse('task-1', { run_in_background: false }, parentEl); const info = manager.finalizeSyncSubagent('task-1', 'done', false); expect(info).not.toBeNull(); expect(info?.id).toBe('task-1'); expect(finalizeSubagentBlock).toHaveBeenCalled(); expect(manager.getSyncSubagent('task-1')).toBeUndefined(); }); it('extracts result from SDK toolUseResult.content for sync subagent', () => { const { finalizeSubagentBlock } = jest.requireMock('@/features/chat/rendering'); const { manager } = createManager(); const parentEl = createMockEl(); manager.handleTaskToolUse('task-sdk', { run_in_background: false }, parentEl); const sdkToolUseResult = { status: 'completed', content: [ { type: 'text', text: 'Full sync subagent result with multiple lines.\n\nSecond paragraph.' }, { type: 'text', text: 'agentId: agent-sync\n<usage>total_tokens: 500</usage>' }, ], agentId: 'agent-sync', }; const info = manager.finalizeSyncSubagent('task-sdk', '{}', false, sdkToolUseResult); expect(info).not.toBeNull(); // Verify the extracted result (first content block) was passed to the renderer expect(finalizeSubagentBlock).toHaveBeenCalledWith( expect.anything(), 'Full sync subagent result with multiple lines.\n\nSecond paragraph.', false ); }); it('returns null when finalizing nonexistent subagent', () => { const { manager } = createManager(); const info = manager.finalizeSyncSubagent('nonexistent', 'done', false); expect(info).toBeNull(); }); it('ignores tool call for nonexistent subagent', () => { const { addSubagentToolCall } = jest.requireMock('@/features/chat/rendering'); const { manager } = createManager(); manager.addSyncToolCall('nonexistent', { id: 'tc-1', name: 'Read', input: {}, status: 'running', isExpanded: false, }); expect(addSubagentToolCall).not.toHaveBeenCalled(); }); }); // ============================================ // Async Error Resolution (resolveAsyncError) // ============================================ describe('resolveAsyncError via handleAgentOutputToolResult', () => { function setupRunningSubagent(manager: SubagentManager) { const parentEl = createMockEl(); manager.handleTaskToolUse('task-1', { description: 'BG', run_in_background: true }, parentEl); manager.handleTaskToolResult('task-1', JSON.stringify({ agent_id: 'agent-1' })); manager.handleAgentOutputToolUse({ id: 'output-1', name: 'AgentOutput', input: { task_id: 'agent-1' }, status: 'running', isExpanded: false, }); } it('marks completed when toolUseResult.status is "completed" even if chunk isError is true', () => { const { manager, updates } = createManager(); setupRunningSubagent(manager); manager.handleAgentOutputToolResult( 'output-1', 'result text', true, { status: 'completed', content: [{ type: 'text', text: 'Done' }] } ); const final = updates[updates.length - 1]; expect(final.asyncStatus).toBe('completed'); expect(final.status).toBe('completed'); }); it('marks completed when toolUseResult.retrieval_status is "success" even if chunk isError is true', () => { const { manager, updates } = createManager(); setupRunningSubagent(manager); manager.handleAgentOutputToolResult( 'output-1', 'result text', true, { retrieval_status: 'success', result: 'All good' } ); const final = updates[updates.length - 1]; expect(final.asyncStatus).toBe('completed'); }); it('marks error when toolUseResult.retrieval_status is "error" even if chunk isError is false', () => { const { manager, updates } = createManager(); setupRunningSubagent(manager); manager.handleAgentOutputToolResult( 'output-1', 'result text', false, { retrieval_status: 'error', error: 'Agent crashed' } ); const final = updates[updates.length - 1]; expect(final.asyncStatus).toBe('error'); }); it('falls back to chunk isError when toolUseResult has no status fields', () => { const { manager, updates } = createManager(); setupRunningSubagent(manager); manager.handleAgentOutputToolResult('output-1', 'result text', true, { foo: 'bar' }); const final = updates[updates.length - 1]; expect(final.asyncStatus).toBe('error'); }); it('falls back to chunk isError when no toolUseResult is provided', () => { const { manager, updates } = createManager(); setupRunningSubagent(manager); manager.handleAgentOutputToolResult('output-1', 'result text', false); const final = updates[updates.length - 1]; expect(final.asyncStatus).toBe('completed'); }); }); // ============================================ // Hook Delivery Methods // ============================================ describe('hook delivery', () => { describe('hasRunningSubagents', () => { it('returns false when no subagents exist', () => { const { manager } = createManager(); expect(manager.hasRunningSubagents()).toBe(false); }); it('returns true when pending async subagents exist', () => { const { manager } = createManager(); const parentEl = createMockEl(); manager.handleTaskToolUse('task-1', { description: 'Background', run_in_background: true }, parentEl); expect(manager.hasRunningSubagents()).toBe(true); }); it('returns true when active running subagents exist', () => { const { manager } = createManager(); const parentEl = createMockEl(); manager.handleTaskToolUse('task-1', { description: 'Background', run_in_background: true }, parentEl); manager.handleTaskToolResult('task-1', JSON.stringify({ agent_id: 'agent-123' })); expect(manager.hasRunningSubagents()).toBe(true); }); it('returns false when all subagents have completed', () => { const { manager } = createManager(); const parentEl = createMockEl(); manager.handleTaskToolUse('task-1', { description: 'Task agent-123', run_in_background: true }, parentEl); manager.handleTaskToolResult('task-1', JSON.stringify({ agent_id: 'agent-123' })); manager.handleAgentOutputToolUse({ id: 'output-agent-123', name: 'AgentOutput', input: { agent_id: 'agent-123' }, status: 'running', isExpanded: false, }); manager.handleAgentOutputToolResult( 'output-agent-123', JSON.stringify({ result: 'Result from agent-123' }), false ); expect(manager.hasRunningSubagents()).toBe(false); }); }); }); // ============================================ // Lifecycle // ============================================ describe('lifecycle', () => { it('resets spawned count', () => { const { manager } = createManager(); const parentEl = createMockEl(); manager.handleTaskToolUse('task-1', { run_in_background: false }, parentEl); expect(manager.subagentsSpawnedThisStream).toBe(1); manager.resetSpawnedCount(); expect(manager.subagentsSpawnedThisStream).toBe(0); }); it('resets streaming state clears sync maps and pending tasks', () => { const { manager } = createManager(); const parentEl = createMockEl(); manager.handleTaskToolUse('task-sync', { run_in_background: false }, parentEl); manager.handleTaskToolUse('task-pending', { prompt: 'test' }, null); expect(manager.getSyncSubagent('task-sync')).toBeDefined(); expect(manager.hasPendingTask('task-pending')).toBe(true); manager.resetStreamingState(); expect(manager.getSyncSubagent('task-sync')).toBeUndefined(); expect(manager.hasPendingTask('task-pending')).toBe(false); }); it('clears all state', () => { const { manager } = createManager(); const parentEl = createMockEl(); manager.handleTaskToolUse('task-async', { description: 'Background', run_in_background: true }, parentEl); manager.clear(); expect(manager.getByTaskId('task-async')).toBeUndefined(); expect(manager.isPendingAsyncTask('task-async')).toBe(false); }); it('updates callback via setCallback', () => { const { manager } = createManager(); const parentEl = createMockEl(); const newUpdates: SubagentInfo[] = []; manager.handleTaskToolUse('task-1', { description: 'Background', run_in_background: true }, parentEl); manager.setCallback((subagent) => { newUpdates.push({ ...subagent }); }); manager.handleTaskToolResult('task-1', JSON.stringify({ agent_id: 'agent-new' })); expect(newUpdates.length).toBeGreaterThan(0); expect(newUpdates[newUpdates.length - 1].agentId).toBe('agent-new'); }); }); }); ================================================ FILE: tests/unit/features/chat/services/TitleGenerationService.test.ts ================================================ // eslint-disable-next-line jest/no-mocks-import import { getLastOptions, resetMockMessages, setMockMessages, } from '@test/__mocks__/claude-agent-sdk'; import { type TitleGenerationResult, TitleGenerationService } from '@/features/chat/services/TitleGenerationService'; function createMockPlugin(settings = {}) { return { settings: { model: 'sonnet', titleGenerationModel: '', thinkingBudget: 'off', ...settings, }, app: { vault: { adapter: { basePath: '/test/vault/path', }, }, }, getActiveEnvironmentVariables: jest.fn().mockReturnValue(''), getResolvedClaudeCliPath: jest.fn().mockReturnValue('/fake/claude'), } as any; } describe('TitleGenerationService', () => { let service: TitleGenerationService; let mockPlugin: any; beforeEach(() => { jest.clearAllMocks(); resetMockMessages(); mockPlugin = createMockPlugin(); service = new TitleGenerationService(mockPlugin); }); describe('generateTitle', () => { it('should generate a title from user message', async () => { setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, { type: 'assistant', message: { content: [{ type: 'text', text: 'Setting Up React Project' }], }, }, { type: 'result' }, ]); const callback = jest.fn(); await service.generateTitle( 'conv-123', 'How do I set up a React project?', callback ); expect(callback).toHaveBeenCalledWith('conv-123', { success: true, title: 'Setting Up React Project', }); }); it('should use no tools for title generation', async () => { setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, { type: 'assistant', message: { content: [{ type: 'text', text: 'Test Title' }], }, }, { type: 'result' }, ]); const callback = jest.fn(); await service.generateTitle('conv-123', 'test', callback); const options = getLastOptions(); expect(options?.tools).toEqual([]); expect(options?.permissionMode).toBe('bypassPermissions'); }); it('should use titleGenerationModel setting when set', async () => { mockPlugin.settings.titleGenerationModel = 'opus'; setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, { type: 'assistant', message: { content: [{ type: 'text', text: 'Title' }], }, }, { type: 'result' }, ]); const callback = jest.fn(); await service.generateTitle('conv-123', 'test', callback); const options = getLastOptions(); expect(options?.model).toBe('opus'); }); it('should prioritize setting over env var', async () => { mockPlugin.settings.titleGenerationModel = 'sonnet'; mockPlugin.getActiveEnvironmentVariables.mockReturnValue( 'ANTHROPIC_DEFAULT_HAIKU_MODEL=custom-haiku' ); setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, { type: 'assistant', message: { content: [{ type: 'text', text: 'Title' }], }, }, { type: 'result' }, ]); const callback = jest.fn(); await service.generateTitle('conv-123', 'test', callback); const options = getLastOptions(); expect(options?.model).toBe('sonnet'); }); it('should use ANTHROPIC_DEFAULT_HAIKU_MODEL when setting is empty', async () => { mockPlugin.settings.titleGenerationModel = ''; mockPlugin.getActiveEnvironmentVariables.mockReturnValue( 'ANTHROPIC_DEFAULT_HAIKU_MODEL=custom-haiku' ); setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, { type: 'assistant', message: { content: [{ type: 'text', text: 'Title' }], }, }, { type: 'result' }, ]); const callback = jest.fn(); await service.generateTitle('conv-123', 'test', callback); const options = getLastOptions(); expect(options?.model).toBe('custom-haiku'); }); it('should fallback to claude-haiku-4-5 model', async () => { setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, { type: 'assistant', message: { content: [{ type: 'text', text: 'Title' }], }, }, { type: 'result' }, ]); const callback = jest.fn(); await service.generateTitle('conv-123', 'test', callback); const options = getLastOptions(); expect(options?.model).toBe('claude-haiku-4-5'); }); it('should strip surrounding quotes from title', async () => { setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, { type: 'assistant', message: { content: [{ type: 'text', text: '"Quoted Title"' }], }, }, { type: 'result' }, ]); const callback = jest.fn(); await service.generateTitle('conv-123', 'test', callback); expect(callback).toHaveBeenCalledWith('conv-123', { success: true, title: 'Quoted Title', }); }); it('should strip trailing punctuation from title', async () => { setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, { type: 'assistant', message: { content: [{ type: 'text', text: 'Title With Punctuation...' }], }, }, { type: 'result' }, ]); const callback = jest.fn(); await service.generateTitle('conv-123', 'test', callback); expect(callback).toHaveBeenCalledWith('conv-123', { success: true, title: 'Title With Punctuation', }); }); it('should truncate titles longer than 50 characters', async () => { const longTitle = 'A'.repeat(60); setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, { type: 'assistant', message: { content: [{ type: 'text', text: longTitle }], }, }, { type: 'result' }, ]); const callback = jest.fn(); await service.generateTitle('conv-123', 'test', callback); expect(callback).toHaveBeenCalledWith('conv-123', { success: true, title: 'A'.repeat(47) + '...', }); }); it('should fail gracefully when response is empty', async () => { setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, { type: 'assistant', message: { content: [{ type: 'text', text: '' }], }, }, { type: 'result' }, ]); const callback = jest.fn(); await service.generateTitle('conv-123', 'test', callback); expect(callback).toHaveBeenCalledWith('conv-123', { success: false, error: 'Failed to parse title from response', }); }); it('should fail when vault path cannot be determined', async () => { mockPlugin.app.vault.adapter.basePath = undefined; const callback = jest.fn(); await service.generateTitle('conv-123', 'test', callback); expect(callback).toHaveBeenCalledWith('conv-123', { success: false, error: 'Could not determine vault path', }); }); it('should fail when Claude CLI is not found', async () => { mockPlugin.getResolvedClaudeCliPath.mockReturnValue(null); const callback = jest.fn(); await service.generateTitle('conv-123', 'test', callback); expect(callback).toHaveBeenCalledWith('conv-123', { success: false, error: 'Claude CLI not found', }); }); it('should set settingSources to project only when loadUserClaudeSettings is false', async () => { mockPlugin.settings.loadUserClaudeSettings = false; setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, { type: 'assistant', message: { content: [{ type: 'text', text: 'Title' }], }, }, { type: 'result' }, ]); const callback = jest.fn(); await service.generateTitle('conv-123', 'test', callback); const options = getLastOptions(); expect(options?.settingSources).toEqual(['project']); }); it('should set settingSources to include user when loadUserClaudeSettings is true', async () => { mockPlugin.settings.loadUserClaudeSettings = true; setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, { type: 'assistant', message: { content: [{ type: 'text', text: 'Title' }], }, }, { type: 'result' }, ]); const callback = jest.fn(); await service.generateTitle('conv-123', 'test', callback); const options = getLastOptions(); expect(options?.settingSources).toEqual(['user', 'project']); }); it('should truncate long user messages', async () => { const longMessage = 'x'.repeat(1000); setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, { type: 'assistant', message: { content: [{ type: 'text', text: 'Title' }], }, }, { type: 'result' }, ]); const callback = jest.fn(); await service.generateTitle('conv-123', longMessage, callback); // Service should still complete successfully with truncated message expect(callback).toHaveBeenCalledWith('conv-123', { success: true, title: 'Title', }); }); }); describe('concurrent generation', () => { it('should support multiple concurrent generations', async () => { setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, { type: 'assistant', message: { content: [{ type: 'text', text: 'Title' }], }, }, { type: 'result' }, ]); const callback1 = jest.fn(); const callback2 = jest.fn(); // Start two generations concurrently const promise1 = service.generateTitle('conv-1', 'msg1', callback1); const promise2 = service.generateTitle('conv-2', 'msg2', callback2); await Promise.all([promise1, promise2]); expect(callback1).toHaveBeenCalledWith('conv-1', { success: true, title: 'Title' }); expect(callback2).toHaveBeenCalledWith('conv-2', { success: true, title: 'Title' }); }); it('should cancel previous generation for same conversation', async () => { // First call will be aborted when second call starts const callback1 = jest.fn(); const callback2 = jest.fn(); // Mock a slow first generation setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, { type: 'assistant', message: { content: [{ type: 'text', text: 'Title 1' }], }, }, { type: 'result' }, ]); // Start first generation (won't await it) const promise1 = service.generateTitle('conv-1', 'msg1', callback1); // Immediately start second generation for same conversation setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session-2' }, { type: 'assistant', message: { content: [{ type: 'text', text: 'Title 2' }], }, }, { type: 'result' }, ]); const promise2 = service.generateTitle('conv-1', 'msg2', callback2); await Promise.all([promise1, promise2]); // Second generation should complete with new title expect(callback2).toHaveBeenCalledWith('conv-1', { success: true, title: 'Title 2' }); }); }); describe('cancel', () => { it('should cancel all active generations', async () => { setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, { type: 'assistant', message: { content: [{ type: 'text', text: 'Title' }], }, }, { type: 'result' }, ]); const callback = jest.fn(); // Start generation then cancel immediately const promise = service.generateTitle('conv-1', 'msg', callback); service.cancel(); await promise; // Should have been called with cancelled error or completed expect(callback).toHaveBeenCalled(); }); }); describe('safeCallback', () => { it('should catch errors thrown by callback', async () => { setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, { type: 'assistant', message: { content: [{ type: 'text', text: 'Title' }], }, }, { type: 'result' }, ]); const throwingCallback = jest.fn().mockRejectedValue(new Error('Callback error')); // Should not throw await expect( service.generateTitle('conv-123', 'test', throwingCallback) ).resolves.not.toThrow(); }); }); }); describe('TitleGenerationResult type', () => { it('should be a discriminated union for success', () => { const success: TitleGenerationResult = { success: true, title: 'Test Title' }; expect(success.success).toBe(true); // TypeScript narrows the type based on success: true expect(success).toEqual({ success: true, title: 'Test Title' }); }); it('should be a discriminated union for failure', () => { const failure: TitleGenerationResult = { success: false, error: 'Some error' }; expect(failure.success).toBe(false); // TypeScript narrows the type based on success: false expect(failure).toEqual({ success: false, error: 'Some error' }); }); }); ================================================ FILE: tests/unit/features/chat/state/ChatState.test.ts ================================================ import { ChatState, createInitialState } from '@/features/chat/state/ChatState'; import type { ChatStateCallbacks } from '@/features/chat/state/types'; describe('ChatState', () => { describe('createInitialState', () => { it('returns correct default values', () => { const state = createInitialState(); expect(state.messages).toEqual([]); expect(state.isStreaming).toBe(false); expect(state.cancelRequested).toBe(false); expect(state.streamGeneration).toBe(0); expect(state.isCreatingConversation).toBe(false); expect(state.isSwitchingConversation).toBe(false); expect(state.currentConversationId).toBeNull(); expect(state.queuedMessage).toBeNull(); expect(state.currentContentEl).toBeNull(); expect(state.currentTextEl).toBeNull(); expect(state.currentTextContent).toBe(''); expect(state.currentThinkingState).toBeNull(); expect(state.thinkingEl).toBeNull(); expect(state.queueIndicatorEl).toBeNull(); expect(state.thinkingIndicatorTimeout).toBeNull(); expect(state.toolCallElements).toBeInstanceOf(Map); expect(state.writeEditStates).toBeInstanceOf(Map); expect(state.pendingTools).toBeInstanceOf(Map); expect(state.usage).toBeNull(); expect(state.ignoreUsageUpdates).toBe(false); expect(state.currentTodos).toBeNull(); expect(state.needsAttention).toBe(false); expect(state.autoScrollEnabled).toBe(true); expect(state.responseStartTime).toBeNull(); expect(state.flavorTimerInterval).toBeNull(); }); }); describe('messages', () => { it('returns a copy of messages', () => { const chatState = new ChatState(); const msg = { id: '1', role: 'user' as const, content: 'hi', timestamp: 1 }; chatState.addMessage(msg); const msgs = chatState.messages; msgs.push({ id: '2', role: 'user' as const, content: 'bye', timestamp: 2 }); expect(chatState.messages).toHaveLength(1); }); it('fires onMessagesChanged when setting messages', () => { const onMessagesChanged = jest.fn(); const chatState = new ChatState({ onMessagesChanged }); chatState.messages = [{ id: '1', role: 'user', content: 'hi', timestamp: 1 }]; expect(onMessagesChanged).toHaveBeenCalledTimes(1); }); it('fires onMessagesChanged when adding a message', () => { const onMessagesChanged = jest.fn(); const chatState = new ChatState({ onMessagesChanged }); chatState.addMessage({ id: '1', role: 'user', content: 'hi', timestamp: 1 }); expect(onMessagesChanged).toHaveBeenCalledTimes(1); }); it('fires onMessagesChanged when clearing messages', () => { const onMessagesChanged = jest.fn(); const chatState = new ChatState({ onMessagesChanged }); chatState.addMessage({ id: '1', role: 'user', content: 'hi', timestamp: 1 }); chatState.clearMessages(); expect(chatState.messages).toHaveLength(0); expect(onMessagesChanged).toHaveBeenCalledTimes(2); // once for add, once for clear }); }); describe('streaming control', () => { it('fires onStreamingStateChanged when isStreaming changes', () => { const onStreamingStateChanged = jest.fn(); const chatState = new ChatState({ onStreamingStateChanged }); chatState.isStreaming = true; expect(onStreamingStateChanged).toHaveBeenCalledWith(true); }); it('bumpStreamGeneration increments and returns the new value', () => { const chatState = new ChatState(); expect(chatState.streamGeneration).toBe(0); const gen1 = chatState.bumpStreamGeneration(); expect(gen1).toBe(1); expect(chatState.streamGeneration).toBe(1); const gen2 = chatState.bumpStreamGeneration(); expect(gen2).toBe(2); }); it('stores cancelRequested', () => { const chatState = new ChatState(); chatState.cancelRequested = true; expect(chatState.cancelRequested).toBe(true); }); it('stores isCreatingConversation', () => { const chatState = new ChatState(); chatState.isCreatingConversation = true; expect(chatState.isCreatingConversation).toBe(true); }); it('stores isSwitchingConversation', () => { const chatState = new ChatState(); chatState.isSwitchingConversation = true; expect(chatState.isSwitchingConversation).toBe(true); }); }); describe('conversation', () => { it('fires onConversationChanged when currentConversationId changes', () => { const onConversationChanged = jest.fn(); const chatState = new ChatState({ onConversationChanged }); chatState.currentConversationId = 'conv-1'; expect(onConversationChanged).toHaveBeenCalledWith('conv-1'); }); it('fires onConversationChanged with null', () => { const onConversationChanged = jest.fn(); const chatState = new ChatState({ onConversationChanged }); chatState.currentConversationId = 'conv-1'; chatState.currentConversationId = null; expect(onConversationChanged).toHaveBeenCalledWith(null); }); }); describe('queued message', () => { it('stores and retrieves queued message', () => { const chatState = new ChatState(); const queued = { content: 'queued', editorContext: null, canvasContext: null }; chatState.queuedMessage = queued; expect(chatState.queuedMessage).toBe(queued); }); }); describe('streaming DOM state', () => { it('stores currentContentEl', () => { const chatState = new ChatState(); const el = {} as HTMLElement; chatState.currentContentEl = el; expect(chatState.currentContentEl).toBe(el); }); it('stores currentTextEl', () => { const chatState = new ChatState(); const el = {} as HTMLElement; chatState.currentTextEl = el; expect(chatState.currentTextEl).toBe(el); }); it('stores currentTextContent', () => { const chatState = new ChatState(); chatState.currentTextContent = 'hello'; expect(chatState.currentTextContent).toBe('hello'); }); it('stores currentThinkingState', () => { const chatState = new ChatState(); const state = { content: 'thinking' } as any; chatState.currentThinkingState = state; expect(chatState.currentThinkingState).toBe(state); }); it('stores thinkingEl', () => { const chatState = new ChatState(); const el = {} as HTMLElement; chatState.thinkingEl = el; expect(chatState.thinkingEl).toBe(el); }); it('stores queueIndicatorEl', () => { const chatState = new ChatState(); const el = {} as HTMLElement; chatState.queueIndicatorEl = el; expect(chatState.queueIndicatorEl).toBe(el); }); it('stores thinkingIndicatorTimeout', () => { const chatState = new ChatState(); const timeout = setTimeout(() => {}, 100); chatState.thinkingIndicatorTimeout = timeout; expect(chatState.thinkingIndicatorTimeout).toBe(timeout); clearTimeout(timeout); }); }); describe('tool tracking maps', () => { it('returns mutable toolCallElements map', () => { const chatState = new ChatState(); const el = {} as HTMLElement; chatState.toolCallElements.set('tool-1', el); expect(chatState.toolCallElements.get('tool-1')).toBe(el); }); it('returns mutable writeEditStates map', () => { const chatState = new ChatState(); const state = {} as any; chatState.writeEditStates.set('we-1', state); expect(chatState.writeEditStates.get('we-1')).toBe(state); }); it('returns mutable pendingTools map', () => { const chatState = new ChatState(); const pt = { toolCall: {} as any, parentEl: null }; chatState.pendingTools.set('pt-1', pt); expect(chatState.pendingTools.get('pt-1')).toBe(pt); }); }); describe('usage', () => { it('fires onUsageChanged when usage changes', () => { const onUsageChanged = jest.fn(); const chatState = new ChatState({ onUsageChanged }); const usage = { inputTokens: 100, outputTokens: 50 } as any; chatState.usage = usage; expect(onUsageChanged).toHaveBeenCalledWith(usage); }); it('fires onUsageChanged with null', () => { const onUsageChanged = jest.fn(); const chatState = new ChatState({ onUsageChanged }); chatState.usage = { inputTokens: 100, outputTokens: 50 } as any; chatState.usage = null; expect(onUsageChanged).toHaveBeenCalledWith(null); }); it('stores ignoreUsageUpdates', () => { const chatState = new ChatState(); chatState.ignoreUsageUpdates = true; expect(chatState.ignoreUsageUpdates).toBe(true); }); }); describe('currentTodos', () => { it('fires onTodosChanged when todos change', () => { const onTodosChanged = jest.fn(); const chatState = new ChatState({ onTodosChanged }); const todos = [{ content: 'Test', status: 'pending' as const, activeForm: 'Testing' }]; chatState.currentTodos = todos; expect(onTodosChanged).toHaveBeenCalledWith(todos); }); it('normalizes empty array to null', () => { const onTodosChanged = jest.fn(); const chatState = new ChatState({ onTodosChanged }); chatState.currentTodos = []; expect(onTodosChanged).toHaveBeenCalledWith(null); }); it('returns a copy of todos', () => { const chatState = new ChatState(); const todos = [{ content: 'Test', status: 'pending' as const, activeForm: 'Testing' }]; chatState.currentTodos = todos; const retrieved = chatState.currentTodos!; retrieved.push({ content: 'Other', status: 'pending' as const, activeForm: 'Othering' }); expect(chatState.currentTodos).toHaveLength(1); }); it('returns null when not set', () => { const chatState = new ChatState(); expect(chatState.currentTodos).toBeNull(); }); }); describe('needsAttention', () => { it('fires onAttentionChanged when value changes', () => { const onAttentionChanged = jest.fn(); const chatState = new ChatState({ onAttentionChanged }); chatState.needsAttention = true; expect(onAttentionChanged).toHaveBeenCalledWith(true); }); }); describe('autoScrollEnabled', () => { it('fires onAutoScrollChanged when value changes', () => { const onAutoScrollChanged = jest.fn(); const chatState = new ChatState({ onAutoScrollChanged }); // Default is true, so set to false to trigger change chatState.autoScrollEnabled = false; expect(onAutoScrollChanged).toHaveBeenCalledWith(false); }); it('does not fire onAutoScrollChanged when value is the same', () => { const onAutoScrollChanged = jest.fn(); const chatState = new ChatState({ onAutoScrollChanged }); // Default is true, set to true again chatState.autoScrollEnabled = true; expect(onAutoScrollChanged).not.toHaveBeenCalled(); }); }); describe('response timer', () => { it('stores responseStartTime', () => { const chatState = new ChatState(); chatState.responseStartTime = 12345; expect(chatState.responseStartTime).toBe(12345); }); it('stores flavorTimerInterval', () => { const chatState = new ChatState(); const interval = setInterval(() => {}, 1000); chatState.flavorTimerInterval = interval; expect(chatState.flavorTimerInterval).toBe(interval); clearInterval(interval); }); }); describe('clearFlavorTimerInterval', () => { it('clears active interval', () => { const chatState = new ChatState(); const clearSpy = jest.spyOn(global, 'clearInterval'); const interval = setInterval(() => {}, 1000); chatState.flavorTimerInterval = interval; chatState.clearFlavorTimerInterval(); expect(clearSpy).toHaveBeenCalledWith(interval); expect(chatState.flavorTimerInterval).toBeNull(); clearSpy.mockRestore(); }); it('is a no-op when no interval is active', () => { const chatState = new ChatState(); const clearSpy = jest.spyOn(global, 'clearInterval'); chatState.clearFlavorTimerInterval(); expect(clearSpy).not.toHaveBeenCalled(); clearSpy.mockRestore(); }); }); describe('resetStreamingState', () => { it('resets all streaming-related state', () => { const chatState = new ChatState(); chatState.currentContentEl = {} as HTMLElement; chatState.currentTextEl = {} as HTMLElement; chatState.currentTextContent = 'text'; chatState.currentThinkingState = {} as any; chatState.isStreaming = true; chatState.cancelRequested = true; const timeout = setTimeout(() => {}, 1000); chatState.thinkingIndicatorTimeout = timeout; const interval = setInterval(() => {}, 1000); chatState.flavorTimerInterval = interval; chatState.responseStartTime = 12345; chatState.resetStreamingState(); expect(chatState.currentContentEl).toBeNull(); expect(chatState.currentTextEl).toBeNull(); expect(chatState.currentTextContent).toBe(''); expect(chatState.currentThinkingState).toBeNull(); expect(chatState.isStreaming).toBe(false); expect(chatState.cancelRequested).toBe(false); expect(chatState.thinkingIndicatorTimeout).toBeNull(); expect(chatState.flavorTimerInterval).toBeNull(); expect(chatState.responseStartTime).toBeNull(); }); }); describe('clearMaps', () => { it('clears all tracking maps', () => { const chatState = new ChatState(); chatState.toolCallElements.set('a', {} as HTMLElement); chatState.writeEditStates.set('b', {} as any); chatState.pendingTools.set('c', { toolCall: {} as any, parentEl: null }); chatState.clearMaps(); expect(chatState.toolCallElements.size).toBe(0); expect(chatState.writeEditStates.size).toBe(0); expect(chatState.pendingTools.size).toBe(0); }); }); describe('resetForNewConversation', () => { it('resets all conversation state', () => { const onMessagesChanged = jest.fn(); const onUsageChanged = jest.fn(); const onTodosChanged = jest.fn(); const onAutoScrollChanged = jest.fn(); const chatState = new ChatState({ onMessagesChanged, onUsageChanged, onTodosChanged, onAutoScrollChanged, }); // Set up some state chatState.addMessage({ id: '1', role: 'user', content: 'hi', timestamp: 1 }); chatState.isStreaming = true; chatState.cancelRequested = true; chatState.currentContentEl = {} as HTMLElement; chatState.toolCallElements.set('a', {} as HTMLElement); chatState.queuedMessage = { content: 'queued', editorContext: null, canvasContext: null }; chatState.usage = { inputTokens: 100, outputTokens: 50 } as any; chatState.currentTodos = [{ content: 'Test', status: 'pending' as const, activeForm: 'Testing' }]; // autoScrollEnabled defaults to true, set to false first so reset triggers change chatState.autoScrollEnabled = false; // Reset all tracking jest.clearAllMocks(); chatState.resetForNewConversation(); expect(chatState.messages).toHaveLength(0); expect(chatState.isStreaming).toBe(false); expect(chatState.cancelRequested).toBe(false); expect(chatState.currentContentEl).toBeNull(); expect(chatState.toolCallElements.size).toBe(0); expect(chatState.writeEditStates.size).toBe(0); expect(chatState.pendingTools.size).toBe(0); expect(chatState.queuedMessage).toBeNull(); expect(chatState.usage).toBeNull(); expect(chatState.currentTodos).toBeNull(); expect(chatState.autoScrollEnabled).toBe(true); // Verify callbacks were fired expect(onMessagesChanged).toHaveBeenCalled(); expect(onUsageChanged).toHaveBeenCalledWith(null); expect(onTodosChanged).toHaveBeenCalledWith(null); expect(onAutoScrollChanged).toHaveBeenCalledWith(true); }); }); describe('getPersistedMessages', () => { it('returns messages as-is', () => { const chatState = new ChatState(); const msg = { id: '1', role: 'user' as const, content: 'test', timestamp: 1 }; chatState.addMessage(msg); const persisted = chatState.getPersistedMessages(); expect(persisted).toHaveLength(1); expect(persisted[0]).toEqual(msg); }); }); describe('callbacks property', () => { it('allows getting callbacks', () => { const callbacks: ChatStateCallbacks = { onMessagesChanged: jest.fn() }; const chatState = new ChatState(callbacks); expect(chatState.callbacks).toBe(callbacks); }); it('allows setting callbacks', () => { const chatState = new ChatState(); const newCallbacks: ChatStateCallbacks = { onMessagesChanged: jest.fn() }; chatState.callbacks = newCallbacks; chatState.addMessage({ id: '1', role: 'user', content: 'hi', timestamp: 1 }); expect(newCallbacks.onMessagesChanged).toHaveBeenCalled(); }); }); describe('truncateAt', () => { it('removes target message and all after, fires callback', () => { const onMessagesChanged = jest.fn(); const chatState = new ChatState({ onMessagesChanged }); chatState.addMessage({ id: 'a', role: 'user', content: 'first', timestamp: 1 }); chatState.addMessage({ id: 'b', role: 'assistant', content: 'reply1', timestamp: 2 }); chatState.addMessage({ id: 'c', role: 'user', content: 'second', timestamp: 3 }); chatState.addMessage({ id: 'd', role: 'assistant', content: 'reply2', timestamp: 4 }); onMessagesChanged.mockClear(); const removed = chatState.truncateAt('c'); expect(removed).toBe(2); expect(chatState.messages.map(m => m.id)).toEqual(['a', 'b']); expect(onMessagesChanged).toHaveBeenCalledTimes(1); }); it('returns 0 and does not fire callback for unknown id', () => { const onMessagesChanged = jest.fn(); const chatState = new ChatState({ onMessagesChanged }); chatState.addMessage({ id: 'a', role: 'user', content: 'first', timestamp: 1 }); onMessagesChanged.mockClear(); const removed = chatState.truncateAt('nonexistent'); expect(removed).toBe(0); expect(chatState.messages.map(m => m.id)).toEqual(['a']); expect(onMessagesChanged).not.toHaveBeenCalled(); }); it('clears all messages when truncating at first message', () => { const onMessagesChanged = jest.fn(); const chatState = new ChatState({ onMessagesChanged }); chatState.addMessage({ id: 'a', role: 'user', content: 'first', timestamp: 1 }); chatState.addMessage({ id: 'b', role: 'assistant', content: 'reply', timestamp: 2 }); onMessagesChanged.mockClear(); const removed = chatState.truncateAt('a'); expect(removed).toBe(2); expect(chatState.messages).toEqual([]); expect(onMessagesChanged).toHaveBeenCalledTimes(1); }); it('removes only last message when truncating at last', () => { const onMessagesChanged = jest.fn(); const chatState = new ChatState({ onMessagesChanged }); chatState.addMessage({ id: 'a', role: 'user', content: 'first', timestamp: 1 }); chatState.addMessage({ id: 'b', role: 'assistant', content: 'reply', timestamp: 2 }); onMessagesChanged.mockClear(); const removed = chatState.truncateAt('b'); expect(removed).toBe(1); expect(chatState.messages.map(m => m.id)).toEqual(['a']); expect(onMessagesChanged).toHaveBeenCalledTimes(1); }); }); }); ================================================ FILE: tests/unit/features/chat/tabs/Tab.test.ts ================================================ import { createMockEl } from '@test/helpers/mockElement'; import { Notice } from 'obsidian'; import { ChatState } from '@/features/chat/state/ChatState'; import { activateTab, createTab, deactivateTab, destroyTab, getTabTitle, initializeTabControllers, initializeTabService, initializeTabUI, setupServiceCallbacks, type TabCreateOptions, wireTabInputEvents, } from '@/features/chat/tabs/Tab'; // Mock ResizeObserver (not available in jsdom) const resizeObserverInstances: MockResizeObserver[] = []; class MockResizeObserver { callback: ResizeObserverCallback; constructor(callback: ResizeObserverCallback) { this.callback = callback; resizeObserverInstances.push(this); } observe = jest.fn(); unobserve = jest.fn(); disconnect = jest.fn(); } global.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver; // Mock ClaudianService jest.mock('@/core/agent', () => ({ ClaudianService: jest.fn().mockImplementation(() => ({ ensureReady: jest.fn().mockResolvedValue(true), closePersistentQuery: jest.fn(), isReady: jest.fn().mockReturnValue(false), applyForkState: jest.fn((conv: any) => conv.sessionId ?? conv.forkSource?.sessionId ?? null), onReadyStateChange: jest.fn((listener: (ready: boolean) => void) => { listener(false); return () => {}; }), })), })); // Mock factories must be defined before jest.mock calls due to hoisting // These will be initialized fresh in beforeEach const createMockFileContextManager = () => ({ setMcpManager: jest.fn(), setAgentService: jest.fn(), setOnMcpMentionChange: jest.fn(), preScanExternalContexts: jest.fn(), handleInputChange: jest.fn(), handleMentionKeydown: jest.fn().mockReturnValue(false), isMentionDropdownVisible: jest.fn().mockReturnValue(false), hideMentionDropdown: jest.fn(), destroy: jest.fn(), }); const createMockImageContextManager = () => ({ destroy: jest.fn(), }); const createMockSlashCommandDropdown = () => ({ handleKeydown: jest.fn().mockReturnValue(false), isVisible: jest.fn().mockReturnValue(false), hide: jest.fn(), setEnabled: jest.fn(), destroy: jest.fn(), }); const createMockInstructionModeManager = () => ({ handleTriggerKey: jest.fn().mockReturnValue(false), handleKeydown: jest.fn().mockReturnValue(false), handleInputChange: jest.fn(), isActive: jest.fn().mockReturnValue(false), destroy: jest.fn(), }); const createMockBangBashModeManager = () => ({ handleTriggerKey: jest.fn().mockReturnValue(false), handleKeydown: jest.fn().mockReturnValue(false), handleInputChange: jest.fn(), isActive: jest.fn().mockReturnValue(false), destroy: jest.fn(), }); const createMockStatusPanel = () => ({ mount: jest.fn(), remount: jest.fn(), updateTodos: jest.fn(), destroy: jest.fn(), }); const createMockModelSelector = () => ({ updateDisplay: jest.fn(), renderOptions: jest.fn(), setReady: jest.fn(), }); const createMockClaudianService = (overrides?: { ensureReady?: jest.Mock; onReadyStateChange?: jest.Mock; }) => ({ ensureReady: overrides?.ensureReady ?? jest.fn().mockResolvedValue(true), closePersistentQuery: jest.fn(), isReady: jest.fn().mockReturnValue(false), applyForkState: jest.fn((conv: any) => conv.sessionId ?? conv.forkSource?.sessionId ?? null), onReadyStateChange: overrides?.onReadyStateChange ?? jest.fn((listener: (ready: boolean) => void) => { listener(false); return () => {}; }), }); const createMockThinkingBudgetSelector = () => ({ updateDisplay: jest.fn(), }); const createMockContextUsageMeter = () => ({ update: jest.fn(), }); const createMockExternalContextSelector = () => ({ getExternalContexts: jest.fn().mockReturnValue([]), setOnChange: jest.fn(), setPersistentPaths: jest.fn(), setOnPersistenceChange: jest.fn(), }); const createMockMcpServerSelector = () => ({ setMcpManager: jest.fn(), addMentionedServers: jest.fn(), }); const createMockPermissionToggle = () => ({}); // Shared mock instances (reset in beforeEach) let mockFileContextManager: ReturnType<typeof createMockFileContextManager>; let mockImageContextManager: ReturnType<typeof createMockImageContextManager>; let mockSlashCommandDropdown: ReturnType<typeof createMockSlashCommandDropdown>; let mockInstructionModeManager: ReturnType<typeof createMockInstructionModeManager>; let mockBangBashModeManager: ReturnType<typeof createMockBangBashModeManager>; let mockStatusPanel: ReturnType<typeof createMockStatusPanel>; let mockModelSelector: ReturnType<typeof createMockModelSelector>; let mockThinkingBudgetSelector: ReturnType<typeof createMockThinkingBudgetSelector>; let mockContextUsageMeter: ReturnType<typeof createMockContextUsageMeter>; let mockExternalContextSelector: ReturnType<typeof createMockExternalContextSelector>; let mockMcpServerSelector: ReturnType<typeof createMockMcpServerSelector>; let mockPermissionToggle: ReturnType<typeof createMockPermissionToggle>; let mockMessageRenderer: { scrollToBottomIfNeeded: jest.Mock; setAsyncSubagentClickCallback: jest.Mock }; let mockSelectionController: ReturnType<typeof createMockSelectionController>; let mockBrowserSelectionController: ReturnType<typeof createMockBrowserSelectionController>; let mockCanvasSelectionController: ReturnType<typeof createMockCanvasSelectionController>; let mockStreamController: { onAsyncSubagentStateChange: jest.Mock }; let mockConversationController: { save: jest.Mock }; let mockInputController: ReturnType<typeof createMockInputController>; let mockNavigationController: { initialize: jest.Mock; dispose: jest.Mock }; const createMockSelectionController = () => ({ start: jest.fn(), stop: jest.fn(), clear: jest.fn(), showHighlight: jest.fn(), updateContextRowVisibility: jest.fn(), }); const createMockBrowserSelectionController = () => ({ start: jest.fn(), stop: jest.fn(), clear: jest.fn(), updateContextRowVisibility: jest.fn(), }); const createMockCanvasSelectionController = () => ({ start: jest.fn(), stop: jest.fn(), clear: jest.fn(), updateContextRowVisibility: jest.fn(), }); const createMockInputController = () => ({ sendMessage: jest.fn(), cancelStreaming: jest.fn(), handleInstructionSubmit: jest.fn(), updateQueueIndicator: jest.fn(), handleResumeKeydown: jest.fn().mockReturnValue(false), isResumeDropdownVisible: jest.fn().mockReturnValue(false), destroyResumeDropdown: jest.fn(), }); jest.mock('@/features/chat/ui', () => ({ FileContextManager: jest.fn().mockImplementation(() => { mockFileContextManager = createMockFileContextManager(); return mockFileContextManager; }), ImageContextManager: jest.fn().mockImplementation(() => { mockImageContextManager = createMockImageContextManager(); return mockImageContextManager; }), InstructionModeManager: jest.fn().mockImplementation(() => { mockInstructionModeManager = createMockInstructionModeManager(); return mockInstructionModeManager; }), StatusPanel: jest.fn().mockImplementation(() => { mockStatusPanel = createMockStatusPanel(); return mockStatusPanel; }), createInputToolbar: jest.fn().mockImplementation(() => { mockModelSelector = createMockModelSelector(); mockThinkingBudgetSelector = createMockThinkingBudgetSelector(); mockContextUsageMeter = createMockContextUsageMeter(); mockExternalContextSelector = createMockExternalContextSelector(); mockMcpServerSelector = createMockMcpServerSelector(); mockPermissionToggle = createMockPermissionToggle(); return { modelSelector: mockModelSelector, thinkingBudgetSelector: mockThinkingBudgetSelector, contextUsageMeter: mockContextUsageMeter, externalContextSelector: mockExternalContextSelector, mcpServerSelector: mockMcpServerSelector, permissionToggle: mockPermissionToggle, }; }), })); jest.mock('@/shared/components/SlashCommandDropdown', () => ({ SlashCommandDropdown: jest.fn().mockImplementation(() => { mockSlashCommandDropdown = createMockSlashCommandDropdown(); return mockSlashCommandDropdown; }), })); // Mock rendering jest.mock('@/features/chat/rendering', () => ({ MessageRenderer: jest.fn().mockImplementation(() => { mockMessageRenderer = { scrollToBottomIfNeeded: jest.fn(), setAsyncSubagentClickCallback: jest.fn(), }; return mockMessageRenderer; }), cleanupThinkingBlock: jest.fn(), })); // Mock controllers jest.mock('@/features/chat/controllers', () => ({ SelectionController: jest.fn().mockImplementation(() => { mockSelectionController = createMockSelectionController(); return mockSelectionController; }), BrowserSelectionController: jest.fn().mockImplementation(() => { mockBrowserSelectionController = createMockBrowserSelectionController(); return mockBrowserSelectionController; }), CanvasSelectionController: jest.fn().mockImplementation(() => { mockCanvasSelectionController = createMockCanvasSelectionController(); return mockCanvasSelectionController; }), StreamController: jest.fn().mockImplementation(() => { mockStreamController = { onAsyncSubagentStateChange: jest.fn() }; return mockStreamController; }), ConversationController: jest.fn().mockImplementation(() => { mockConversationController = { save: jest.fn().mockResolvedValue(undefined) }; return mockConversationController; }), InputController: jest.fn().mockImplementation(() => { mockInputController = createMockInputController(); return mockInputController; }), NavigationController: jest.fn().mockImplementation(() => { mockNavigationController = { initialize: jest.fn(), dispose: jest.fn() }; return mockNavigationController; }), })); // Mock services jest.mock('@/features/chat/services/SubagentManager', () => ({ SubagentManager: jest.fn().mockImplementation(() => ({ orphanAllActive: jest.fn(), setCallback: jest.fn(), clear: jest.fn(), })), })); jest.mock('@/features/chat/services/InstructionRefineService', () => ({ InstructionRefineService: jest.fn().mockImplementation(() => ({ cancel: jest.fn(), })), })); jest.mock('@/features/chat/services/TitleGenerationService', () => ({ TitleGenerationService: jest.fn().mockImplementation(() => ({ cancel: jest.fn(), })), })); // Mock path util jest.mock('@/utils/path', () => ({ getVaultPath: jest.fn().mockReturnValue('/test/vault'), })); // Helper to create mock plugin function createMockPlugin(overrides: Record<string, any> = {}): any { return { app: { vault: { adapter: { basePath: '/test/vault' }, }, }, settings: { excludedTags: [], model: 'claude-sonnet-4-5', thinkingBudget: 'low', permissionMode: 'yolo', slashCommands: [], keyboardNavigation: { scrollUpKey: 'k', scrollDownKey: 'j', focusInputKey: 'i', }, persistentExternalContextPaths: [], }, mcpManager: { getMcpServers: jest.fn().mockReturnValue([]) }, agentManager: { searchAgents: jest.fn().mockReturnValue([]) }, getConversationById: jest.fn().mockResolvedValue(null), getConversationSync: jest.fn().mockReturnValue(null), saveSettings: jest.fn().mockResolvedValue(undefined), getActiveEnvironmentVariables: jest.fn().mockReturnValue({}), ...overrides, }; } // Helper to create mock MCP manager function createMockMcpManager(): any { return { getMcpServers: jest.fn().mockReturnValue([]), }; } // Helper to create TabCreateOptions function createMockOptions(overrides: Partial<TabCreateOptions> = {}): TabCreateOptions { return { plugin: createMockPlugin(), mcpManager: createMockMcpManager(), containerEl: createMockEl(), ...overrides, }; } describe('Tab - Creation', () => { describe('createTab', () => { it('should create a new tab with unique ID', () => { const options = createMockOptions(); const tab = createTab(options); expect(tab.id).toBeDefined(); expect(tab.id).toMatch(/^tab-/); }); it('should use provided tab ID when specified', () => { const options = createMockOptions({ tabId: 'custom-tab-id' }); const tab = createTab(options); expect(tab.id).toBe('custom-tab-id'); }); it('should initialize with null conversationId when no conversation provided', () => { const options = createMockOptions(); const tab = createTab(options); expect(tab.conversationId).toBeNull(); }); it('should set conversationId when conversation is provided', () => { const options = createMockOptions({ conversation: { id: 'conv-123', title: 'Test Conversation', messages: [], sessionId: null, createdAt: Date.now(), updatedAt: Date.now(), }, }); const tab = createTab(options); expect(tab.conversationId).toBe('conv-123'); }); it('should create tab with lazy-initialized service (null)', () => { const options = createMockOptions(); const tab = createTab(options); expect(tab.service).toBeNull(); expect(tab.serviceInitialized).toBe(false); }); it('should create ChatState with callbacks', () => { const onStreamingChanged = jest.fn(); const onAttentionChanged = jest.fn(); const onConversationIdChanged = jest.fn(); const options = createMockOptions({ onStreamingChanged, onAttentionChanged, onConversationIdChanged, }); const tab = createTab(options); expect(tab.state).toBeInstanceOf(ChatState); }); it('should create DOM structure with hidden content', () => { const containerEl = createMockEl(); const options = createMockOptions({ containerEl }); const tab = createTab(options); expect(tab.dom.contentEl).toBeDefined(); expect(tab.dom.contentEl.style.display).toBe('none'); expect(tab.dom.messagesEl).toBeDefined(); expect(tab.dom.inputEl).toBeDefined(); }); it('should initialize empty eventCleanups array', () => { const options = createMockOptions(); const tab = createTab(options); expect(tab.dom.eventCleanups).toEqual([]); }); it('should initialize all controllers as null', () => { const options = createMockOptions(); const tab = createTab(options); expect(tab.controllers.selectionController).toBeNull(); expect(tab.controllers.conversationController).toBeNull(); expect(tab.controllers.streamController).toBeNull(); expect(tab.controllers.inputController).toBeNull(); expect(tab.controllers.navigationController).toBeNull(); }); }); }); describe('Tab - Service Initialization', () => { describe('initializeTabService', () => { it('should not reinitialize if already initialized', async () => { const options = createMockOptions(); const tab = createTab(options); tab.serviceInitialized = true; tab.service = {} as any; await initializeTabService(tab, options.plugin, options.mcpManager); // Service should not be replaced expect(tab.service).toEqual({}); }); it('should create ClaudianService on first initialization', async () => { const options = createMockOptions(); const tab = createTab(options); await initializeTabService(tab, options.plugin, options.mcpManager); expect(tab.service).toBeDefined(); expect(tab.serviceInitialized).toBe(true); }); it('should ensureReady without session ID (just spin up process)', async () => { const mockEnsureReady = jest.fn().mockResolvedValue(true); const agentModule = jest.requireMock('@/core/agent') as { ClaudianService: jest.Mock }; agentModule.ClaudianService.mockImplementationOnce(() => createMockClaudianService({ ensureReady: mockEnsureReady })); const options = createMockOptions(); const tab = createTab(options); await initializeTabService(tab, options.plugin, options.mcpManager); expect(mockEnsureReady).toHaveBeenCalledWith({ sessionId: undefined, externalContextPaths: [], }); }); it('should ensureReady with saved external contexts for existing conversation', async () => { const mockEnsureReady = jest.fn().mockResolvedValue(true); const agentModule = jest.requireMock('@/core/agent') as { ClaudianService: jest.Mock }; agentModule.ClaudianService.mockImplementationOnce(() => createMockClaudianService({ ensureReady: mockEnsureReady })); const conversation = { id: 'conv-1', title: 'Existing Conversation', messages: [{ id: 'msg-1', role: 'user' as const, content: 'test', timestamp: Date.now() }], sessionId: 'session-123', externalContextPaths: ['/saved/path'], createdAt: Date.now(), updatedAt: Date.now(), }; const plugin = createMockPlugin(); plugin.settings.persistentExternalContextPaths = ['/persistent/path']; plugin.getConversationById = jest.fn().mockResolvedValue(conversation); const options = createMockOptions({ plugin, conversation }); const tab = createTab(options); await initializeTabService(tab, options.plugin, options.mcpManager); expect(mockEnsureReady).toHaveBeenCalledWith({ sessionId: 'session-123', externalContextPaths: ['/saved/path'], }); }); it('should sync model selector ready state with service readiness', async () => { const mockOnReadyStateChange = jest.fn((listener: (ready: boolean) => void) => { listener(false); return () => {}; }); const agentModule = jest.requireMock('@/core/agent') as { ClaudianService: jest.Mock }; agentModule.ClaudianService.mockImplementationOnce(() => createMockClaudianService({ onReadyStateChange: mockOnReadyStateChange })); const options = createMockOptions(); const tab = createTab(options); initializeTabUI(tab, options.plugin); await initializeTabService(tab, options.plugin, options.mcpManager); expect(mockModelSelector.setReady).toHaveBeenCalledWith(false); const readyListener = mockOnReadyStateChange.mock.calls[0]?.[0] as (ready: boolean) => void; readyListener(true); expect(mockModelSelector.setReady).toHaveBeenCalledWith(true); readyListener(false); expect(mockModelSelector.setReady).toHaveBeenCalledWith(false); }); }); }); describe('Tab - Activation/Deactivation', () => { describe('activateTab', () => { it('should show tab content', () => { const options = createMockOptions(); const tab = createTab(options); activateTab(tab); expect(tab.dom.contentEl.style.display).toBe('flex'); }); }); describe('deactivateTab', () => { it('should hide tab content', () => { const options = createMockOptions(); const tab = createTab(options); // First activate, then deactivate activateTab(tab); deactivateTab(tab); expect(tab.dom.contentEl.style.display).toBe('none'); }); }); }); describe('Tab - Event Wiring', () => { describe('wireTabInputEvents', () => { it('should register event listeners on input element', () => { const options = createMockOptions(); const tab = createTab(options); // Initialize minimal controllers needed tab.controllers.inputController = { sendMessage: jest.fn(), cancelStreaming: jest.fn(), } as any; tab.controllers.selectionController = { showHighlight: jest.fn(), } as any; wireTabInputEvents(tab, options.plugin); // Check that event listeners were added (cast to any to access mock method) const listeners = (tab.dom.inputEl as any).getEventListeners(); expect(listeners.get('keydown')).toBeDefined(); expect(listeners.get('input')).toBeDefined(); expect(listeners.get('focus')).toBeDefined(); }); it('should store cleanup functions for memory management', () => { const options = createMockOptions(); const tab = createTab(options); // Initialize minimal controllers tab.controllers.inputController = { sendMessage: jest.fn() } as any; tab.controllers.selectionController = { showHighlight: jest.fn() } as any; wireTabInputEvents(tab, options.plugin); expect(tab.dom.eventCleanups.length).toBe(4); // keydown, input, focus, scroll }); }); }); describe('Tab - Destruction', () => { describe('destroyTab', () => { it('should be an async function', async () => { const options = createMockOptions(); const tab = createTab(options); const result = destroyTab(tab); expect(result).toBeInstanceOf(Promise); await result; // Should resolve without error }); it('should call cleanup functions for event listeners', async () => { const options = createMockOptions(); const tab = createTab(options); const cleanup1 = jest.fn(); const cleanup2 = jest.fn(); tab.dom.eventCleanups = [cleanup1, cleanup2]; await destroyTab(tab); expect(cleanup1).toHaveBeenCalled(); expect(cleanup2).toHaveBeenCalled(); }); it('should clear eventCleanups array after cleanup', async () => { const options = createMockOptions(); const tab = createTab(options); tab.dom.eventCleanups = [jest.fn(), jest.fn()]; await destroyTab(tab); expect(tab.dom.eventCleanups.length).toBe(0); }); it('should unsubscribe from ready state changes when tab is destroyed', async () => { const unsubscribeFn = jest.fn(); const mockOnReadyStateChange = jest.fn(() => unsubscribeFn); const agentModule = jest.requireMock('@/core/agent') as { ClaudianService: jest.Mock }; agentModule.ClaudianService.mockImplementationOnce(() => createMockClaudianService({ onReadyStateChange: mockOnReadyStateChange })); const options = createMockOptions(); const tab = createTab(options); initializeTabUI(tab, options.plugin); await initializeTabService(tab, options.plugin, options.mcpManager); expect(mockOnReadyStateChange).toHaveBeenCalled(); await destroyTab(tab); expect(unsubscribeFn).toHaveBeenCalled(); }); it('should close service persistent query', async () => { const mockClosePersistentQuery = jest.fn(); const options = createMockOptions(); const tab = createTab(options); tab.service = { closePersistentQuery: mockClosePersistentQuery, } as any; await destroyTab(tab); expect(mockClosePersistentQuery).toHaveBeenCalledWith('tab closed'); expect(tab.service).toBeNull(); }); it('should remove DOM element', async () => { const options = createMockOptions(); const tab = createTab(options); const removeSpy = jest.spyOn(tab.dom.contentEl, 'remove'); await destroyTab(tab); expect(removeSpy).toHaveBeenCalled(); }); it('should cleanup subagents', async () => { const options = createMockOptions(); const tab = createTab(options); const orphanAllActive = jest.fn(); const clear = jest.fn(); tab.services.subagentManager = { orphanAllActive, clear } as any; await destroyTab(tab); expect(orphanAllActive).toHaveBeenCalled(); expect(clear).toHaveBeenCalled(); }); it('should cleanup UI components', async () => { const options = createMockOptions(); const tab = createTab(options); const destroyFileContext = jest.fn(); const destroySlashDropdown = jest.fn(); const destroyInstructionMode = jest.fn(); const cancelInstructionRefine = jest.fn(); const cancelTitleGeneration = jest.fn(); const destroyTodoPanel = jest.fn(); const destroyResumeDropdown = jest.fn(); tab.controllers.inputController = { destroyResumeDropdown } as any; tab.ui.fileContextManager = { destroy: destroyFileContext } as any; tab.ui.slashCommandDropdown = { destroy: destroySlashDropdown } as any; tab.ui.instructionModeManager = { destroy: destroyInstructionMode } as any; tab.services.instructionRefineService = { cancel: cancelInstructionRefine } as any; tab.services.titleGenerationService = { cancel: cancelTitleGeneration } as any; tab.ui.statusPanel = { destroy: destroyTodoPanel } as any; await destroyTab(tab); expect(destroyResumeDropdown).toHaveBeenCalled(); expect(destroyFileContext).toHaveBeenCalled(); expect(destroySlashDropdown).toHaveBeenCalled(); expect(destroyInstructionMode).toHaveBeenCalled(); expect(cancelInstructionRefine).toHaveBeenCalled(); expect(cancelTitleGeneration).toHaveBeenCalled(); expect(destroyTodoPanel).toHaveBeenCalled(); }); }); }); describe('Tab - Service Callbacks', () => { describe('setupServiceCallbacks', () => { it('renders tool-only auto-triggered turns with a placeholder assistant message', () => { const plugin = createMockPlugin(); const tab = createTab(createMockOptions({ plugin })); const addMessageSpy = jest.spyOn(tab.state, 'addMessage'); const renderStoredMessage = jest.fn(); const scrollToBottom = jest.fn(); Object.defineProperty(tab.dom.contentEl, 'isConnected', { value: true, writable: true, configurable: true, }); tab.renderer = { renderStoredMessage, scrollToBottom, } as any; tab.controllers.inputController = { handleApprovalRequest: jest.fn(), dismissPendingApproval: jest.fn(), handleAskUserQuestion: jest.fn(), handleExitPlanMode: jest.fn(), } as any; tab.services.subagentManager = { hasRunningSubagents: jest.fn().mockReturnValue(false), } as any; const service = { setApprovalCallback: jest.fn(), setApprovalDismisser: jest.fn(), setAskUserQuestionCallback: jest.fn(), setExitPlanModeCallback: jest.fn(), setSubagentHookProvider: jest.fn(), setAutoTurnCallback: jest.fn(), setPermissionModeSyncCallback: jest.fn(), }; tab.service = service as any; setupServiceCallbacks(tab, plugin); const autoTurnCallback = service.setAutoTurnCallback.mock.calls[0][0]; autoTurnCallback([ { type: 'tool_result', tool_use_id: 'task-1', content: 'done' }, ]); expect(addMessageSpy).toHaveBeenCalledWith( expect.objectContaining({ role: 'assistant', content: '(background task completed)', }) ); expect(renderStoredMessage).toHaveBeenCalled(); expect(scrollToBottom).toHaveBeenCalled(); }); it('skips auto-triggered rendering after the tab DOM is detached', () => { const plugin = createMockPlugin(); const tab = createTab(createMockOptions({ plugin })); const addMessageSpy = jest.spyOn(tab.state, 'addMessage'); const renderStoredMessage = jest.fn(); const scrollToBottom = jest.fn(); Object.defineProperty(tab.dom.contentEl, 'isConnected', { value: true, writable: true, configurable: true, }); tab.renderer = { renderStoredMessage, scrollToBottom, } as any; tab.controllers.inputController = { handleApprovalRequest: jest.fn(), dismissPendingApproval: jest.fn(), handleAskUserQuestion: jest.fn(), handleExitPlanMode: jest.fn(), } as any; tab.services.subagentManager = { hasRunningSubagents: jest.fn().mockReturnValue(false), } as any; const service = { setApprovalCallback: jest.fn(), setApprovalDismisser: jest.fn(), setAskUserQuestionCallback: jest.fn(), setExitPlanModeCallback: jest.fn(), setSubagentHookProvider: jest.fn(), setAutoTurnCallback: jest.fn(), setPermissionModeSyncCallback: jest.fn(), }; tab.service = service as any; setupServiceCallbacks(tab, plugin); const autoTurnCallback = service.setAutoTurnCallback.mock.calls[0][0]; (tab.dom.contentEl as any).isConnected = false; autoTurnCallback([ { type: 'text', content: 'Background result' }, ]); expect(addMessageSpy).not.toHaveBeenCalled(); expect(renderStoredMessage).not.toHaveBeenCalled(); expect(scrollToBottom).not.toHaveBeenCalled(); }); }); }); describe('Tab - Title', () => { describe('getTabTitle', () => { it('should return "New Chat" for tab without conversation', () => { const options = createMockOptions(); const tab = createTab(options); const title = getTabTitle(tab, options.plugin); expect(title).toBe('New Chat'); }); it('should return conversation title when available', () => { const plugin = createMockPlugin({ getConversationSync: jest.fn().mockReturnValue({ id: 'conv-123', title: 'My Conversation', }), }); const options = createMockOptions({ plugin }); const tab = createTab(options); tab.conversationId = 'conv-123'; const title = getTabTitle(tab, plugin); expect(title).toBe('My Conversation'); }); it('should return "New Chat" when conversation has no title', () => { const plugin = createMockPlugin({ getConversationSync: jest.fn().mockReturnValue({ id: 'conv-123', title: null, }), }); const options = createMockOptions({ plugin }); const tab = createTab(options); tab.conversationId = 'conv-123'; const title = getTabTitle(tab, plugin); expect(title).toBe('New Chat'); }); }); }); describe('Tab - UI Initialization', () => { beforeEach(() => { jest.clearAllMocks(); }); describe('initializeTabUI', () => { it('should create FileContextManager', () => { const options = createMockOptions(); const tab = createTab(options); initializeTabUI(tab, options.plugin); expect(tab.ui.fileContextManager).toBeDefined(); }); it('should wire FileContextManager to MCP service', () => { const options = createMockOptions(); const tab = createTab(options); initializeTabUI(tab, options.plugin); expect(mockFileContextManager.setMcpManager).toHaveBeenCalledWith(options.plugin.mcpManager); }); it('should create ImageContextManager', () => { const options = createMockOptions(); const tab = createTab(options); initializeTabUI(tab, options.plugin); expect(tab.ui.imageContextManager).toBeDefined(); }); it('should create selection indicator element', () => { const options = createMockOptions(); const tab = createTab(options); initializeTabUI(tab, options.plugin); expect(tab.dom.selectionIndicatorEl).toBeDefined(); expect(tab.dom.selectionIndicatorEl!.style.display).toBe('none'); }); it('should create SlashCommandDropdown', () => { const options = createMockOptions(); const tab = createTab(options); initializeTabUI(tab, options.plugin); expect(tab.ui.slashCommandDropdown).toBeDefined(); }); it('should create InstructionRefineService', () => { const options = createMockOptions(); const tab = createTab(options); initializeTabUI(tab, options.plugin); expect(tab.services.instructionRefineService).toBeDefined(); }); it('should create TitleGenerationService', () => { const options = createMockOptions(); const tab = createTab(options); initializeTabUI(tab, options.plugin); expect(tab.services.titleGenerationService).toBeDefined(); }); it('should create InstructionModeManager', () => { const options = createMockOptions(); const tab = createTab(options); initializeTabUI(tab, options.plugin); expect(tab.ui.instructionModeManager).toBeDefined(); }); it('should create and mount StatusPanel', () => { const options = createMockOptions(); const tab = createTab(options); initializeTabUI(tab, options.plugin); expect(tab.ui.statusPanel).toBeDefined(); expect(mockStatusPanel.mount).toHaveBeenCalledWith(tab.dom.statusPanelContainerEl); }); it('should create input toolbar components', () => { const options = createMockOptions(); const tab = createTab(options); initializeTabUI(tab, options.plugin); expect(tab.ui.modelSelector).toBeDefined(); expect(tab.ui.thinkingBudgetSelector).toBeDefined(); expect(tab.ui.contextUsageMeter).toBeDefined(); expect(tab.ui.externalContextSelector).toBeDefined(); expect(tab.ui.mcpServerSelector).toBeDefined(); expect(tab.ui.permissionToggle).toBeDefined(); }); it('should wire MCP server selector to MCP service', () => { const options = createMockOptions(); const tab = createTab(options); initializeTabUI(tab, options.plugin); expect(mockMcpServerSelector.setMcpManager).toHaveBeenCalledWith(options.plugin.mcpManager); }); it('should wire external context selector onChange', () => { const options = createMockOptions(); const tab = createTab(options); initializeTabUI(tab, options.plugin); expect(mockExternalContextSelector.setOnChange).toHaveBeenCalled(); }); it('should initialize persistent paths from settings', () => { const plugin = createMockPlugin({ settings: { ...createMockPlugin().settings, persistentExternalContextPaths: ['/path/1', '/path/2'], }, }); const options = createMockOptions({ plugin }); const tab = createTab(options); initializeTabUI(tab, plugin); expect(mockExternalContextSelector.setPersistentPaths).toHaveBeenCalledWith(['/path/1', '/path/2']); }); it('should update ChatState callbacks for UI updates', () => { const options = createMockOptions(); const tab = createTab(options); initializeTabUI(tab, options.plugin); // Verify callbacks are set by checking the state expect(tab.state.callbacks.onUsageChanged).toBeDefined(); expect(tab.state.callbacks.onTodosChanged).toBeDefined(); }); }); }); describe('Tab - Controller Initialization', () => { beforeEach(() => { jest.clearAllMocks(); }); describe('initializeTabControllers', () => { it('should create MessageRenderer', () => { const options = createMockOptions(); const tab = createTab(options); const mockComponent = {} as any; initializeTabUI(tab, options.plugin); initializeTabControllers(tab, options.plugin, mockComponent, options.mcpManager); expect(tab.renderer).toBeDefined(); }); it('should create SelectionController', () => { const options = createMockOptions(); const tab = createTab(options); const mockComponent = {} as any; initializeTabUI(tab, options.plugin); initializeTabControllers(tab, options.plugin, mockComponent, options.mcpManager); expect(tab.controllers.selectionController).toBeDefined(); }); it('should create StreamController', () => { const options = createMockOptions(); const tab = createTab(options); const mockComponent = {} as any; initializeTabUI(tab, options.plugin); initializeTabControllers(tab, options.plugin, mockComponent, options.mcpManager); expect(tab.controllers.streamController).toBeDefined(); }); it('should create ConversationController', () => { const options = createMockOptions(); const tab = createTab(options); const mockComponent = {} as any; initializeTabUI(tab, options.plugin); initializeTabControllers(tab, options.plugin, mockComponent, options.mcpManager); expect(tab.controllers.conversationController).toBeDefined(); }); it('should create InputController', () => { const options = createMockOptions(); const tab = createTab(options); const mockComponent = {} as any; initializeTabUI(tab, options.plugin); initializeTabControllers(tab, options.plugin, mockComponent, options.mcpManager); expect(tab.controllers.inputController).toBeDefined(); }); it('should create and initialize NavigationController', () => { const options = createMockOptions(); const tab = createTab(options); const mockComponent = {} as any; initializeTabUI(tab, options.plugin); initializeTabControllers(tab, options.plugin, mockComponent, options.mcpManager); expect(tab.controllers.navigationController).toBeDefined(); expect(mockNavigationController.initialize).toHaveBeenCalled(); }); it('should update SubagentManager with StreamController callback', () => { const options = createMockOptions(); const tab = createTab(options); const mockComponent = {} as any; initializeTabUI(tab, options.plugin); initializeTabControllers(tab, options.plugin, mockComponent, options.mcpManager); // The subagent manager should have its callback set expect(tab.services.subagentManager).toBeDefined(); }); it('persists async subagent state changes when not streaming', async () => { const options = createMockOptions(); const tab = createTab(options); const mockComponent = {} as any; initializeTabUI(tab, options.plugin); initializeTabControllers(tab, options.plugin, mockComponent, options.mcpManager); tab.state.currentConversationId = 'conv-1'; tab.state.isStreaming = false; const setCallback = tab.services.subagentManager.setCallback as jest.Mock; const callback = setCallback.mock.calls[0][0] as (subagent: any) => void; callback({ id: 'task-1', description: 'Background task', mode: 'async', asyncStatus: 'completed', status: 'completed', prompt: 'do work', result: 'done', toolCalls: [], isExpanded: false, }); // Wait one microtask so Promise chain from save(false) can run. await Promise.resolve(); expect(mockStreamController.onAsyncSubagentStateChange).toHaveBeenCalled(); expect(mockConversationController.save).toHaveBeenCalledWith(false); }); it('does not persist async subagent state while main stream is active', async () => { const options = createMockOptions(); const tab = createTab(options); const mockComponent = {} as any; initializeTabUI(tab, options.plugin); initializeTabControllers(tab, options.plugin, mockComponent, options.mcpManager); tab.state.currentConversationId = 'conv-1'; tab.state.isStreaming = true; const setCallback = tab.services.subagentManager.setCallback as jest.Mock; const callback = setCallback.mock.calls[0][0] as (subagent: any) => void; callback({ id: 'task-1', description: 'Background task', mode: 'async', asyncStatus: 'running', status: 'running', toolCalls: [], isExpanded: false, }); await Promise.resolve(); expect(mockConversationController.save).not.toHaveBeenCalled(); }); }); }); describe('Tab - Event Handler Behavior', () => { beforeEach(() => { jest.clearAllMocks(); mockFileContextManager = createMockFileContextManager(); mockSlashCommandDropdown = createMockSlashCommandDropdown(); mockInstructionModeManager = createMockInstructionModeManager(); mockBangBashModeManager = createMockBangBashModeManager(); mockInputController = createMockInputController(); mockSelectionController = createMockSelectionController(); }); describe('wireTabInputEvents - keydown handlers', () => { it('should not pass keydown events to other handlers when bang-bash mode is active', () => { const options = createMockOptions(); const tab = createTab(options); tab.ui.bangBashModeManager = mockBangBashModeManager as any; tab.ui.instructionModeManager = mockInstructionModeManager as any; tab.ui.slashCommandDropdown = mockSlashCommandDropdown as any; tab.ui.fileContextManager = mockFileContextManager as any; tab.controllers.inputController = mockInputController as any; tab.controllers.selectionController = mockSelectionController as any; mockBangBashModeManager.isActive.mockReturnValue(true); wireTabInputEvents(tab, options.plugin); const listeners = (tab.dom.inputEl as any).getEventListeners(); const keydownHandler = listeners.get('keydown')[0]; const event = { key: '#', preventDefault: jest.fn() }; keydownHandler(event); expect(mockBangBashModeManager.handleKeydown).toHaveBeenCalled(); expect(mockInstructionModeManager.handleTriggerKey).not.toHaveBeenCalled(); expect(mockSlashCommandDropdown.handleKeydown).not.toHaveBeenCalled(); expect(mockFileContextManager.handleMentionKeydown).not.toHaveBeenCalled(); }); it('should suppress slash dropdown and mention handling on bang-bash enter/exit', () => { const options = createMockOptions(); const tab = createTab(options); let active = false; tab.ui.bangBashModeManager = { isActive: jest.fn(() => active), handleTriggerKey: jest.fn((e: any) => { active = true; e.preventDefault(); return true; }), handleKeydown: jest.fn((e: any) => { if (!active) return false; if (e.key === 'Escape') { active = false; e.preventDefault(); return true; } return false; }), handleInputChange: jest.fn(), destroy: jest.fn(), } as any; tab.ui.instructionModeManager = mockInstructionModeManager as any; tab.ui.slashCommandDropdown = mockSlashCommandDropdown as any; tab.ui.fileContextManager = mockFileContextManager as any; tab.controllers.inputController = mockInputController as any; tab.controllers.selectionController = mockSelectionController as any; mockInstructionModeManager.handleTriggerKey.mockReturnValue(false); mockInstructionModeManager.handleKeydown.mockReturnValue(false); wireTabInputEvents(tab, options.plugin); const listeners = (tab.dom.inputEl as any).getEventListeners(); const keydownHandler = listeners.get('keydown')[0]; keydownHandler({ key: '!', preventDefault: jest.fn() }); expect(mockSlashCommandDropdown.setEnabled).toHaveBeenCalledWith(false); expect(mockFileContextManager.hideMentionDropdown).toHaveBeenCalled(); keydownHandler({ key: 'Escape', preventDefault: jest.fn() }); expect(mockSlashCommandDropdown.setEnabled).toHaveBeenCalledWith(true); }); it('should handle instruction mode trigger key', () => { const options = createMockOptions(); const tab = createTab(options); // Set up UI managers tab.ui.instructionModeManager = mockInstructionModeManager as any; tab.ui.slashCommandDropdown = mockSlashCommandDropdown as any; tab.ui.fileContextManager = mockFileContextManager as any; tab.controllers.inputController = mockInputController as any; tab.controllers.selectionController = mockSelectionController as any; // Make instruction mode handle the trigger mockInstructionModeManager.handleTriggerKey.mockReturnValueOnce(true); wireTabInputEvents(tab, options.plugin); // Simulate keydown const listeners = (tab.dom.inputEl as any).getEventListeners(); const keydownHandler = listeners.get('keydown')[0]; const event = { key: '#', preventDefault: jest.fn() }; keydownHandler(event); expect(mockInstructionModeManager.handleTriggerKey).toHaveBeenCalled(); }); it('should handle instruction mode keydown', () => { const options = createMockOptions(); const tab = createTab(options); tab.ui.instructionModeManager = mockInstructionModeManager as any; tab.ui.slashCommandDropdown = mockSlashCommandDropdown as any; tab.ui.fileContextManager = mockFileContextManager as any; tab.controllers.inputController = mockInputController as any; tab.controllers.selectionController = mockSelectionController as any; // Make instruction mode handle keydown mockInstructionModeManager.handleTriggerKey.mockReturnValue(false); mockInstructionModeManager.handleKeydown.mockReturnValueOnce(true); wireTabInputEvents(tab, options.plugin); const listeners = (tab.dom.inputEl as any).getEventListeners(); const keydownHandler = listeners.get('keydown')[0]; const event = { key: 'Tab', preventDefault: jest.fn() }; keydownHandler(event); expect(mockInstructionModeManager.handleKeydown).toHaveBeenCalled(); }); it('should handle slash command dropdown keydown', () => { const options = createMockOptions(); const tab = createTab(options); tab.ui.instructionModeManager = mockInstructionModeManager as any; tab.ui.slashCommandDropdown = mockSlashCommandDropdown as any; tab.ui.fileContextManager = mockFileContextManager as any; tab.controllers.inputController = mockInputController as any; tab.controllers.selectionController = mockSelectionController as any; mockInstructionModeManager.handleTriggerKey.mockReturnValue(false); mockInstructionModeManager.handleKeydown.mockReturnValue(false); mockSlashCommandDropdown.handleKeydown.mockReturnValueOnce(true); wireTabInputEvents(tab, options.plugin); const listeners = (tab.dom.inputEl as any).getEventListeners(); const keydownHandler = listeners.get('keydown')[0]; const event = { key: 'ArrowDown', preventDefault: jest.fn() }; keydownHandler(event); expect(mockSlashCommandDropdown.handleKeydown).toHaveBeenCalled(); }); it('should handle resume dropdown keydown', () => { const options = createMockOptions(); const tab = createTab(options); tab.ui.instructionModeManager = mockInstructionModeManager as any; tab.ui.slashCommandDropdown = mockSlashCommandDropdown as any; tab.ui.fileContextManager = mockFileContextManager as any; tab.controllers.inputController = mockInputController as any; tab.controllers.selectionController = mockSelectionController as any; mockInstructionModeManager.handleTriggerKey.mockReturnValue(false); mockInstructionModeManager.handleKeydown.mockReturnValue(false); mockInputController.handleResumeKeydown.mockReturnValueOnce(true); wireTabInputEvents(tab, options.plugin); const listeners = (tab.dom.inputEl as any).getEventListeners(); const keydownHandler = listeners.get('keydown')[0]; const event = { key: 'ArrowDown', preventDefault: jest.fn() }; keydownHandler(event); expect(mockInputController.handleResumeKeydown).toHaveBeenCalled(); expect(mockSlashCommandDropdown.handleKeydown).not.toHaveBeenCalled(); expect(mockFileContextManager.handleMentionKeydown).not.toHaveBeenCalled(); }); it('should handle file context mention keydown', () => { const options = createMockOptions(); const tab = createTab(options); tab.ui.instructionModeManager = mockInstructionModeManager as any; tab.ui.slashCommandDropdown = mockSlashCommandDropdown as any; tab.ui.fileContextManager = mockFileContextManager as any; tab.controllers.inputController = mockInputController as any; tab.controllers.selectionController = mockSelectionController as any; mockInstructionModeManager.handleTriggerKey.mockReturnValue(false); mockInstructionModeManager.handleKeydown.mockReturnValue(false); mockSlashCommandDropdown.handleKeydown.mockReturnValue(false); mockFileContextManager.handleMentionKeydown.mockReturnValueOnce(true); wireTabInputEvents(tab, options.plugin); const listeners = (tab.dom.inputEl as any).getEventListeners(); const keydownHandler = listeners.get('keydown')[0]; const event = { key: 'ArrowUp', preventDefault: jest.fn() }; keydownHandler(event); expect(mockFileContextManager.handleMentionKeydown).toHaveBeenCalled(); }); it('should cancel streaming on Escape when streaming', () => { const options = createMockOptions(); const tab = createTab(options); tab.ui.instructionModeManager = mockInstructionModeManager as any; tab.ui.slashCommandDropdown = mockSlashCommandDropdown as any; tab.ui.fileContextManager = mockFileContextManager as any; tab.controllers.inputController = mockInputController as any; tab.controllers.selectionController = mockSelectionController as any; tab.state.isStreaming = true; mockInstructionModeManager.handleTriggerKey.mockReturnValue(false); mockInstructionModeManager.handleKeydown.mockReturnValue(false); mockSlashCommandDropdown.handleKeydown.mockReturnValue(false); mockFileContextManager.handleMentionKeydown.mockReturnValue(false); wireTabInputEvents(tab, options.plugin); const listeners = (tab.dom.inputEl as any).getEventListeners(); const keydownHandler = listeners.get('keydown')[0]; const event = { key: 'Escape', isComposing: false, preventDefault: jest.fn() }; keydownHandler(event); expect(event.preventDefault).toHaveBeenCalled(); expect(mockInputController.cancelStreaming).toHaveBeenCalled(); }); it('should not cancel streaming on Escape when isComposing (IME)', () => { const options = createMockOptions(); const tab = createTab(options); tab.ui.instructionModeManager = mockInstructionModeManager as any; tab.ui.slashCommandDropdown = mockSlashCommandDropdown as any; tab.ui.fileContextManager = mockFileContextManager as any; tab.controllers.inputController = mockInputController as any; tab.controllers.selectionController = mockSelectionController as any; tab.state.isStreaming = true; mockInstructionModeManager.handleTriggerKey.mockReturnValue(false); mockInstructionModeManager.handleKeydown.mockReturnValue(false); mockSlashCommandDropdown.handleKeydown.mockReturnValue(false); mockFileContextManager.handleMentionKeydown.mockReturnValue(false); wireTabInputEvents(tab, options.plugin); const listeners = (tab.dom.inputEl as any).getEventListeners(); const keydownHandler = listeners.get('keydown')[0]; const event = { key: 'Escape', isComposing: true, preventDefault: jest.fn() }; keydownHandler(event); expect(event.preventDefault).not.toHaveBeenCalled(); expect(mockInputController.cancelStreaming).not.toHaveBeenCalled(); }); it('should send message on Enter (without Shift)', () => { const options = createMockOptions(); const tab = createTab(options); tab.ui.instructionModeManager = mockInstructionModeManager as any; tab.ui.slashCommandDropdown = mockSlashCommandDropdown as any; tab.ui.fileContextManager = mockFileContextManager as any; tab.controllers.inputController = mockInputController as any; tab.controllers.selectionController = mockSelectionController as any; mockInstructionModeManager.handleTriggerKey.mockReturnValue(false); mockInstructionModeManager.handleKeydown.mockReturnValue(false); mockSlashCommandDropdown.handleKeydown.mockReturnValue(false); mockFileContextManager.handleMentionKeydown.mockReturnValue(false); wireTabInputEvents(tab, options.plugin); const listeners = (tab.dom.inputEl as any).getEventListeners(); const keydownHandler = listeners.get('keydown')[0]; const event = { key: 'Enter', shiftKey: false, isComposing: false, preventDefault: jest.fn() }; keydownHandler(event); expect(event.preventDefault).toHaveBeenCalled(); expect(mockInputController.sendMessage).toHaveBeenCalled(); }); it('should not send message on Shift+Enter (newline)', () => { const options = createMockOptions(); const tab = createTab(options); tab.ui.instructionModeManager = mockInstructionModeManager as any; tab.ui.slashCommandDropdown = mockSlashCommandDropdown as any; tab.ui.fileContextManager = mockFileContextManager as any; tab.controllers.inputController = mockInputController as any; tab.controllers.selectionController = mockSelectionController as any; mockInstructionModeManager.handleTriggerKey.mockReturnValue(false); mockInstructionModeManager.handleKeydown.mockReturnValue(false); mockSlashCommandDropdown.handleKeydown.mockReturnValue(false); mockFileContextManager.handleMentionKeydown.mockReturnValue(false); wireTabInputEvents(tab, options.plugin); const listeners = (tab.dom.inputEl as any).getEventListeners(); const keydownHandler = listeners.get('keydown')[0]; const event = { key: 'Enter', shiftKey: true, isComposing: false, preventDefault: jest.fn() }; keydownHandler(event); expect(event.preventDefault).not.toHaveBeenCalled(); expect(mockInputController.sendMessage).not.toHaveBeenCalled(); }); it('should not send message on Enter when isComposing (IME)', () => { const options = createMockOptions(); const tab = createTab(options); tab.ui.instructionModeManager = mockInstructionModeManager as any; tab.ui.slashCommandDropdown = mockSlashCommandDropdown as any; tab.ui.fileContextManager = mockFileContextManager as any; tab.controllers.inputController = mockInputController as any; tab.controllers.selectionController = mockSelectionController as any; mockInstructionModeManager.handleTriggerKey.mockReturnValue(false); mockInstructionModeManager.handleKeydown.mockReturnValue(false); mockSlashCommandDropdown.handleKeydown.mockReturnValue(false); mockFileContextManager.handleMentionKeydown.mockReturnValue(false); wireTabInputEvents(tab, options.plugin); const listeners = (tab.dom.inputEl as any).getEventListeners(); const keydownHandler = listeners.get('keydown')[0]; const event = { key: 'Enter', shiftKey: false, isComposing: true, preventDefault: jest.fn() }; keydownHandler(event); expect(event.preventDefault).not.toHaveBeenCalled(); expect(mockInputController.sendMessage).not.toHaveBeenCalled(); }); }); describe('wireTabInputEvents - input handler', () => { it('should trigger file context input change', () => { const options = createMockOptions(); const tab = createTab(options); tab.ui.fileContextManager = mockFileContextManager as any; tab.ui.instructionModeManager = mockInstructionModeManager as any; tab.controllers.inputController = mockInputController as any; tab.controllers.selectionController = mockSelectionController as any; wireTabInputEvents(tab, options.plugin); const listeners = (tab.dom.inputEl as any).getEventListeners(); const inputHandler = listeners.get('input')[0]; inputHandler(); expect(mockFileContextManager.handleInputChange).toHaveBeenCalled(); expect(mockInstructionModeManager.handleInputChange).toHaveBeenCalled(); }); }); describe('wireTabInputEvents - focus handler', () => { it('should show selection highlight on focus', () => { const options = createMockOptions(); const tab = createTab(options); tab.controllers.selectionController = mockSelectionController as any; tab.controllers.inputController = mockInputController as any; wireTabInputEvents(tab, options.plugin); const listeners = (tab.dom.inputEl as any).getEventListeners(); const focusHandler = listeners.get('focus')[0]; focusHandler(); expect(mockSelectionController.showHighlight).toHaveBeenCalled(); }); }); describe('wireTabInputEvents - input handlers', () => { it('should not call FileContextManager.handleInputChange when bang-bash mode is active', () => { const options = createMockOptions(); const tab = createTab(options); tab.ui.bangBashModeManager = mockBangBashModeManager as any; tab.ui.instructionModeManager = mockInstructionModeManager as any; tab.ui.slashCommandDropdown = mockSlashCommandDropdown as any; tab.ui.fileContextManager = mockFileContextManager as any; mockBangBashModeManager.isActive.mockReturnValue(true); wireTabInputEvents(tab, options.plugin); const listeners = (tab.dom.inputEl as any).getEventListeners(); const inputHandler = listeners.get('input')[0]; inputHandler(); expect(mockFileContextManager.handleInputChange).not.toHaveBeenCalled(); expect(mockBangBashModeManager.handleInputChange).toHaveBeenCalled(); }); }); }); describe('Tab - ChatState Callback Integration', () => { beforeEach(() => { jest.clearAllMocks(); }); it('should invoke onStreamingChanged callback when streaming state changes', () => { const onStreamingChanged = jest.fn(); const options = createMockOptions({ onStreamingChanged }); const tab = createTab(options); // Trigger the callback through ChatState tab.state.callbacks.onStreamingStateChanged?.(true); expect(onStreamingChanged).toHaveBeenCalledWith(true); }); it('should invoke onAttentionChanged callback when attention state changes', () => { const onAttentionChanged = jest.fn(); const options = createMockOptions({ onAttentionChanged }); const tab = createTab(options); // Trigger the callback through ChatState tab.state.callbacks.onAttentionChanged?.(true); expect(onAttentionChanged).toHaveBeenCalledWith(true); }); it('should invoke onConversationIdChanged callback when conversation changes', () => { const onConversationIdChanged = jest.fn(); const options = createMockOptions({ onConversationIdChanged }); const tab = createTab(options); // Trigger the callback through ChatState tab.state.callbacks.onConversationChanged?.('new-conv-id'); expect(onConversationIdChanged).toHaveBeenCalledWith('new-conv-id'); }); }); describe('Tab - UI Callback Wiring', () => { beforeEach(() => { jest.clearAllMocks(); }); describe('initializeTabUI callbacks', () => { it('should wire onChipsChanged to scroll to bottom', () => { const options = createMockOptions(); const tab = createTab(options); // Initialize UI to wire callbacks initializeTabUI(tab, options.plugin); // Set up renderer tab.renderer = mockMessageRenderer as any; // Get the FileContextManager constructor call arguments const { FileContextManager } = jest.requireMock('@/features/chat/ui'); const constructorCall = FileContextManager.mock.calls[0]; const callbacks = constructorCall[3]; // 4th argument is callbacks // Trigger onChipsChanged callback callbacks.onChipsChanged(); expect(mockMessageRenderer.scrollToBottomIfNeeded).toHaveBeenCalled(); }); it('should wire onImagesChanged to scroll to bottom', () => { const options = createMockOptions(); const tab = createTab(options); initializeTabUI(tab, options.plugin); tab.renderer = mockMessageRenderer as any; // Get the ImageContextManager constructor call const { ImageContextManager } = jest.requireMock('@/features/chat/ui'); const constructorCall = ImageContextManager.mock.calls[0]; const callbacks = constructorCall[2]; // 3rd argument is callbacks (app parameter was removed) callbacks.onImagesChanged(); expect(mockMessageRenderer.scrollToBottomIfNeeded).toHaveBeenCalled(); }); it('should wire getExcludedTags to return plugin settings', () => { const plugin = createMockPlugin({ settings: { ...createMockPlugin().settings, excludedTags: ['tag1', 'tag2'], }, }); const options = createMockOptions({ plugin }); const tab = createTab(options); initializeTabUI(tab, plugin); const { FileContextManager } = jest.requireMock('@/features/chat/ui'); const constructorCall = FileContextManager.mock.calls[0]; const callbacks = constructorCall[3]; const excludedTags = callbacks.getExcludedTags(); expect(excludedTags).toEqual(['tag1', 'tag2']); }); it('should wire getExternalContexts to return external context selector contexts', () => { const options = createMockOptions(); const tab = createTab(options); initializeTabUI(tab, options.plugin); // Mock external context selector return value mockExternalContextSelector.getExternalContexts.mockReturnValue(['/path/1', '/path/2']); const { FileContextManager } = jest.requireMock('@/features/chat/ui'); const constructorCall = FileContextManager.mock.calls[0]; const callbacks = constructorCall[3]; const contexts = callbacks.getExternalContexts(); expect(contexts).toEqual(['/path/1', '/path/2']); }); it('should wire MCP mention change to add servers to selector', () => { const options = createMockOptions(); const tab = createTab(options); initializeTabUI(tab, options.plugin); // Get the setOnMcpMentionChange callback const onMcpMentionChange = mockFileContextManager.setOnMcpMentionChange.mock.calls[0][0]; // Trigger with server list onMcpMentionChange(['server1', 'server2']); expect(mockMcpServerSelector.addMentionedServers).toHaveBeenCalledWith(['server1', 'server2']); }); it('should wire external context onChange to pre-scan contexts', () => { const options = createMockOptions(); const tab = createTab(options); initializeTabUI(tab, options.plugin); // Get the setOnChange callback const onChange = mockExternalContextSelector.setOnChange.mock.calls[0][0]; // Trigger onChange onChange(); expect(mockFileContextManager.preScanExternalContexts).toHaveBeenCalled(); }); it('should wire persistence change to save settings', async () => { const saveSettings = jest.fn().mockResolvedValue(undefined); const plugin = createMockPlugin({ saveSettings }); const options = createMockOptions({ plugin }); const tab = createTab(options); initializeTabUI(tab, plugin); // Get the setOnPersistenceChange callback const onPersistenceChange = mockExternalContextSelector.setOnPersistenceChange.mock.calls[0][0]; // Trigger with new paths await onPersistenceChange(['/new/path1', '/new/path2']); expect(plugin.settings.persistentExternalContextPaths).toEqual(['/new/path1', '/new/path2']); expect(saveSettings).toHaveBeenCalled(); }); it('should wire onUsageChanged callback to update context meter', () => { const options = createMockOptions(); const tab = createTab(options); initializeTabUI(tab, options.plugin); // Verify callback is wired const usage = { inputTokens: 1000, outputTokens: 500 }; tab.state.callbacks.onUsageChanged?.(usage as any); expect(mockContextUsageMeter.update).toHaveBeenCalledWith(usage); }); it('should wire onTodosChanged callback to update todo panel', () => { const options = createMockOptions(); const tab = createTab(options); initializeTabUI(tab, options.plugin); // Verify callback is wired const todos = [{ id: '1', content: 'Test todo', status: 'pending' }]; tab.state.callbacks.onTodosChanged?.(todos as any); expect(mockStatusPanel.updateTodos).toHaveBeenCalledWith(todos); }); it('should wire instruction mode onSubmit to input controller', async () => { const options = createMockOptions(); const tab = createTab(options); const mockComponent = {} as any; initializeTabUI(tab, options.plugin); initializeTabControllers(tab, options.plugin, mockComponent, options.mcpManager); // Get the InstructionModeManager constructor arguments const { InstructionModeManager } = jest.requireMock('@/features/chat/ui'); const constructorCall = InstructionModeManager.mock.calls[0]; const callbacks = constructorCall[1]; // 2nd argument is callbacks // Trigger onSubmit await callbacks.onSubmit('refined instruction'); expect(mockInputController.handleInstructionSubmit).toHaveBeenCalledWith('refined instruction'); }); it('should wire getInputWrapper to return input wrapper element', () => { const options = createMockOptions(); const tab = createTab(options); initializeTabUI(tab, options.plugin); const { InstructionModeManager } = jest.requireMock('@/features/chat/ui'); const constructorCall = InstructionModeManager.mock.calls[0]; const callbacks = constructorCall[1]; const wrapper = callbacks.getInputWrapper(); expect(wrapper).toBe(tab.dom.inputWrapper); }); it('should wire getSdkCommands callback when provided in options', async () => { const mockSdkCommands = [{ id: 'sdk:commit', name: 'commit', content: '' }]; const getSdkCommands = jest.fn().mockResolvedValue(mockSdkCommands); const plugin = createMockPlugin(); const options = createMockOptions({ plugin }); const tab = createTab(options); initializeTabUI(tab, plugin, { getSdkCommands }); const { SlashCommandDropdown } = jest.requireMock('@/shared/components/SlashCommandDropdown'); const constructorCall = SlashCommandDropdown.mock.calls[0]; const callbacks = constructorCall[2]; // 3rd argument is callbacks // Verify getSdkCommands callback is wired expect(callbacks.getSdkCommands).toBe(getSdkCommands); // Verify it returns the expected commands const returnedCommands = await callbacks.getSdkCommands(); expect(returnedCommands).toEqual(mockSdkCommands); }); }); }); describe('Tab - Service Initialization Error Handling', () => { beforeEach(() => { jest.clearAllMocks(); }); it('should skip re-initialization if already initialized', async () => { const options = createMockOptions(); const tab = createTab(options); // Mark as already initialized tab.serviceInitialized = true; const originalService = { id: 'existing-service' } as any; tab.service = originalService; await initializeTabService(tab, options.plugin, options.mcpManager); // Should not change existing service expect(tab.service).toBe(originalService); expect(tab.serviceInitialized).toBe(true); }); it('should set serviceInitialized to true after successful initialization', async () => { const options = createMockOptions(); const tab = createTab(options); expect(tab.serviceInitialized).toBe(false); expect(tab.service).toBeNull(); await initializeTabService(tab, options.plugin, options.mcpManager); expect(tab.serviceInitialized).toBe(true); expect(tab.service).not.toBeNull(); }); }); describe('Tab - Controller Configuration', () => { beforeEach(() => { jest.clearAllMocks(); }); describe('InputController configuration', () => { it('should wire ensureServiceInitialized to return true when already initialized', async () => { const { InputController } = jest.requireMock('@/features/chat/controllers'); const options = createMockOptions(); const tab = createTab(options); const mockComponent = {} as any; initializeTabUI(tab, options.plugin); initializeTabControllers(tab, options.plugin, mockComponent, options.mcpManager); // Get InputController constructor config const constructorCall = InputController.mock.calls[0]; const config = constructorCall[0]; // Test ensureServiceInitialized when already initialized tab.serviceInitialized = true; const result = await config.ensureServiceInitialized(); expect(result).toBe(true); }); it('should wire getAgentService to return tab service', () => { const { InputController } = jest.requireMock('@/features/chat/controllers'); const options = createMockOptions(); const tab = createTab(options); const mockComponent = {} as any; initializeTabUI(tab, options.plugin); initializeTabControllers(tab, options.plugin, mockComponent, options.mcpManager); const constructorCall = InputController.mock.calls[0]; const config = constructorCall[0]; // Verify getAgentService returns tab's service tab.service = { id: 'test-service' } as any; expect(config.getAgentService()).toBe(tab.service); }); it('should wire getters to return tab UI components', () => { const { InputController } = jest.requireMock('@/features/chat/controllers'); const options = createMockOptions(); const tab = createTab(options); const mockComponent = {} as any; initializeTabUI(tab, options.plugin); initializeTabControllers(tab, options.plugin, mockComponent, options.mcpManager); const constructorCall = InputController.mock.calls[0]; const config = constructorCall[0]; // Test getters return correct UI components expect(config.getInputEl()).toBe(tab.dom.inputEl); expect(config.getMessagesEl()).toBe(tab.dom.messagesEl); expect(config.getFileContextManager()).toBe(tab.ui.fileContextManager); expect(config.getImageContextManager()).toBe(tab.ui.imageContextManager); expect(config.getMcpServerSelector()).toBe(tab.ui.mcpServerSelector); expect(config.getExternalContextSelector()).toBe(tab.ui.externalContextSelector); expect(config.getInstructionModeManager()).toBe(tab.ui.instructionModeManager); expect(config.getInstructionRefineService()).toBe(tab.services.instructionRefineService); expect(config.getTitleGenerationService()).toBe(tab.services.titleGenerationService); }); }); describe('StreamController configuration', () => { it('should wire updateQueueIndicator to input controller', () => { const { StreamController } = jest.requireMock('@/features/chat/controllers'); const options = createMockOptions(); const tab = createTab(options); const mockComponent = {} as any; initializeTabUI(tab, options.plugin); initializeTabControllers(tab, options.plugin, mockComponent, options.mcpManager); const constructorCall = StreamController.mock.calls[0]; const config = constructorCall[0]; config.updateQueueIndicator(); expect(mockInputController.updateQueueIndicator).toHaveBeenCalled(); }); it('should wire getAgentService to return tab service', () => { const { StreamController } = jest.requireMock('@/features/chat/controllers'); const options = createMockOptions(); const tab = createTab(options); const mockComponent = {} as any; initializeTabUI(tab, options.plugin); initializeTabControllers(tab, options.plugin, mockComponent, options.mcpManager); tab.service = { id: 'test-service' } as any; const constructorCall = StreamController.mock.calls[0]; const config = constructorCall[0]; expect(config.getAgentService()).toBe(tab.service); }); it('should wire getMessagesEl to return tab messages element', () => { const { StreamController } = jest.requireMock('@/features/chat/controllers'); const options = createMockOptions(); const tab = createTab(options); const mockComponent = {} as any; initializeTabUI(tab, options.plugin); initializeTabControllers(tab, options.plugin, mockComponent, options.mcpManager); const constructorCall = StreamController.mock.calls[0]; const config = constructorCall[0]; expect(config.getMessagesEl()).toBe(tab.dom.messagesEl); }); }); describe('NavigationController configuration', () => { it('should wire shouldSkipEscapeHandling to check UI state', () => { const { NavigationController } = jest.requireMock('@/features/chat/controllers'); const options = createMockOptions(); const tab = createTab(options); const mockComponent = {} as any; initializeTabUI(tab, options.plugin); initializeTabControllers(tab, options.plugin, mockComponent, options.mcpManager); const constructorCall = NavigationController.mock.calls[0]; const config = constructorCall[0]; // Test when instruction mode is active mockInstructionModeManager.isActive.mockReturnValue(true); expect(config.shouldSkipEscapeHandling()).toBe(true); // Test when slash command dropdown is visible mockInstructionModeManager.isActive.mockReturnValue(false); mockSlashCommandDropdown.isVisible.mockReturnValue(true); expect(config.shouldSkipEscapeHandling()).toBe(true); // Test when mention dropdown is visible mockSlashCommandDropdown.isVisible.mockReturnValue(false); mockFileContextManager.isMentionDropdownVisible.mockReturnValue(true); expect(config.shouldSkipEscapeHandling()).toBe(true); // Test when resume dropdown is visible mockFileContextManager.isMentionDropdownVisible.mockReturnValue(false); mockInputController.isResumeDropdownVisible.mockReturnValue(true); expect(config.shouldSkipEscapeHandling()).toBe(true); // Test when nothing active mockInputController.isResumeDropdownVisible.mockReturnValue(false); expect(config.shouldSkipEscapeHandling()).toBe(false); }); it('should wire isStreaming to return tab state', () => { const { NavigationController } = jest.requireMock('@/features/chat/controllers'); const options = createMockOptions(); const tab = createTab(options); const mockComponent = {} as any; initializeTabUI(tab, options.plugin); initializeTabControllers(tab, options.plugin, mockComponent, options.mcpManager); const constructorCall = NavigationController.mock.calls[0]; const config = constructorCall[0]; tab.state.isStreaming = true; expect(config.isStreaming()).toBe(true); tab.state.isStreaming = false; expect(config.isStreaming()).toBe(false); }); it('should wire getSettings to return keyboard navigation settings', () => { const keyboardNavigation = { scrollUpKey: 'k', scrollDownKey: 'j', focusInputKey: 'i', }; const plugin = createMockPlugin({ settings: { ...createMockPlugin().settings, keyboardNavigation, }, }); const { NavigationController } = jest.requireMock('@/features/chat/controllers'); const options = createMockOptions({ plugin }); const tab = createTab(options); const mockComponent = {} as any; initializeTabUI(tab, plugin); initializeTabControllers(tab, plugin, mockComponent, options.mcpManager); const constructorCall = NavigationController.mock.calls[0]; const config = constructorCall[0]; expect(config.getSettings()).toEqual(keyboardNavigation); }); }); describe('ConversationController configuration', () => { it('should wire getHistoryDropdown to return null (tab has no dropdown)', () => { const { ConversationController } = jest.requireMock('@/features/chat/controllers'); const options = createMockOptions(); const tab = createTab(options); const mockComponent = {} as any; initializeTabUI(tab, options.plugin); initializeTabControllers(tab, options.plugin, mockComponent, options.mcpManager); const constructorCall = ConversationController.mock.calls[0]; const config = constructorCall[0]; expect(config.getHistoryDropdown()).toBeNull(); }); it('should wire welcome element getters and setters', () => { const { ConversationController } = jest.requireMock('@/features/chat/controllers'); const options = createMockOptions(); const tab = createTab(options); const mockComponent = {} as any; initializeTabUI(tab, options.plugin); initializeTabControllers(tab, options.plugin, mockComponent, options.mcpManager); const constructorCall = ConversationController.mock.calls[0]; const config = constructorCall[0]; // Test getter - use mock element const mockWelcome = { id: 'welcome-el' } as any; tab.dom.welcomeEl = mockWelcome; expect(config.getWelcomeEl()).toBe(mockWelcome); // Test setter const newWelcomeEl = { id: 'new-welcome-el' } as any; config.setWelcomeEl(newWelcomeEl); expect(tab.dom.welcomeEl).toBe(newWelcomeEl); }); }); }); const mockNotice = Notice as jest.Mock; describe('Tab - handleForkRequest', () => { function setupForkTest(overrides: Record<string, any> = {}) { const options = createMockOptions(overrides); const tab = createTab(options); const mockComponent = {} as any; const forkRequestCallback = jest.fn().mockResolvedValue(undefined); initializeTabUI(tab, options.plugin); initializeTabControllers(tab, options.plugin, mockComponent, options.mcpManager, forkRequestCallback); // Extract the fork callback from the MessageRenderer constructor const { MessageRenderer } = jest.requireMock('@/features/chat/rendering') as { MessageRenderer: jest.Mock }; const lastCall = MessageRenderer.mock.calls[MessageRenderer.mock.calls.length - 1]; const forkCallback = lastCall[4]; // 5th argument is forkCallback return { tab, forkCallback, forkRequestCallback, plugin: options.plugin }; } beforeEach(() => { mockNotice.mockClear(); }); it('should show notice when streaming', async () => { const { tab, forkCallback } = setupForkTest(); tab.state.isStreaming = true; tab.state.messages = [ { id: 'u1', role: 'user', content: 'hello', timestamp: 1, sdkUserUuid: 'user-u' }, ]; await forkCallback('u1'); expect(mockNotice).toHaveBeenCalled(); }); it('should show notice when message ID not found', async () => { const { forkCallback, forkRequestCallback } = setupForkTest(); await forkCallback('nonexistent'); expect(forkRequestCallback).not.toHaveBeenCalled(); expect(mockNotice).toHaveBeenCalledWith('Fork failed: Message not found'); }); it('should show notice when user message has no sdkUserUuid', async () => { const { tab, forkCallback, forkRequestCallback } = setupForkTest(); tab.state.messages = [ { id: 'u1', role: 'user', content: 'hello', timestamp: 1 }, ]; await forkCallback('u1'); expect(mockNotice).toHaveBeenCalled(); expect(forkRequestCallback).not.toHaveBeenCalled(); }); it('should show notice when no assistant response follows the user message', async () => { const { tab, forkCallback, forkRequestCallback } = setupForkTest(); // User message without a following assistant response with UUID tab.state.messages = [ { id: 'a0', role: 'assistant', content: 'hi', timestamp: 1, sdkAssistantUuid: 'asst-0' }, { id: 'u1', role: 'user', content: 'hello', timestamp: 2, sdkUserUuid: 'user-u' }, // No assistant response after u1 ]; await forkCallback('u1'); expect(mockNotice).toHaveBeenCalled(); expect(forkRequestCallback).not.toHaveBeenCalled(); }); it('should show notice when no session ID is available', async () => { const plugin = createMockPlugin({ getConversationSync: jest.fn().mockReturnValue(null), }); const { tab, forkCallback, forkRequestCallback } = setupForkTest({ plugin }); tab.state.messages = [ { id: 'a0', role: 'assistant', content: 'hi', timestamp: 1, sdkAssistantUuid: 'asst-0' }, { id: 'u1', role: 'user', content: 'hello', timestamp: 2, sdkUserUuid: 'user-u' }, { id: 'a1', role: 'assistant', content: 'resp', timestamp: 3, sdkAssistantUuid: 'asst-1' }, ]; // No service and no conversation tab.service = null; await forkCallback('u1'); expect(mockNotice).toHaveBeenCalled(); expect(forkRequestCallback).not.toHaveBeenCalled(); }); it('should call forkRequestCallback with correct ForkContext on success', async () => { const plugin = createMockPlugin({ getConversationSync: jest.fn().mockReturnValue({ title: 'My Conversation', currentNote: 'notes/test.md', }), }); const { tab, forkCallback, forkRequestCallback } = setupForkTest({ plugin }); tab.state.messages = [ { id: 'a0', role: 'assistant', content: 'hi', timestamp: 1, sdkAssistantUuid: 'asst-0' }, { id: 'u1', role: 'user', content: 'hello', timestamp: 2, sdkUserUuid: 'user-u' }, { id: 'a1', role: 'assistant', content: 'resp', timestamp: 3, sdkAssistantUuid: 'asst-1' }, { id: 'u2', role: 'user', content: 'world', timestamp: 4, sdkUserUuid: 'user-u2' }, { id: 'a2', role: 'assistant', content: 'resp2', timestamp: 5, sdkAssistantUuid: 'asst-2' }, ]; // Service has a session ID tab.service = { getSessionId: jest.fn().mockReturnValue('session-abc'), } as any; tab.conversationId = 'conv-1'; await forkCallback('u2'); expect(forkRequestCallback).toHaveBeenCalledWith(expect.objectContaining({ sourceSessionId: 'session-abc', resumeAt: 'asst-1', // prev assistant UUID before u2 sourceTitle: 'My Conversation', currentNote: 'notes/test.md', forkAtUserMessage: 2, // u2 is the 2nd user message })); // Messages should be deep-cloned and sliced before the fork point const ctx = forkRequestCallback.mock.calls[0][0]; expect(ctx.messages).toHaveLength(3); // a0, u1, a1 (before u2) expect(ctx.messages.map((m: any) => m.id)).toEqual(['a0', 'u1', 'a1']); }); it('should fall back to conversation session ID when service has none', async () => { const plugin = createMockPlugin({ getConversationSync: jest.fn().mockReturnValue({ sdkSessionId: 'conv-session-xyz', title: 'Fallback Chat', }), }); const { tab, forkCallback, forkRequestCallback } = setupForkTest({ plugin }); tab.state.messages = [ { id: 'a0', role: 'assistant', content: 'hi', timestamp: 1, sdkAssistantUuid: 'asst-0' }, { id: 'u1', role: 'user', content: 'hello', timestamp: 2, sdkUserUuid: 'user-u' }, { id: 'a1', role: 'assistant', content: 'resp', timestamp: 3, sdkAssistantUuid: 'asst-1' }, ]; tab.service = null; tab.conversationId = 'conv-1'; await forkCallback('u1'); expect(forkRequestCallback).toHaveBeenCalledWith(expect.objectContaining({ sourceSessionId: 'conv-session-xyz', })); }); it('should produce deep-cloned messages that do not share references with originals', async () => { const plugin = createMockPlugin({ getConversationSync: jest.fn().mockReturnValue({ title: 'Test' }), }); const { tab, forkCallback, forkRequestCallback } = setupForkTest({ plugin }); const originalMsg = { id: 'a0', role: 'assistant' as const, content: 'hi', timestamp: 1, sdkAssistantUuid: 'asst-0' }; tab.state.messages = [ originalMsg, { id: 'u1', role: 'user', content: 'hello', timestamp: 2, sdkUserUuid: 'user-u' }, { id: 'a1', role: 'assistant', content: 'resp', timestamp: 3, sdkAssistantUuid: 'asst-1' }, ]; tab.service = { getSessionId: jest.fn().mockReturnValue('session-1') } as any; tab.conversationId = 'conv-1'; await forkCallback('u1'); const ctx = forkRequestCallback.mock.calls[0][0]; // Deep clone should not share references expect(ctx.messages[0]).not.toBe(originalMsg); expect(ctx.messages[0]).toEqual(originalMsg); }); it('should fork at first user message with empty messages before fork', async () => { const plugin = createMockPlugin({ getConversationSync: jest.fn().mockReturnValue({ title: 'First Fork' }), }); const { tab, forkCallback, forkRequestCallback } = setupForkTest({ plugin }); tab.state.messages = [ { id: 'u1', role: 'user', content: 'hello', timestamp: 1, sdkUserUuid: 'user-u1' }, { id: 'a1', role: 'assistant', content: 'hi', timestamp: 2, sdkAssistantUuid: 'asst-1' }, ]; tab.service = { getSessionId: jest.fn().mockReturnValue('session-1') } as any; tab.conversationId = 'conv-1'; await forkCallback('u1'); // No assistant message before u1, so findRewindContext returns no prevAssistantUuid expect(forkRequestCallback).not.toHaveBeenCalled(); expect(mockNotice).toHaveBeenCalled(); }); it('should fall back to conversation forkSource.sessionId when no sessionId or sdkSessionId', async () => { const plugin = createMockPlugin({ getConversationSync: jest.fn().mockReturnValue({ title: 'Nested Fork', forkSource: { sessionId: 'original-source-session', resumeAt: 'asst-prev' }, }), }); const { tab, forkCallback, forkRequestCallback } = setupForkTest({ plugin }); tab.state.messages = [ { id: 'a0', role: 'assistant', content: 'hi', timestamp: 1, sdkAssistantUuid: 'asst-0' }, { id: 'u1', role: 'user', content: 'hello', timestamp: 2, sdkUserUuid: 'user-u' }, { id: 'a1', role: 'assistant', content: 'resp', timestamp: 3, sdkAssistantUuid: 'asst-1' }, ]; tab.service = null; tab.conversationId = 'conv-1'; await forkCallback('u1'); expect(forkRequestCallback).toHaveBeenCalledWith(expect.objectContaining({ sourceSessionId: 'original-source-session', })); }); it('should prefer service session ID over conversation metadata', async () => { const plugin = createMockPlugin({ getConversationSync: jest.fn().mockReturnValue({ title: 'Test', sdkSessionId: 'conv-session', sessionId: 'old-session', }), }); const { tab, forkCallback, forkRequestCallback } = setupForkTest({ plugin }); tab.state.messages = [ { id: 'a0', role: 'assistant', content: 'hi', timestamp: 1, sdkAssistantUuid: 'asst-0' }, { id: 'u1', role: 'user', content: 'hello', timestamp: 2, sdkUserUuid: 'user-u' }, { id: 'a1', role: 'assistant', content: 'resp', timestamp: 3, sdkAssistantUuid: 'asst-1' }, ]; tab.service = { getSessionId: jest.fn().mockReturnValue('service-session') } as any; tab.conversationId = 'conv-1'; await forkCallback('u1'); expect(forkRequestCallback).toHaveBeenCalledWith(expect.objectContaining({ sourceSessionId: 'service-session', })); }); it('should set forkAtUserMessage to 1 for the first user message', async () => { const plugin = createMockPlugin({ getConversationSync: jest.fn().mockReturnValue({ title: 'Test' }), }); const { tab, forkCallback, forkRequestCallback } = setupForkTest({ plugin }); tab.state.messages = [ { id: 'a0', role: 'assistant', content: 'hi', timestamp: 1, sdkAssistantUuid: 'asst-0' }, { id: 'u1', role: 'user', content: 'hello', timestamp: 2, sdkUserUuid: 'user-u1' }, { id: 'a1', role: 'assistant', content: 'resp', timestamp: 3, sdkAssistantUuid: 'asst-1' }, ]; tab.service = { getSessionId: jest.fn().mockReturnValue('session-1') } as any; tab.conversationId = 'conv-1'; await forkCallback('u1'); expect(forkRequestCallback).toHaveBeenCalledWith(expect.objectContaining({ forkAtUserMessage: 1, })); }); it('should not set forkCallback on renderer when no forkRequestCallback provided', () => { const options = createMockOptions(); const tab = createTab(options); const mockComponent = {} as any; initializeTabUI(tab, options.plugin); initializeTabControllers(tab, options.plugin, mockComponent, options.mcpManager); const { MessageRenderer } = jest.requireMock('@/features/chat/rendering') as { MessageRenderer: jest.Mock }; const lastCall = MessageRenderer.mock.calls[MessageRenderer.mock.calls.length - 1]; const forkCallback = lastCall[4]; expect(forkCallback).toBeUndefined(); }); }); describe('Tab - handleForkAll (via /fork command)', () => { function setupForkAllTest(overrides: Record<string, any> = {}) { const options = createMockOptions(overrides); const tab = createTab(options); const mockComponent = {} as any; const forkRequestCallback = jest.fn().mockResolvedValue(undefined); initializeTabUI(tab, options.plugin); initializeTabControllers(tab, options.plugin, mockComponent, options.mcpManager, forkRequestCallback); // Extract onForkAll from InputController constructor call const { InputController } = jest.requireMock('@/features/chat/controllers') as { InputController: jest.Mock }; const lastCall = InputController.mock.calls[InputController.mock.calls.length - 1]; const config = lastCall[0]; const onForkAll = config.onForkAll as (() => Promise<void>) | undefined; return { tab, onForkAll: onForkAll!, forkRequestCallback, plugin: options.plugin }; } beforeEach(() => { mockNotice.mockClear(); }); it('should call forkRequestCallback with all messages and last assistant UUID', async () => { const plugin = createMockPlugin({ getConversationSync: jest.fn().mockReturnValue({ title: 'My Conversation', currentNote: 'notes/test.md', }), }); const { tab, onForkAll, forkRequestCallback } = setupForkAllTest({ plugin }); tab.state.messages = [ { id: 'a0', role: 'assistant', content: 'hi', timestamp: 1, sdkAssistantUuid: 'asst-0' }, { id: 'u1', role: 'user', content: 'hello', timestamp: 2, sdkUserUuid: 'user-u1' }, { id: 'a1', role: 'assistant', content: 'resp', timestamp: 3, sdkAssistantUuid: 'asst-1' }, { id: 'u2', role: 'user', content: 'world', timestamp: 4, sdkUserUuid: 'user-u2' }, { id: 'a2', role: 'assistant', content: 'resp2', timestamp: 5, sdkAssistantUuid: 'asst-2' }, ]; tab.service = { getSessionId: jest.fn().mockReturnValue('session-abc') } as any; tab.conversationId = 'conv-1'; await onForkAll(); expect(forkRequestCallback).toHaveBeenCalledWith(expect.objectContaining({ sourceSessionId: 'session-abc', resumeAt: 'asst-2', // last assistant UUID sourceTitle: 'My Conversation', currentNote: 'notes/test.md', })); const ctx = forkRequestCallback.mock.calls[0][0]; expect(ctx.messages).toHaveLength(5); // all messages expect(ctx.messages.map((m: any) => m.id)).toEqual(['a0', 'u1', 'a1', 'u2', 'a2']); expect(ctx.forkAtUserMessage).toBe(3); // 2 user messages + 1 }); it('should include trailing user + interrupt messages and not count interrupt for fork number', async () => { const plugin = createMockPlugin({ getConversationSync: jest.fn().mockReturnValue({ title: 'My Conversation', currentNote: 'notes/test.md', }), }); const { tab, onForkAll, forkRequestCallback } = setupForkAllTest({ plugin }); tab.state.messages = [ { id: 'a0', role: 'assistant', content: 'hi', timestamp: 1, sdkAssistantUuid: 'asst-0' }, { id: 'u1', role: 'user', content: 'hello', timestamp: 2, sdkUserUuid: 'user-u1' }, { id: 'a1', role: 'assistant', content: 'resp', timestamp: 3, sdkAssistantUuid: 'asst-1' }, { id: 'u2', role: 'user', content: 'world', timestamp: 4, sdkUserUuid: 'user-u2' }, { id: 'a2', role: 'assistant', content: 'resp2', timestamp: 5, sdkAssistantUuid: 'asst-2' }, { id: 'u3', role: 'user', content: 'more', timestamp: 6, sdkUserUuid: 'user-u3' }, { id: 'int-1', role: 'user', content: '[Request interrupted by user]', timestamp: 7, sdkUserUuid: 'user-int', isInterrupt: true }, ]; tab.service = { getSessionId: jest.fn().mockReturnValue('session-abc') } as any; tab.conversationId = 'conv-1'; await onForkAll(); expect(forkRequestCallback).toHaveBeenCalledWith(expect.objectContaining({ sourceSessionId: 'session-abc', resumeAt: 'asst-2', forkAtUserMessage: 4, // u1, u2, u3 + 1 (interrupt excluded) })); const ctx = forkRequestCallback.mock.calls[0][0]; expect(ctx.messages).toHaveLength(7); expect(ctx.messages.map((m: any) => m.id)).toEqual(['a0', 'u1', 'a1', 'u2', 'a2', 'u3', 'int-1']); }); it('should show notice when streaming', async () => { const { tab, onForkAll, forkRequestCallback } = setupForkAllTest(); tab.state.isStreaming = true; tab.state.messages = [ { id: 'u1', role: 'user', content: 'hello', timestamp: 1, sdkUserUuid: 'user-u' }, { id: 'a1', role: 'assistant', content: 'resp', timestamp: 2, sdkAssistantUuid: 'asst-1' }, ]; await onForkAll(); expect(mockNotice).toHaveBeenCalled(); expect(forkRequestCallback).not.toHaveBeenCalled(); }); it('should show notice when no messages', async () => { const { tab, onForkAll, forkRequestCallback } = setupForkAllTest(); tab.state.messages = []; await onForkAll(); expect(mockNotice).toHaveBeenCalledWith('Cannot fork: no messages in conversation'); expect(forkRequestCallback).not.toHaveBeenCalled(); }); it('should show notice when no assistant message has sdkAssistantUuid', async () => { const { tab, onForkAll, forkRequestCallback } = setupForkAllTest(); tab.state.messages = [ { id: 'u1', role: 'user', content: 'hello', timestamp: 1, sdkUserUuid: 'user-u' }, { id: 'a1', role: 'assistant', content: 'resp', timestamp: 2 }, ]; await onForkAll(); expect(mockNotice).toHaveBeenCalledWith('Cannot fork: no assistant response with identifiers'); expect(forkRequestCallback).not.toHaveBeenCalled(); }); it('should show notice when no session ID is available', async () => { const plugin = createMockPlugin({ getConversationSync: jest.fn().mockReturnValue(null), }); const { tab, onForkAll, forkRequestCallback } = setupForkAllTest({ plugin }); tab.state.messages = [ { id: 'u1', role: 'user', content: 'hello', timestamp: 1, sdkUserUuid: 'user-u' }, { id: 'a1', role: 'assistant', content: 'resp', timestamp: 2, sdkAssistantUuid: 'asst-1' }, ]; tab.service = null; await onForkAll(); expect(mockNotice).toHaveBeenCalled(); expect(forkRequestCallback).not.toHaveBeenCalled(); }); it('should fall back to conversation session ID when service has none', async () => { const plugin = createMockPlugin({ getConversationSync: jest.fn().mockReturnValue({ sdkSessionId: 'conv-session-xyz', title: 'Fallback Chat', }), }); const { tab, onForkAll, forkRequestCallback } = setupForkAllTest({ plugin }); tab.state.messages = [ { id: 'u1', role: 'user', content: 'hello', timestamp: 1, sdkUserUuid: 'user-u' }, { id: 'a1', role: 'assistant', content: 'resp', timestamp: 2, sdkAssistantUuid: 'asst-1' }, ]; tab.service = null; tab.conversationId = 'conv-1'; await onForkAll(); expect(forkRequestCallback).toHaveBeenCalledWith(expect.objectContaining({ sourceSessionId: 'conv-session-xyz', })); }); it('should deep-clone messages (not share references)', async () => { const plugin = createMockPlugin({ getConversationSync: jest.fn().mockReturnValue({ title: 'Test' }), }); const { tab, onForkAll, forkRequestCallback } = setupForkAllTest({ plugin }); const originalMsg = { id: 'a0', role: 'assistant' as const, content: 'hi', timestamp: 1, sdkAssistantUuid: 'asst-0' }; tab.state.messages = [ originalMsg, { id: 'u1', role: 'user', content: 'hello', timestamp: 2, sdkUserUuid: 'user-u' }, { id: 'a1', role: 'assistant', content: 'resp', timestamp: 3, sdkAssistantUuid: 'asst-1' }, ]; tab.service = { getSessionId: jest.fn().mockReturnValue('session-1') } as any; tab.conversationId = 'conv-1'; await onForkAll(); const ctx = forkRequestCallback.mock.calls[0][0]; expect(ctx.messages[0]).not.toBe(originalMsg); expect(ctx.messages[0]).toEqual(originalMsg); }); it('should not set onForkAll on InputController when no forkRequestCallback provided', () => { const options = createMockOptions(); const tab = createTab(options); const mockComponent = {} as any; initializeTabUI(tab, options.plugin); initializeTabControllers(tab, options.plugin, mockComponent, options.mcpManager); const { InputController } = jest.requireMock('@/features/chat/controllers') as { InputController: jest.Mock }; const lastCall = InputController.mock.calls[InputController.mock.calls.length - 1]; const config = lastCall[0]; expect(config.onForkAll).toBeUndefined(); }); }); ================================================ FILE: tests/unit/features/chat/tabs/TabBar.test.ts ================================================ import { createMockEl } from '@test/helpers/mockElement'; import { TabBar, type TabBarCallbacks } from '@/features/chat/tabs/TabBar'; import type { TabBarItem } from '@/features/chat/tabs/types'; // Helper to create mock callbacks function createMockCallbacks(): TabBarCallbacks { return { onTabClick: jest.fn(), onTabClose: jest.fn(), onNewTab: jest.fn(), }; } // Helper to create tab bar items function createTabBarItem(overrides: Partial<TabBarItem> = {}): TabBarItem { return { id: 'tab-1', index: 1, title: 'Test Tab', isActive: false, isStreaming: false, needsAttention: false, canClose: true, ...overrides, }; } describe('TabBar', () => { describe('constructor', () => { it('should add tab badges class to container', () => { const containerEl = createMockEl(); const callbacks = createMockCallbacks(); new TabBar(containerEl, callbacks); expect(containerEl._classList.has('claudian-tab-badges')).toBe(true); }); }); describe('update', () => { it('should clear existing badges before rendering', () => { const containerEl = createMockEl(); const callbacks = createMockCallbacks(); const tabBar = new TabBar(containerEl, callbacks); // First update tabBar.update([createTabBarItem()]); expect(containerEl._children.length).toBe(1); // Second update should clear first tabBar.update([createTabBarItem(), createTabBarItem({ id: 'tab-2', index: 2 })]); expect(containerEl._children.length).toBe(2); }); it('should render badge for each tab item', () => { const containerEl = createMockEl(); const callbacks = createMockCallbacks(); const tabBar = new TabBar(containerEl, callbacks); tabBar.update([ createTabBarItem({ id: 'tab-1', index: 1 }), createTabBarItem({ id: 'tab-2', index: 2 }), createTabBarItem({ id: 'tab-3', index: 3 }), ]); expect(containerEl._children.length).toBe(3); }); it('should render empty when no items', () => { const containerEl = createMockEl(); const callbacks = createMockCallbacks(); const tabBar = new TabBar(containerEl, callbacks); tabBar.update([]); expect(containerEl._children.length).toBe(0); }); }); describe('badge rendering', () => { it('should display index number as text', () => { const containerEl = createMockEl(); const callbacks = createMockCallbacks(); const tabBar = new TabBar(containerEl, callbacks); tabBar.update([createTabBarItem({ index: 5 })]); expect(containerEl._children[0].textContent).toBe('5'); }); it('should set title tooltip from item title', () => { const containerEl = createMockEl(); const callbacks = createMockCallbacks(); const tabBar = new TabBar(containerEl, callbacks); tabBar.update([createTabBarItem({ title: 'My Conversation' })]); expect(containerEl._children[0].getAttribute('title')).toBe('My Conversation'); expect(containerEl._children[0].getAttribute('aria-label')).toBe('My Conversation'); }); }); describe('badge state classes', () => { it('should apply idle class for inactive tab', () => { const containerEl = createMockEl(); const callbacks = createMockCallbacks(); const tabBar = new TabBar(containerEl, callbacks); tabBar.update([createTabBarItem({ isActive: false, isStreaming: false, needsAttention: false })]); expect(containerEl._children[0]._classList.has('claudian-tab-badge-idle')).toBe(true); }); it('should apply active class for active tab', () => { const containerEl = createMockEl(); const callbacks = createMockCallbacks(); const tabBar = new TabBar(containerEl, callbacks); tabBar.update([createTabBarItem({ isActive: true })]); expect(containerEl._children[0]._classList.has('claudian-tab-badge-active')).toBe(true); }); it('should apply streaming class for streaming tab', () => { const containerEl = createMockEl(); const callbacks = createMockCallbacks(); const tabBar = new TabBar(containerEl, callbacks); tabBar.update([createTabBarItem({ isStreaming: true })]); expect(containerEl._children[0]._classList.has('claudian-tab-badge-streaming')).toBe(true); }); it('should apply attention class for tab needing attention', () => { const containerEl = createMockEl(); const callbacks = createMockCallbacks(); const tabBar = new TabBar(containerEl, callbacks); tabBar.update([createTabBarItem({ needsAttention: true })]); expect(containerEl._children[0]._classList.has('claudian-tab-badge-attention')).toBe(true); }); it('should prioritize active over attention', () => { const containerEl = createMockEl(); const callbacks = createMockCallbacks(); const tabBar = new TabBar(containerEl, callbacks); tabBar.update([createTabBarItem({ isActive: true, needsAttention: true })]); expect(containerEl._children[0]._classList.has('claudian-tab-badge-active')).toBe(true); expect(containerEl._children[0]._classList.has('claudian-tab-badge-attention')).toBe(false); }); it('should prioritize attention over streaming', () => { const containerEl = createMockEl(); const callbacks = createMockCallbacks(); const tabBar = new TabBar(containerEl, callbacks); tabBar.update([createTabBarItem({ isStreaming: true, needsAttention: true })]); expect(containerEl._children[0]._classList.has('claudian-tab-badge-attention')).toBe(true); expect(containerEl._children[0]._classList.has('claudian-tab-badge-streaming')).toBe(false); }); it('should prioritize active over streaming', () => { const containerEl = createMockEl(); const callbacks = createMockCallbacks(); const tabBar = new TabBar(containerEl, callbacks); tabBar.update([createTabBarItem({ isActive: true, isStreaming: true })]); expect(containerEl._children[0]._classList.has('claudian-tab-badge-active')).toBe(true); expect(containerEl._children[0]._classList.has('claudian-tab-badge-streaming')).toBe(false); }); }); describe('badge interactions', () => { it('should call onTabClick when badge is clicked', () => { const containerEl = createMockEl(); const callbacks = createMockCallbacks(); const tabBar = new TabBar(containerEl, callbacks); tabBar.update([createTabBarItem({ id: 'clicked-tab' })]); // Simulate click containerEl._children[0].dispatchEvent('click'); expect(callbacks.onTabClick).toHaveBeenCalledWith('clicked-tab'); }); it('should call onTabClose on right-click when canClose is true', () => { const containerEl = createMockEl(); const callbacks = createMockCallbacks(); const tabBar = new TabBar(containerEl, callbacks); tabBar.update([createTabBarItem({ id: 'closeable-tab', canClose: true })]); // Simulate right-click (contextmenu) const mockEvent = { preventDefault: jest.fn() }; containerEl._children[0].dispatchEvent('contextmenu', mockEvent); expect(mockEvent.preventDefault).toHaveBeenCalled(); expect(callbacks.onTabClose).toHaveBeenCalledWith('closeable-tab'); }); it('should not register contextmenu handler when canClose is false', () => { const containerEl = createMockEl(); const callbacks = createMockCallbacks(); const tabBar = new TabBar(containerEl, callbacks); tabBar.update([createTabBarItem({ id: 'uncloseable-tab', canClose: false })]); // Check that contextmenu handler was not registered expect(containerEl._children[0]._eventListeners.has('contextmenu')).toBe(false); }); }); describe('destroy', () => { it('should empty container', () => { const containerEl = createMockEl(); const callbacks = createMockCallbacks(); const tabBar = new TabBar(containerEl, callbacks); tabBar.update([createTabBarItem(), createTabBarItem({ id: 'tab-2', index: 2 })]); expect(containerEl._children.length).toBe(2); tabBar.destroy(); expect(containerEl._children.length).toBe(0); }); it('should remove tab badges class from container', () => { const containerEl = createMockEl(); const callbacks = createMockCallbacks(); const tabBar = new TabBar(containerEl, callbacks); expect(containerEl._classList.has('claudian-tab-badges')).toBe(true); tabBar.destroy(); expect(containerEl._classList.has('claudian-tab-badges')).toBe(false); }); }); }); ================================================ FILE: tests/unit/features/chat/tabs/TabManager.test.ts ================================================ import { createMockEl } from '@test/helpers/mockElement'; import { TabManager } from '@/features/chat/tabs/TabManager'; import { DEFAULT_MAX_TABS, type PersistedTabManagerState, type TabManagerCallbacks, } from '@/features/chat/tabs/types'; // Mock Tab module functions const mockCreateTab = jest.fn(); const mockDestroyTab = jest.fn().mockResolvedValue(undefined); const mockActivateTab = jest.fn(); const mockDeactivateTab = jest.fn(); const mockInitializeTabUI = jest.fn(); const mockInitializeTabControllers = jest.fn(); const mockInitializeTabService = jest.fn().mockResolvedValue(undefined); const mockWireTabInputEvents = jest.fn(); const mockGetTabTitle = jest.fn().mockReturnValue('Test Tab'); const mockSetupApprovalCallback = jest.fn(); jest.mock('@/features/chat/tabs/Tab', () => ({ createTab: (...args: any[]) => mockCreateTab(...args), destroyTab: (...args: any[]) => mockDestroyTab(...args), activateTab: (...args: any[]) => mockActivateTab(...args), deactivateTab: (...args: any[]) => mockDeactivateTab(...args), initializeTabUI: (...args: any[]) => mockInitializeTabUI(...args), initializeTabControllers: (...args: any[]) => mockInitializeTabControllers(...args), initializeTabService: (...args: any[]) => mockInitializeTabService(...args), wireTabInputEvents: (...args: any[]) => mockWireTabInputEvents(...args), getTabTitle: (...args: any[]) => mockGetTabTitle(...args), setupApprovalCallback: (...args: any[]) => mockSetupApprovalCallback(...args), })); const mockChooseForkTarget = jest.fn(); jest.mock('@/shared/modals/ForkTargetModal', () => ({ chooseForkTarget: (...args: any[]) => mockChooseForkTarget(...args), })); function createMockPlugin(overrides: Record<string, any> = {}): any { return { app: { workspace: { revealLeaf: jest.fn(), }, }, settings: { maxTabs: DEFAULT_MAX_TABS, ...(overrides.settings || {}), }, getConversationById: jest.fn().mockResolvedValue(null), getConversationList: jest.fn().mockReturnValue([]), findConversationAcrossViews: jest.fn().mockReturnValue(null), ...overrides, }; } function createMockMcpManager(): any { return {}; } function createMockView(): any { return { leaf: { id: 'leaf-1' }, getTabManager: jest.fn().mockReturnValue(null), }; } function createMockTabData(overrides: Record<string, any> = {}): any { const defaultState = { isStreaming: false, needsAttention: false, messages: [], currentConversationId: null, }; const defaultControllers = { conversationController: { save: jest.fn().mockResolvedValue(undefined), switchTo: jest.fn().mockResolvedValue(undefined), initializeWelcome: jest.fn(), }, inputController: { handleApprovalRequest: jest.fn(), }, }; // Extract state and controllers from overrides to merge properly const { state: stateOverrides, controllers: controllersOverrides, ...restOverrides } = overrides; return { id: `tab-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`, conversationId: null, service: null, serviceInitialized: false, state: { ...defaultState, ...(stateOverrides || {}), }, controllers: { ...defaultControllers, ...(controllersOverrides || {}), }, dom: { contentEl: createMockEl(), }, ...restOverrides, }; } function createManager(options: { plugin?: any; callbacks?: TabManagerCallbacks; tabFactory?: (counter: number) => any; } = {}): TabManager { jest.clearAllMocks(); let tabCounter = 0; const factory = options.tabFactory ?? ((n: number) => createMockTabData({ id: `tab-${n}` })); mockCreateTab.mockImplementation(() => { tabCounter++; return factory(tabCounter); }); return new TabManager( options.plugin ?? createMockPlugin(), createMockMcpManager(), createMockEl(), createMockView(), options.callbacks ); } describe('TabManager - Tab Lifecycle', () => { let callbacks: TabManagerCallbacks; beforeEach(() => { callbacks = { onTabCreated: jest.fn(), onTabSwitched: jest.fn(), onTabClosed: jest.fn(), onTabStreamingChanged: jest.fn(), onTabTitleChanged: jest.fn(), onTabAttentionChanged: jest.fn(), }; }); describe('createTab', () => { it('should create a new tab', async () => { const manager = createManager({ callbacks }); const tab = await manager.createTab(); expect(tab).toBeDefined(); expect(mockCreateTab).toHaveBeenCalled(); expect(mockInitializeTabUI).toHaveBeenCalled(); expect(mockInitializeTabControllers).toHaveBeenCalled(); expect(mockWireTabInputEvents).toHaveBeenCalled(); }); it('should call onTabCreated callback', async () => { const manager = createManager({ callbacks }); await manager.createTab(); expect(callbacks.onTabCreated).toHaveBeenCalled(); }); it('should activate first tab automatically', async () => { const manager = createManager({ callbacks }); await manager.createTab(); expect(mockActivateTab).toHaveBeenCalled(); // Service initialization is now lazy (on first query), not on switch expect(mockInitializeTabService).not.toHaveBeenCalled(); }); it('should enforce max tabs limit', async () => { const manager = createManager({ callbacks }); for (let i = 0; i < DEFAULT_MAX_TABS; i++) { await manager.createTab(); } const extraTab = await manager.createTab(); expect(extraTab).toBeNull(); expect(manager.getTabCount()).toBe(DEFAULT_MAX_TABS); }); it('should use provided tab ID for restoration', async () => { const manager = createManager({ callbacks }); mockCreateTab.mockImplementationOnce(() => createMockTabData({ id: 'restored-tab-id' }) ); await manager.createTab('conv-123', 'restored-tab-id'); expect(mockCreateTab).toHaveBeenCalledWith( expect.objectContaining({ tabId: 'restored-tab-id' }) ); }); }); describe('switchToTab', () => { it('should switch to existing tab', async () => { const manager = createManager({ callbacks }); const tab1 = await manager.createTab(); const tab2 = await manager.createTab(); // First, switch to tab2 to make it active (tab1 is active after creation) await manager.switchToTab(tab2!.id); jest.clearAllMocks(); await manager.switchToTab(tab1!.id); expect(mockDeactivateTab).toHaveBeenCalled(); expect(mockActivateTab).toHaveBeenCalled(); expect(callbacks.onTabSwitched).toHaveBeenCalled(); }); it('should not switch to non-existent tab', async () => { const manager = createManager({ callbacks }); await manager.createTab(); jest.clearAllMocks(); await manager.switchToTab('non-existent-id'); expect(mockActivateTab).not.toHaveBeenCalled(); }); it('should NOT initialize service on switch (lazy until first query)', async () => { const manager = createManager({ callbacks }); await manager.createTab(); // Service initialization is now lazy (on first query), not on switch expect(mockInitializeTabService).not.toHaveBeenCalled(); }); }); describe('closeTab', () => { it('should close a tab', async () => { const manager = createManager({ callbacks }); const tab1 = await manager.createTab(); await manager.createTab(); // Need at least 2 tabs to close one const closed = await manager.closeTab(tab1!.id); expect(closed).toBe(true); expect(mockDestroyTab).toHaveBeenCalled(); expect(callbacks.onTabClosed).toHaveBeenCalledWith(tab1!.id); }); it('should not close streaming tab unless forced', async () => { const streamingTab = createMockTabData({ id: 'streaming-tab', state: { isStreaming: true }, }); mockCreateTab.mockReturnValueOnce(streamingTab); const manager = createManager({ callbacks }); await manager.createTab(); const closed = await manager.closeTab('streaming-tab'); expect(closed).toBe(false); expect(mockDestroyTab).not.toHaveBeenCalled(); }); it('should close streaming tab when forced', async () => { const streamingTab = createMockTabData({ id: 'streaming-tab', state: { isStreaming: true }, }); mockCreateTab.mockReturnValueOnce(streamingTab); const manager = createManager({ callbacks }); await manager.createTab(); await manager.createTab(); // Need second tab const closed = await manager.closeTab('streaming-tab', true); expect(closed).toBe(true); expect(mockDestroyTab).toHaveBeenCalled(); }); it('should switch to another tab after closing active tab', async () => { const manager = createManager({ callbacks }); // Create two tabs (variables intentionally unused - we just need tabs to exist) await manager.createTab(); await manager.createTab(); // Close active tab await manager.closeTab(manager.getActiveTabId()!); // Should have switched to remaining tab expect(manager.getTabCount()).toBe(1); }); it('should prefer previous tab when closing a middle tab', async () => { const manager = createManager({ callbacks }); const tab1 = await manager.createTab(); const tab2 = await manager.createTab(); await manager.createTab(); await manager.switchToTab(tab2!.id); const switchSpy = jest.spyOn(manager, 'switchToTab'); await manager.closeTab(tab2!.id); expect(switchSpy).toHaveBeenCalledWith(tab1!.id); }); it('should fall back to next tab when closing the first tab', async () => { const manager = createManager({ callbacks }); const tab1 = await manager.createTab(); const tab2 = await manager.createTab(); await manager.createTab(); await manager.switchToTab(tab1!.id); const switchSpy = jest.spyOn(manager, 'switchToTab'); await manager.closeTab(tab1!.id); expect(switchSpy).toHaveBeenCalledWith(tab2!.id); }); it('should create new tab if all tabs are closed', async () => { const manager = createManager({ callbacks }); const tab = await manager.createTab(); await manager.closeTab(tab!.id, true); expect(manager.getTabCount()).toBe(1); }); it('should save conversation before closing', async () => { const mockSave = jest.fn().mockResolvedValue(undefined); const tabWithSave = createMockTabData({ id: 'tab-with-save' }); tabWithSave.controllers.conversationController.save = mockSave; mockCreateTab.mockReturnValueOnce(tabWithSave); const manager = createManager({ callbacks }); await manager.createTab(); await manager.createTab(); // Need second tab await manager.closeTab('tab-with-save', true); expect(mockSave).toHaveBeenCalled(); }); it('should switch to next tab when closing first tab', async () => { const manager = createManager({ callbacks }); const tab1 = await manager.createTab(); const tab2 = await manager.createTab(); await manager.createTab(); // tab-3 await manager.switchToTab(tab1!.id); expect(manager.getActiveTabId()).toBe(tab1!.id); await manager.closeTab(tab1!.id); // Should switch to tab-2 (next tab, not previous since there is none) expect(manager.getActiveTabId()).toBe(tab2!.id); }); it('should switch to previous tab when closing middle tab', async () => { const manager = createManager({ callbacks }); const tab1 = await manager.createTab(); const tab2 = await manager.createTab(); await manager.createTab(); // tab-3 await manager.switchToTab(tab2!.id); expect(manager.getActiveTabId()).toBe(tab2!.id); await manager.closeTab(tab2!.id); // Should switch to tab-1 (previous tab) expect(manager.getActiveTabId()).toBe(tab1!.id); }); it('should switch to previous tab when closing last tab in list', async () => { const manager = createManager({ callbacks }); await manager.createTab(); // tab-1 const tab2 = await manager.createTab(); const tab3 = await manager.createTab(); await manager.switchToTab(tab3!.id); expect(manager.getActiveTabId()).toBe(tab3!.id); await manager.closeTab(tab3!.id); // Should switch to tab-2 (previous tab) expect(manager.getActiveTabId()).toBe(tab2!.id); }); }); }); describe('TabManager - Tab Queries', () => { let manager: TabManager; beforeEach(async () => { manager = createManager(); await manager.createTab(); }); describe('getActiveTab', () => { it('should return the active tab', () => { const activeTab = manager.getActiveTab(); expect(activeTab).toBeDefined(); }); }); describe('getActiveTabId', () => { it('should return the active tab ID', () => { const activeTabId = manager.getActiveTabId(); expect(activeTabId).toBeDefined(); }); }); describe('getTab', () => { it('should return tab by ID', () => { const activeTabId = manager.getActiveTabId()!; const tab = manager.getTab(activeTabId); expect(tab).toBeDefined(); expect(tab?.id).toBe(activeTabId); }); it('should return null for non-existent tab', () => { const tab = manager.getTab('non-existent'); expect(tab).toBeNull(); }); }); describe('getAllTabs', () => { it('should return all tabs', async () => { await manager.createTab(); await manager.createTab(); const tabs = manager.getAllTabs(); expect(tabs.length).toBe(3); }); }); describe('getTabCount', () => { it('should return correct count', async () => { expect(manager.getTabCount()).toBe(1); await manager.createTab(); expect(manager.getTabCount()).toBe(2); }); }); describe('canCreateTab', () => { it('should return true when under limit', () => { expect(manager.canCreateTab()).toBe(true); }); it('should return false when at limit', async () => { for (let i = 1; i < DEFAULT_MAX_TABS; i++) { await manager.createTab(); } expect(manager.canCreateTab()).toBe(false); }); }); }); describe('TabManager - Tab Bar Data', () => { let manager: TabManager; beforeEach(async () => { manager = createManager({ tabFactory: (n) => createMockTabData({ id: `tab-${n}`, state: { isStreaming: n === 2, needsAttention: n === 3, }, }), }); }); describe('getTabBarItems', () => { it('should return tab bar items with correct structure', async () => { await manager.createTab(); await manager.createTab(); const items = manager.getTabBarItems(); expect(items.length).toBe(2); expect(items[0]).toHaveProperty('id'); expect(items[0]).toHaveProperty('index'); expect(items[0]).toHaveProperty('title'); expect(items[0]).toHaveProperty('isActive'); expect(items[0]).toHaveProperty('isStreaming'); expect(items[0]).toHaveProperty('needsAttention'); expect(items[0]).toHaveProperty('canClose'); }); it('should have 1-based indices', async () => { await manager.createTab(); await manager.createTab(); await manager.createTab(); const items = manager.getTabBarItems(); expect(items[0].index).toBe(1); expect(items[1].index).toBe(2); expect(items[2].index).toBe(3); }); it('should mark streaming tabs', async () => { await manager.createTab(); // Not streaming await manager.createTab(); // Streaming const items = manager.getTabBarItems(); expect(items[0].isStreaming).toBe(false); expect(items[1].isStreaming).toBe(true); }); }); }); describe('TabManager - Conversation Management', () => { let manager: TabManager; let plugin: any; beforeEach(async () => { plugin = createMockPlugin(); manager = createManager({ plugin }); await manager.createTab(); }); describe('openConversation', () => { it('should switch to tab if conversation is already open', async () => { const tabWithConv = createMockTabData({ id: 'tab-with-conv', conversationId: 'conv-123', }); mockCreateTab.mockReturnValueOnce(tabWithConv); await manager.createTab(); const switchSpy = jest.spyOn(manager, 'switchToTab'); await manager.openConversation('conv-123'); expect(switchSpy).toHaveBeenCalledWith('tab-with-conv'); }); it('should create new tab when preferNewTab is true', async () => { plugin.getConversationById.mockResolvedValue({ id: 'conv-new' }); await manager.openConversation('conv-new', true); expect(mockCreateTab).toHaveBeenCalledWith( expect.objectContaining({ conversation: { id: 'conv-new' }, }) ); }); it('should check for cross-view duplicates', async () => { plugin.findConversationAcrossViews.mockReturnValue({ view: { leaf: { id: 'other-leaf' }, getTabManager: () => ({ switchToTab: jest.fn() }) }, tabId: 'other-tab', }); await manager.openConversation('conv-123'); expect(plugin.app.workspace.revealLeaf).toHaveBeenCalled(); }); }); describe('createNewConversation', () => { it('should create new conversation in active tab', async () => { const activeTab = manager.getActiveTab(); const createNew = jest.fn().mockResolvedValue(undefined); activeTab!.controllers.conversationController = { createNew } as any; await manager.createNewConversation(); expect(createNew).toHaveBeenCalled(); }); }); }); describe('TabManager - Persistence', () => { let manager: TabManager; beforeEach(async () => { manager = createManager({ tabFactory: (n) => createMockTabData({ id: `tab-${n}`, conversationId: n === 2 ? 'conv-456' : null, }), }); }); describe('getPersistedState', () => { it('should return current tab state for persistence', async () => { await manager.createTab(); await manager.createTab(); const state = manager.getPersistedState(); expect(state.openTabs).toHaveLength(2); expect(state.activeTabId).toBeDefined(); expect(state.openTabs[0]).toHaveProperty('tabId'); expect(state.openTabs[0]).toHaveProperty('conversationId'); }); }); describe('restoreState', () => { it('should restore tabs from persisted state', async () => { const persistedState: PersistedTabManagerState = { openTabs: [ { tabId: 'restored-1', conversationId: 'conv-1' }, { tabId: 'restored-2', conversationId: 'conv-2' }, ], activeTabId: 'restored-2', }; await manager.restoreState(persistedState); expect(mockCreateTab).toHaveBeenCalledTimes(2); }); it('should switch to previously active tab', async () => { mockCreateTab.mockImplementation((opts: any) => createMockTabData({ id: opts.tabId || 'default-tab' }) ); const persistedState: PersistedTabManagerState = { openTabs: [ { tabId: 'restored-1', conversationId: null }, { tabId: 'restored-2', conversationId: null }, ], activeTabId: 'restored-2', }; await manager.restoreState(persistedState); expect(manager.getActiveTabId()).toBe('restored-2'); }); it('should create default tab if no tabs restored', async () => { // Reset mock to return valid tab data mockCreateTab.mockReturnValue(createMockTabData({ id: 'default-tab' })); await manager.restoreState({ openTabs: [], activeTabId: null }); expect(mockCreateTab).toHaveBeenCalled(); expect(manager.getTabCount()).toBe(1); }); it('should handle tab restoration errors gracefully', async () => { let callCount = 0; mockCreateTab.mockImplementation(() => { callCount++; if (callCount === 1) { throw new Error('Tab creation failed'); } return createMockTabData({ id: `tab-${callCount}` }); }); const persistedState: PersistedTabManagerState = { openTabs: [ { tabId: 'fail-tab', conversationId: null }, { tabId: 'success-tab', conversationId: null }, ], activeTabId: null, }; // Should not throw await expect(manager.restoreState(persistedState)).resolves.not.toThrow(); // Should have created at least one tab expect(manager.getTabCount()).toBeGreaterThanOrEqual(1); }); }); }); describe('TabManager - Broadcast', () => { let manager: TabManager; beforeEach(async () => { manager = createManager({ tabFactory: (n) => createMockTabData({ id: `tab-${n}`, service: { someMethod: jest.fn() }, serviceInitialized: true, }), }); await manager.createTab(); await manager.createTab(); }); describe('broadcastToAllTabs', () => { it('should call function on all initialized services', async () => { const broadcastFn = jest.fn().mockResolvedValue(undefined); await manager.broadcastToAllTabs(broadcastFn); expect(broadcastFn).toHaveBeenCalledTimes(2); }); it('should handle errors in broadcast gracefully', async () => { const broadcastFn = jest.fn() .mockResolvedValueOnce(undefined) .mockRejectedValueOnce(new Error('Broadcast failed')); // Should not throw await expect(manager.broadcastToAllTabs(broadcastFn)).resolves.not.toThrow(); }); it('should skip tabs without initialized services', async () => { // Create tab without initialized service mockCreateTab.mockReturnValueOnce( createMockTabData({ service: null, serviceInitialized: false }) ); await manager.createTab(); const broadcastFn = jest.fn().mockResolvedValue(undefined); await manager.broadcastToAllTabs(broadcastFn); // Should only be called for the 2 initialized tabs, not the 3rd expect(broadcastFn).toHaveBeenCalledTimes(2); }); }); }); describe('TabManager - Cleanup', () => { let manager: TabManager; beforeEach(async () => { manager = createManager(); await manager.createTab(); await manager.createTab(); }); describe('destroy', () => { it('should destroy all tabs', async () => { await manager.destroy(); expect(mockDestroyTab).toHaveBeenCalledTimes(2); expect(manager.getTabCount()).toBe(0); }); it('should save all conversations before destroying', async () => { const tabs = manager.getAllTabs(); const saveFns = tabs.map(tab => tab.controllers.conversationController?.save); await manager.destroy(); saveFns.forEach(save => { expect(save).toHaveBeenCalled(); }); }); it('should clear active tab ID', async () => { expect(manager.getActiveTabId()).not.toBeNull(); await manager.destroy(); expect(manager.getActiveTabId()).toBeNull(); }); }); }); describe('TabManager - Callback Wiring', () => { beforeEach(() => { jest.clearAllMocks(); }); describe('ChatState callbacks during tab creation', () => { it('should wire onStreamingChanged callback to TabManager callbacks', async () => { const onTabStreamingChanged = jest.fn(); const callbacks: TabManagerCallbacks = { onTabStreamingChanged }; let capturedCallbacks: any; mockCreateTab.mockImplementation((opts: any) => { capturedCallbacks = opts; return createMockTabData({ id: 'test-tab' }); }); const manager = new TabManager(createMockPlugin(), createMockMcpManager(), createMockEl(), createMockView(), callbacks); await manager.createTab(); // Trigger the onStreamingChanged callback capturedCallbacks.onStreamingChanged(true); expect(onTabStreamingChanged).toHaveBeenCalledWith('test-tab', true); }); it('should wire onTitleChanged callback to TabManager callbacks', async () => { const onTabTitleChanged = jest.fn(); const callbacks: TabManagerCallbacks = { onTabTitleChanged }; let capturedCallbacks: any; mockCreateTab.mockImplementation((opts: any) => { capturedCallbacks = opts; return createMockTabData({ id: 'test-tab' }); }); const manager = new TabManager(createMockPlugin(), createMockMcpManager(), createMockEl(), createMockView(), callbacks); await manager.createTab(); capturedCallbacks.onTitleChanged('New Title'); expect(onTabTitleChanged).toHaveBeenCalledWith('test-tab', 'New Title'); }); it('should wire onAttentionChanged callback to TabManager callbacks', async () => { const onTabAttentionChanged = jest.fn(); const callbacks: TabManagerCallbacks = { onTabAttentionChanged }; let capturedCallbacks: any; mockCreateTab.mockImplementation((opts: any) => { capturedCallbacks = opts; return createMockTabData({ id: 'test-tab' }); }); const manager = new TabManager(createMockPlugin(), createMockMcpManager(), createMockEl(), createMockView(), callbacks); await manager.createTab(); capturedCallbacks.onAttentionChanged(true); expect(onTabAttentionChanged).toHaveBeenCalledWith('test-tab', true); }); it('should wire onConversationIdChanged callback to sync tab conversationId', async () => { const onTabConversationChanged = jest.fn(); const callbacks: TabManagerCallbacks = { onTabConversationChanged }; let capturedCallbacks: any; const tabData = createMockTabData({ id: 'test-tab', conversationId: null }); mockCreateTab.mockImplementation((opts: any) => { capturedCallbacks = opts; return tabData; }); const manager = new TabManager(createMockPlugin(), createMockMcpManager(), createMockEl(), createMockView(), callbacks); await manager.createTab(); // Trigger the onConversationIdChanged callback (simulating conversation creation) capturedCallbacks.onConversationIdChanged('new-conv-id'); // Tab's conversationId should be synced expect(tabData.conversationId).toBe('new-conv-id'); expect(onTabConversationChanged).toHaveBeenCalledWith('test-tab', 'new-conv-id'); }); }); }); describe('TabManager - openConversation Current Tab Path', () => { let manager: TabManager; let plugin: any; beforeEach(async () => { plugin = createMockPlugin(); manager = createManager({ plugin }); await manager.createTab(); }); it('should open conversation in current tab when preferNewTab is false', async () => { const activeTab = manager.getActiveTab(); const switchTo = jest.fn().mockResolvedValue(undefined); activeTab!.controllers.conversationController = { switchTo } as any; plugin.getConversationById.mockResolvedValue({ id: 'conv-to-open' }); await manager.openConversation('conv-to-open', false); expect(switchTo).toHaveBeenCalledWith('conv-to-open'); }); it('should open conversation in current tab by default (preferNewTab defaults to false)', async () => { const activeTab = manager.getActiveTab(); const switchTo = jest.fn().mockResolvedValue(undefined); activeTab!.controllers.conversationController = { switchTo } as any; plugin.getConversationById.mockResolvedValue({ id: 'conv-default' }); await manager.openConversation('conv-default'); expect(switchTo).toHaveBeenCalledWith('conv-default'); }); it('should not modify tab.conversationId directly (waits for callback)', async () => { const activeTab = manager.getActiveTab(); const switchTo = jest.fn().mockResolvedValue(undefined); activeTab!.controllers.conversationController = { switchTo } as any; activeTab!.conversationId = null; plugin.getConversationById.mockResolvedValue({ id: 'conv-123' }); await manager.openConversation('conv-123', false); // conversationId should NOT be set by openConversation - it's synced via callback expect(activeTab!.conversationId).toBeNull(); }); it('should not open in current tab if at max tabs and preferNewTab is true', async () => { for (let i = 0; i < DEFAULT_MAX_TABS - 1; i++) { await manager.createTab(); } expect(manager.getTabCount()).toBe(DEFAULT_MAX_TABS); const activeTab = manager.getActiveTab(); const switchTo = jest.fn().mockResolvedValue(undefined); activeTab!.controllers.conversationController = { switchTo } as any; plugin.getConversationById.mockResolvedValue({ id: 'conv-max' }); // preferNewTab=true but at max, so should open in current tab await manager.openConversation('conv-max', true); expect(switchTo).toHaveBeenCalledWith('conv-max'); }); }); describe('TabManager - Service Initialization Errors', () => { it('should handle initializeActiveTabService errors gracefully', async () => { const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); // Make initializeTabService fail mockInitializeTabService.mockRejectedValueOnce(new Error('Service init failed')); mockCreateTab.mockReturnValue( createMockTabData({ id: 'test-tab', serviceInitialized: false }) ); const manager = new TabManager( createMockPlugin(), createMockMcpManager(), createMockEl(), createMockView() ); // Restore state triggers initializeActiveTabService const persistedState: PersistedTabManagerState = { openTabs: [{ tabId: 'restored-tab', conversationId: null }], activeTabId: 'restored-tab', }; // Should not throw even if service init fails await expect(manager.restoreState(persistedState)).resolves.not.toThrow(); consoleSpy.mockRestore(); }); }); describe('TabManager - Concurrent Switch Guard', () => { it('should prevent concurrent tab switches', async () => { const callbacks: TabManagerCallbacks = { onTabSwitched: jest.fn(), }; const manager = createManager({ callbacks }); const tab1 = await manager.createTab(); const tab2 = await manager.createTab(); // Set up tab-1 to trigger the async conversationController.switchTo path // so that switchToTab hangs mid-execution with isSwitchingTab = true let resolveSwitchTo!: () => void; const hangingPromise = new Promise<void>(resolve => { resolveSwitchTo = resolve; }); tab1!.conversationId = 'conv-1'; tab1!.state.messages = []; tab1!.controllers.conversationController!.switchTo = jest.fn().mockReturnValue(hangingPromise); jest.clearAllMocks(); // Start first switch to tab-1 (will hang on conversationController.switchTo) const firstSwitch = manager.switchToTab(tab1!.id); // While first switch is in progress, try a second switch. // isSwitchingTab is true, so this should return immediately (lines 143-144) await manager.switchToTab(tab2!.id); expect(mockDeactivateTab).toHaveBeenCalledTimes(1); expect(mockActivateTab).toHaveBeenCalledTimes(1); // Resolve the hanging first switch resolveSwitchTo(); await firstSwitch; expect(callbacks.onTabSwitched).toHaveBeenCalledTimes(1); // After first switch completes, isSwitchingTab is false // and subsequent switches should work normally await manager.switchToTab(tab2!.id); expect(callbacks.onTabSwitched).toHaveBeenCalledTimes(2); }); }); describe('TabManager - closeTab Edge Cases', () => { it('should return false for non-existent tab', async () => { const manager = createManager(); await manager.createTab(); const result = await manager.closeTab('non-existent-tab'); expect(result).toBe(false); }); it('should not close last empty tab (preserves warm service)', async () => { const manager = createManager({ tabFactory: () => createMockTabData({ id: 'only-tab' }), }); await manager.createTab(); const result = await manager.closeTab('only-tab'); expect(result).toBe(false); expect(manager.getTabCount()).toBe(1); }); it('should create new tab and initialize service when closing the last tab with conversation', async () => { const callbacks: TabManagerCallbacks = { onTabCreated: jest.fn(), onTabClosed: jest.fn(), }; const manager = createManager({ callbacks, tabFactory: (n) => createMockTabData({ id: `tab-${n}`, conversationId: n === 1 ? 'conv-existing' : null, }), }); await manager.createTab(); jest.clearAllMocks(); // Close the only tab (has conversationId so it bypasses the last-empty-tab guard) const result = await manager.closeTab('tab-1'); expect(result).toBe(true); expect(manager.getTabCount()).toBe(1); // New tab was created expect(mockCreateTab).toHaveBeenCalled(); expect(mockInitializeTabService).toHaveBeenCalled(); expect(callbacks.onTabClosed).toHaveBeenCalledWith('tab-1'); }); }); describe('TabManager - forkToNewTab', () => { it('should propagate currentNote from context to forked conversation', async () => { const mockCreateConversation = jest.fn().mockResolvedValue({ id: 'fork-conv-1' }); const mockUpdateConversation = jest.fn().mockResolvedValue(undefined); const plugin = createMockPlugin({ createConversation: mockCreateConversation, updateConversation: mockUpdateConversation, }); const manager = createManager({ plugin }); await manager.createTab(); await manager.forkToNewTab({ messages: [ { id: 'msg-1', role: 'user', content: 'hello', timestamp: 1 }, { id: 'msg-2', role: 'assistant', content: 'hi', timestamp: 2 }, ] as any, sourceSessionId: 'session-1', resumeAt: 'assistant-uuid-1', currentNote: 'notes/test.md', }); expect(mockUpdateConversation).toHaveBeenCalledWith('fork-conv-1', expect.objectContaining({ currentNote: 'notes/test.md', })); }); it('should not set currentNote when context has none', async () => { const mockCreateConversation = jest.fn().mockResolvedValue({ id: 'fork-conv-2' }); const mockUpdateConversation = jest.fn().mockResolvedValue(undefined); const plugin = createMockPlugin({ createConversation: mockCreateConversation, updateConversation: mockUpdateConversation, }); const manager = createManager({ plugin }); await manager.createTab(); await manager.forkToNewTab({ messages: [ { id: 'msg-1', role: 'user', content: 'hello', timestamp: 1 }, { id: 'msg-2', role: 'assistant', content: 'hi', timestamp: 2 }, ] as any, sourceSessionId: 'session-1', resumeAt: 'assistant-uuid-1', }); const updateCall = mockUpdateConversation.mock.calls[0][1]; expect(updateCall.currentNote).toBeUndefined(); }); }); describe('TabManager - forkInCurrentTab', () => { it('should create fork conversation and switch active tab to it', async () => { const mockCreateConversation = jest.fn().mockResolvedValue({ id: 'fork-conv-1' }); const mockUpdateConversation = jest.fn().mockResolvedValue(undefined); const mockSwitchTo = jest.fn().mockResolvedValue(undefined); const plugin = createMockPlugin({ createConversation: mockCreateConversation, updateConversation: mockUpdateConversation, }); let tabCounter = 0; mockCreateTab.mockImplementation(() => { tabCounter++; return createMockTabData({ id: `tab-${tabCounter}`, controllers: { conversationController: { save: jest.fn().mockResolvedValue(undefined), switchTo: mockSwitchTo, initializeWelcome: jest.fn(), }, inputController: { handleApprovalRequest: jest.fn() }, }, }); }); const manager = new TabManager( plugin, createMockMcpManager(), createMockEl(), createMockView() ); await manager.createTab(); const success = await manager.forkInCurrentTab({ messages: [{ id: 'msg-1', role: 'user', content: 'hello', timestamp: 1 }] as any, sourceSessionId: 'session-1', resumeAt: 'assistant-uuid-1', currentNote: 'notes/test.md', sourceTitle: 'My Chat', forkAtUserMessage: 1, }); expect(success).toBe(true); expect(mockCreateConversation).toHaveBeenCalled(); expect(mockUpdateConversation).toHaveBeenCalledWith('fork-conv-1', expect.objectContaining({ forkSource: { sessionId: 'session-1', resumeAt: 'assistant-uuid-1' }, currentNote: 'notes/test.md', })); expect(mockSwitchTo).toHaveBeenCalledWith('fork-conv-1'); }); it('should return false when no active tab exists', async () => { const plugin = createMockPlugin({ createConversation: jest.fn().mockResolvedValue({ id: 'fork-conv-2' }), updateConversation: jest.fn().mockResolvedValue(undefined), }); const manager = createManager({ plugin }); // Don't create any tabs const success = await manager.forkInCurrentTab({ messages: [] as any, sourceSessionId: 'session-1', resumeAt: 'assistant-uuid-1', }); expect(success).toBe(false); }); it('should not check tab count limit', async () => { const mockCreateConversation = jest.fn().mockResolvedValue({ id: 'fork-conv-3' }); const mockUpdateConversation = jest.fn().mockResolvedValue(undefined); const mockSwitchTo = jest.fn().mockResolvedValue(undefined); const plugin = createMockPlugin({ createConversation: mockCreateConversation, updateConversation: mockUpdateConversation, settings: { maxTabs: 3 }, }); let tabCounter = 0; mockCreateTab.mockImplementation(() => { tabCounter++; return createMockTabData({ id: `tab-${tabCounter}`, controllers: { conversationController: { save: jest.fn().mockResolvedValue(undefined), switchTo: mockSwitchTo, initializeWelcome: jest.fn(), }, inputController: { handleApprovalRequest: jest.fn() }, }, }); }); const manager = new TabManager( plugin, createMockMcpManager(), createMockEl(), createMockView() ); // Fill all tabs to max await manager.createTab(); await manager.createTab(); await manager.createTab(); // forkInCurrentTab should still work even at max tabs const success = await manager.forkInCurrentTab({ messages: [{ id: 'msg-1', role: 'user', content: 'hello', timestamp: 1 }] as any, sourceSessionId: 'session-1', resumeAt: 'assistant-uuid-1', }); expect(success).toBe(true); expect(mockSwitchTo).toHaveBeenCalled(); }); }); describe('TabManager - switchToTab Session Sync', () => { it('should sync service session for already-loaded tab with conversation', async () => { jest.clearAllMocks(); const mockSetSessionId = jest.fn(); const mockService = { setSessionId: mockSetSessionId, closePersistentQuery: jest.fn(), ensureReady: jest.fn().mockResolvedValue(true), onReadyStateChange: jest.fn(() => () => {}), isReady: jest.fn().mockReturnValue(true), applyForkState: jest.fn((conv: any) => conv.sessionId ?? conv.forkSource?.sessionId ?? null), }; let tabCounter = 0; mockCreateTab.mockImplementation(() => { tabCounter++; const tab = createMockTabData({ id: `tab-${tabCounter}`, conversationId: tabCounter === 2 ? 'conv-loaded' : null, service: tabCounter === 2 ? mockService : null, serviceInitialized: tabCounter === 2, }); // For tab-2, simulate already having messages loaded if (tabCounter === 2) { tab.state.messages = [{ id: 'msg-1', role: 'user', content: 'test' }] as any; } return tab; }); const plugin = createMockPlugin(); plugin.getConversationById = jest.fn().mockResolvedValue({ id: 'conv-loaded', messages: [{ id: 'msg-1', role: 'user', content: 'test' }], sessionId: 'session-xyz', externalContextPaths: ['/some/path'], }); const manager = new TabManager( plugin, createMockMcpManager(), createMockEl(), createMockView() ); await manager.createTab(); // tab-1, active await manager.createTab(); // tab-2, auto-switches and triggers session sync // Should have synced the service session during auto-switch to tab-2 expect(mockSetSessionId).toHaveBeenCalledWith('session-xyz', ['/some/path']); }); it('should use persistentExternalContextPaths when conversation has no messages', async () => { jest.clearAllMocks(); const mockSetSessionId = jest.fn(); const mockService = { setSessionId: mockSetSessionId, applyForkState: jest.fn((conv: any) => conv.sessionId ?? conv.forkSource?.sessionId ?? null), }; let tabCounter = 0; mockCreateTab.mockImplementation(() => { tabCounter++; const tab = createMockTabData({ id: `tab-${tabCounter}`, conversationId: tabCounter === 2 ? 'conv-empty' : null, service: tabCounter === 2 ? mockService : null, serviceInitialized: tabCounter === 2, }); // Tab has local messages but the persisted conversation does not if (tabCounter === 2) { tab.state.messages = [{ id: 'msg-1', role: 'user', content: 'test' }] as any; } return tab; }); const plugin = createMockPlugin({ settings: { maxTabs: DEFAULT_MAX_TABS, persistentExternalContextPaths: ['/persistent/path'], }, }); plugin.getConversationById = jest.fn().mockResolvedValue({ id: 'conv-empty', messages: [], sessionId: 'session-abc', externalContextPaths: [], }); const manager = new TabManager( plugin, createMockMcpManager(), createMockEl(), createMockView() ); await manager.createTab(); // tab-1 await manager.createTab(); // tab-2, auto-switches and triggers session sync // conversation.messages is empty, so should fall back to persistentExternalContextPaths expect(mockSetSessionId).toHaveBeenCalledWith('session-abc', ['/persistent/path']); }); it('should initialize welcome for new tab without conversation', async () => { jest.clearAllMocks(); const mockInitializeWelcome = jest.fn(); let tabCounter = 0; mockCreateTab.mockImplementation(() => { tabCounter++; const tab = createMockTabData({ id: `tab-${tabCounter}` }); tab.controllers.conversationController = { ...tab.controllers.conversationController, initializeWelcome: mockInitializeWelcome, }; return tab; }); const manager = new TabManager( createMockPlugin(), createMockMcpManager(), createMockEl(), createMockView() ); await manager.createTab(); // tab-1 await manager.createTab(); // tab-2 (now active) // Switch to tab-1 first so we can switch back to tab-2 await manager.switchToTab('tab-1'); mockInitializeWelcome.mockClear(); // Switch to tab-2 (no conversationId, no messages -> should call initializeWelcome) await manager.switchToTab('tab-2'); expect(mockInitializeWelcome).toHaveBeenCalled(); }); }); describe('TabManager - handleForkRequest (modal dispatch)', () => { it('should fork to new tab when user selects "new-tab"', async () => { mockChooseForkTarget.mockResolvedValue('new-tab'); const mockCreateConversation = jest.fn().mockResolvedValue({ id: 'fork-conv-1' }); const mockUpdateConversation = jest.fn().mockResolvedValue(undefined); const plugin = createMockPlugin({ createConversation: mockCreateConversation, updateConversation: mockUpdateConversation, }); let capturedForkCallback: any; mockInitializeTabControllers.mockImplementation( (_tab: any, _plugin: any, _view: any, _mcp: any, forkCb: any) => { capturedForkCallback = forkCb; } ); const manager = createManager({ plugin }); await manager.createTab(); // Invoke the fork callback that was passed to initializeTabControllers await capturedForkCallback({ messages: [{ id: 'msg-1', role: 'user', content: 'hello', timestamp: 1 }], sourceSessionId: 'session-1', resumeAt: 'asst-uuid-1', sourceTitle: 'Test Chat', forkAtUserMessage: 1, }); expect(mockChooseForkTarget).toHaveBeenCalled(); expect(mockCreateConversation).toHaveBeenCalled(); expect(mockUpdateConversation).toHaveBeenCalled(); }); it('should fork in current tab when user selects "current-tab"', async () => { mockChooseForkTarget.mockResolvedValue('current-tab'); const mockCreateConversation = jest.fn().mockResolvedValue({ id: 'fork-conv-2' }); const mockUpdateConversation = jest.fn().mockResolvedValue(undefined); const mockSwitchTo = jest.fn().mockResolvedValue(undefined); const plugin = createMockPlugin({ createConversation: mockCreateConversation, updateConversation: mockUpdateConversation, }); let capturedForkCallback: any; let tabCounter = 0; mockCreateTab.mockImplementation(() => { tabCounter++; return createMockTabData({ id: `tab-${tabCounter}`, controllers: { conversationController: { save: jest.fn().mockResolvedValue(undefined), switchTo: mockSwitchTo, initializeWelcome: jest.fn(), }, inputController: { handleApprovalRequest: jest.fn() }, }, }); }); mockInitializeTabControllers.mockImplementation( (_tab: any, _plugin: any, _view: any, _mcp: any, forkCb: any) => { capturedForkCallback = forkCb; } ); const manager = new TabManager( plugin, createMockMcpManager(), createMockEl(), createMockView() ); await manager.createTab(); await capturedForkCallback({ messages: [], sourceSessionId: 'session-1', resumeAt: 'asst-uuid-1', }); expect(mockChooseForkTarget).toHaveBeenCalled(); expect(mockSwitchTo).toHaveBeenCalledWith('fork-conv-2'); }); it('should do nothing when user cancels modal', async () => { mockChooseForkTarget.mockResolvedValue(null); const mockCreateConversation = jest.fn(); const plugin = createMockPlugin({ createConversation: mockCreateConversation }); let capturedForkCallback: any; mockInitializeTabControllers.mockImplementation( (_tab: any, _plugin: any, _view: any, _mcp: any, forkCb: any) => { capturedForkCallback = forkCb; } ); const manager = createManager({ plugin }); await manager.createTab(); await capturedForkCallback({ messages: [], sourceSessionId: 'session-1', resumeAt: 'asst-uuid-1', }); expect(mockChooseForkTarget).toHaveBeenCalled(); expect(mockCreateConversation).not.toHaveBeenCalled(); }); }); describe('TabManager - forkToNewTab at max tabs', () => { it('should return null when at max tabs', async () => { jest.clearAllMocks(); const plugin = createMockPlugin(); // MIN_TABS is 3, so maxTabs must be >= 3 to avoid clamping plugin.settings.maxTabs = 3; plugin.createConversation = jest.fn().mockResolvedValue({ id: 'fork-conv' }); plugin.updateConversation = jest.fn().mockResolvedValue(undefined); let tabCounter = 0; mockCreateTab.mockImplementation(() => { tabCounter++; return createMockTabData({ id: `tab-${tabCounter}` }); }); const manager = new TabManager( plugin, createMockMcpManager(), createMockEl(), createMockView() ); await manager.createTab(); await manager.createTab(); await manager.createTab(); expect(manager.getTabCount()).toBe(3); const result = await manager.forkToNewTab({ messages: [], sourceSessionId: 'session-1', resumeAt: 'asst-uuid', }); expect(result).toBeNull(); }); }); describe('TabManager - createForkConversation', () => { it('should set sdkMessagesLoaded to true on fork conversation', async () => { const mockCreateConversation = jest.fn().mockResolvedValue({ id: 'fork-conv-1' }); const mockUpdateConversation = jest.fn().mockResolvedValue(undefined); const plugin = createMockPlugin({ createConversation: mockCreateConversation, updateConversation: mockUpdateConversation, }); const manager = createManager({ plugin }); await manager.createTab(); await manager.forkToNewTab({ messages: [{ id: 'msg-1', role: 'user', content: 'hello', timestamp: 1 }] as any, sourceSessionId: 'session-1', resumeAt: 'asst-uuid-1', sourceTitle: 'My Chat', }); expect(mockUpdateConversation).toHaveBeenCalledWith('fork-conv-1', expect.objectContaining({ sdkMessagesLoaded: true, })); }); it('should set forkSource with sessionId and resumeAt', async () => { const mockCreateConversation = jest.fn().mockResolvedValue({ id: 'fork-conv-1' }); const mockUpdateConversation = jest.fn().mockResolvedValue(undefined); const plugin = createMockPlugin({ createConversation: mockCreateConversation, updateConversation: mockUpdateConversation, }); const manager = createManager({ plugin }); await manager.createTab(); await manager.forkToNewTab({ messages: [], sourceSessionId: 'session-abc', resumeAt: 'asst-uuid-xyz', }); expect(mockUpdateConversation).toHaveBeenCalledWith('fork-conv-1', expect.objectContaining({ forkSource: { sessionId: 'session-abc', resumeAt: 'asst-uuid-xyz' }, })); }); it('should not set title when sourceTitle is undefined', async () => { const mockCreateConversation = jest.fn().mockResolvedValue({ id: 'fork-conv-1' }); const mockUpdateConversation = jest.fn().mockResolvedValue(undefined); const plugin = createMockPlugin({ createConversation: mockCreateConversation, updateConversation: mockUpdateConversation, }); const manager = createManager({ plugin }); await manager.createTab(); await manager.forkToNewTab({ messages: [], sourceSessionId: 'session-1', resumeAt: 'asst-uuid-1', // no sourceTitle }); const updateCall = mockUpdateConversation.mock.calls[0][1]; expect(updateCall.title).toBeUndefined(); }); }); describe('TabManager - buildForkTitle', () => { function setupTitleTest(existingTitles: string[] = []) { const mockCreateConversation = jest.fn().mockResolvedValue({ id: 'fork-conv' }); const mockUpdateConversation = jest.fn().mockResolvedValue(undefined); const plugin = createMockPlugin({ createConversation: mockCreateConversation, updateConversation: mockUpdateConversation, getConversationList: jest.fn().mockReturnValue( existingTitles.map((t, i) => ({ id: `conv-${i}`, title: t })) ), }); return { plugin, mockUpdateConversation }; } it('should format title as "Fork: {source} (#{num})"', async () => { const { plugin, mockUpdateConversation } = setupTitleTest(); const manager = createManager({ plugin }); await manager.createTab(); await manager.forkToNewTab({ messages: [], sourceSessionId: 'session-1', resumeAt: 'asst-uuid-1', sourceTitle: 'My Chat', forkAtUserMessage: 3, }); const updateCall = mockUpdateConversation.mock.calls[0][1]; expect(updateCall.title).toBe('Fork: My Chat (#3)'); }); it('should format title without message number when not provided', async () => { const { plugin, mockUpdateConversation } = setupTitleTest(); const manager = createManager({ plugin }); await manager.createTab(); await manager.forkToNewTab({ messages: [], sourceSessionId: 'session-1', resumeAt: 'asst-uuid-1', sourceTitle: 'My Chat', }); const updateCall = mockUpdateConversation.mock.calls[0][1]; expect(updateCall.title).toBe('Fork: My Chat'); }); it('should truncate long source titles', async () => { const { plugin, mockUpdateConversation } = setupTitleTest(); const manager = createManager({ plugin }); await manager.createTab(); const longTitle = 'A'.repeat(100); await manager.forkToNewTab({ messages: [], sourceSessionId: 'session-1', resumeAt: 'asst-uuid-1', sourceTitle: longTitle, forkAtUserMessage: 1, }); const updateCall = mockUpdateConversation.mock.calls[0][1]; expect(updateCall.title.length).toBeLessThanOrEqual(50); expect(updateCall.title).toContain('…'); expect(updateCall.title).toContain('Fork: '); expect(updateCall.title).toContain('(#1)'); }); it('should deduplicate title when same fork title exists', async () => { const { plugin, mockUpdateConversation } = setupTitleTest(['Fork: My Chat (#1)']); const manager = createManager({ plugin }); await manager.createTab(); await manager.forkToNewTab({ messages: [], sourceSessionId: 'session-1', resumeAt: 'asst-uuid-1', sourceTitle: 'My Chat', forkAtUserMessage: 1, }); const updateCall = mockUpdateConversation.mock.calls[0][1]; expect(updateCall.title).toBe('Fork: My Chat (#1) 2'); }); it('should find next available dedup number', async () => { const { plugin, mockUpdateConversation } = setupTitleTest([ 'Fork: My Chat (#1)', 'Fork: My Chat (#1) 2', 'Fork: My Chat (#1) 3', ]); const manager = createManager({ plugin }); await manager.createTab(); await manager.forkToNewTab({ messages: [], sourceSessionId: 'session-1', resumeAt: 'asst-uuid-1', sourceTitle: 'My Chat', forkAtUserMessage: 1, }); const updateCall = mockUpdateConversation.mock.calls[0][1]; expect(updateCall.title).toBe('Fork: My Chat (#1) 4'); }); }); ================================================ FILE: tests/unit/features/chat/tabs/index.test.ts ================================================ import { createTab,TabBar, TabManager } from '@/features/chat/tabs'; describe('features/chat/tabs index', () => { it('re-exports runtime symbols', () => { expect(createTab).toBeDefined(); expect(TabBar).toBeDefined(); expect(TabManager).toBeDefined(); }); }); ================================================ FILE: tests/unit/features/chat/ui/BangBashModeManager.test.ts ================================================ import { BangBashModeManager } from '@/features/chat/ui/BangBashModeManager'; function createWrapper() { return { addClass: jest.fn(), removeClass: jest.fn(), } as any; } function createKeyEvent(key: string, options: { shiftKey?: boolean } = {}) { return { key, shiftKey: options.shiftKey ?? false, isComposing: false, preventDefault: jest.fn(), } as any; } describe('BangBashModeManager', () => { it('should enter bash mode on ! keystroke when input is empty', () => { const wrapper = createWrapper(); const inputEl = { value: '', placeholder: 'Ask...' } as any; const callbacks = { onSubmit: jest.fn().mockResolvedValue(undefined), getInputWrapper: () => wrapper, }; const manager = new BangBashModeManager(inputEl, callbacks); const e = createKeyEvent('!'); const handled = manager.handleTriggerKey(e); expect(handled).toBe(true); expect(e.preventDefault).toHaveBeenCalled(); expect(manager.isActive()).toBe(true); expect(wrapper.addClass).toHaveBeenCalledWith('claudian-input-bang-bash-mode'); }); it('should NOT enter bash mode on ! keystroke when input has content', () => { const wrapper = createWrapper(); const inputEl = { value: 'hello', placeholder: 'Ask...' } as any; const callbacks = { onSubmit: jest.fn().mockResolvedValue(undefined), getInputWrapper: () => wrapper, }; const manager = new BangBashModeManager(inputEl, callbacks); const e = createKeyEvent('!'); const handled = manager.handleTriggerKey(e); expect(handled).toBe(false); expect(e.preventDefault).not.toHaveBeenCalled(); expect(manager.isActive()).toBe(false); expect(wrapper.addClass).not.toHaveBeenCalled(); }); it('should NOT enter bash mode when already active', () => { const wrapper = createWrapper(); const inputEl = { value: '', placeholder: 'Ask...' } as any; const callbacks = { onSubmit: jest.fn().mockResolvedValue(undefined), getInputWrapper: () => wrapper, }; const manager = new BangBashModeManager(inputEl, callbacks); manager.handleTriggerKey(createKeyEvent('!')); expect(manager.isActive()).toBe(true); // Try triggering again while active inputEl.value = ''; const e = createKeyEvent('!'); const handled = manager.handleTriggerKey(e); expect(handled).toBe(false); }); it('should stay in bash mode when input is cleared (exit via Escape)', () => { const wrapper = createWrapper(); const inputEl = { value: '', placeholder: 'Ask...' } as any; const callbacks = { onSubmit: jest.fn().mockResolvedValue(undefined), getInputWrapper: () => wrapper, }; const manager = new BangBashModeManager(inputEl, callbacks); manager.handleTriggerKey(createKeyEvent('!')); expect(manager.isActive()).toBe(true); inputEl.value = ''; manager.handleInputChange(); expect(manager.isActive()).toBe(true); expect(wrapper.removeClass).not.toHaveBeenCalled(); }); it('should submit command on Enter and trim whitespace', async () => { const wrapper = createWrapper(); const inputEl = { value: '', placeholder: 'Ask...' } as any; const callbacks = { onSubmit: jest.fn().mockResolvedValue(undefined), getInputWrapper: () => wrapper, }; const manager = new BangBashModeManager(inputEl, callbacks); manager.handleTriggerKey(createKeyEvent('!')); inputEl.value = ' ls -la '; manager.handleInputChange(); const e = createKeyEvent('Enter'); const handled = manager.handleKeydown(e); expect(handled).toBe(true); expect(e.preventDefault).toHaveBeenCalled(); // Wait for async submit await new Promise(resolve => setTimeout(resolve, 0)); expect(callbacks.onSubmit).toHaveBeenCalledWith('ls -la'); }); it('should handle Enter when command is empty (no submit)', () => { const wrapper = createWrapper(); const inputEl = { value: '', placeholder: 'Ask...' } as any; const callbacks = { onSubmit: jest.fn().mockResolvedValue(undefined), getInputWrapper: () => wrapper, }; const manager = new BangBashModeManager(inputEl, callbacks); manager.handleTriggerKey(createKeyEvent('!')); inputEl.value = ' '; manager.handleInputChange(); const e = createKeyEvent('Enter'); const handled = manager.handleKeydown(e); expect(handled).toBe(true); expect(e.preventDefault).toHaveBeenCalled(); expect(callbacks.onSubmit).not.toHaveBeenCalled(); }); it('should cancel on Escape and clear input', () => { const wrapper = createWrapper(); const inputEl = { value: '', placeholder: 'Ask...' } as any; const callbacks = { onSubmit: jest.fn().mockResolvedValue(undefined), getInputWrapper: () => wrapper, }; const manager = new BangBashModeManager(inputEl, callbacks); manager.handleTriggerKey(createKeyEvent('!')); expect(manager.isActive()).toBe(true); inputEl.value = 'hello'; manager.handleInputChange(); const e = createKeyEvent('Escape'); const handled = manager.handleKeydown(e); expect(handled).toBe(true); expect(e.preventDefault).toHaveBeenCalled(); expect(inputEl.value).toBe(''); expect(manager.isActive()).toBe(false); }); it('should return false for non-Enter/Escape keys when active', () => { const wrapper = createWrapper(); const inputEl = { value: '', placeholder: 'Ask...' } as any; const callbacks = { onSubmit: jest.fn().mockResolvedValue(undefined), getInputWrapper: () => wrapper, }; const manager = new BangBashModeManager(inputEl, callbacks); manager.handleTriggerKey(createKeyEvent('!')); expect(manager.isActive()).toBe(true); inputEl.value = 'some text'; manager.handleInputChange(); const e = createKeyEvent('a'); const handled = manager.handleKeydown(e); expect(handled).toBe(false); expect(e.preventDefault).not.toHaveBeenCalled(); }); it('should return raw command text via getRawCommand', () => { const wrapper = createWrapper(); const inputEl = { value: '', placeholder: 'Ask...' } as any; const callbacks = { onSubmit: jest.fn().mockResolvedValue(undefined), getInputWrapper: () => wrapper, }; const manager = new BangBashModeManager(inputEl, callbacks); manager.handleTriggerKey(createKeyEvent('!')); inputEl.value = 'npm test'; manager.handleInputChange(); expect(manager.getRawCommand()).toBe('npm test'); }); it('should clear input, exit mode and reset input height on clear()', () => { const wrapper = createWrapper(); const inputEl = { value: '', placeholder: 'Ask...' } as any; const resetInputHeight = jest.fn(); const callbacks = { onSubmit: jest.fn().mockResolvedValue(undefined), getInputWrapper: () => wrapper, resetInputHeight, }; const manager = new BangBashModeManager(inputEl, callbacks); manager.handleTriggerKey(createKeyEvent('!')); expect(manager.isActive()).toBe(true); inputEl.value = 'some command'; manager.handleInputChange(); manager.clear(); expect(inputEl.value).toBe(''); expect(manager.isActive()).toBe(false); expect(resetInputHeight).toHaveBeenCalled(); }); it('should remove bash mode class and restore placeholder on destroy()', () => { const wrapper = createWrapper(); const inputEl = { value: '', placeholder: 'Ask...' } as any; const callbacks = { onSubmit: jest.fn().mockResolvedValue(undefined), getInputWrapper: () => wrapper, }; const manager = new BangBashModeManager(inputEl, callbacks); manager.handleTriggerKey(createKeyEvent('!')); expect(manager.isActive()).toBe(true); manager.destroy(); expect(wrapper.removeClass).toHaveBeenCalledWith('claudian-input-bang-bash-mode'); expect(inputEl.placeholder).toBe('Ask...'); }); it('should not enter mode when wrapper is null', () => { const inputEl = { value: '', placeholder: 'Ask...' } as any; const callbacks = { onSubmit: jest.fn().mockResolvedValue(undefined), getInputWrapper: () => null, }; const manager = new BangBashModeManager(inputEl, callbacks); const e = createKeyEvent('!'); const handled = manager.handleTriggerKey(e); expect(handled).toBe(false); expect(manager.isActive()).toBe(false); }); it('should prevent double-submit when Enter is pressed rapidly', async () => { const wrapper = createWrapper(); const inputEl = { value: '', placeholder: 'Ask...' } as any; let resolveSubmit: () => void; const submitPromise = new Promise<void>((resolve) => { resolveSubmit = resolve; }); const callbacks = { onSubmit: jest.fn().mockReturnValue(submitPromise), getInputWrapper: () => wrapper, }; const manager = new BangBashModeManager(inputEl, callbacks); manager.handleTriggerKey(createKeyEvent('!')); inputEl.value = 'ls -la'; manager.handleInputChange(); // First Enter manager.handleKeydown(createKeyEvent('Enter')); await new Promise(resolve => setTimeout(resolve, 0)); // Re-enter mode and try to submit again while first is still running manager.handleTriggerKey(createKeyEvent('!')); inputEl.value = 'echo second'; manager.handleInputChange(); manager.handleKeydown(createKeyEvent('Enter')); await new Promise(resolve => setTimeout(resolve, 0)); // Only the first submit should have been called expect(callbacks.onSubmit).toHaveBeenCalledTimes(1); expect(callbacks.onSubmit).toHaveBeenCalledWith('ls -la'); // Resolve the first submit resolveSubmit!(); await new Promise(resolve => setTimeout(resolve, 0)); }); it('should not produce unhandled rejection when onSubmit throws', async () => { const wrapper = createWrapper(); const inputEl = { value: '', placeholder: 'Ask...' } as any; const callbacks = { onSubmit: jest.fn().mockRejectedValue(new Error('boom')), getInputWrapper: () => wrapper, }; const manager = new BangBashModeManager(inputEl, callbacks); manager.handleTriggerKey(createKeyEvent('!')); inputEl.value = 'bad-command'; manager.handleInputChange(); manager.handleKeydown(createKeyEvent('Enter')); // Wait for async submit to complete await new Promise(resolve => setTimeout(resolve, 0)); // Should not throw, error is caught internally expect(callbacks.onSubmit).toHaveBeenCalledWith('bad-command'); expect(manager.isActive()).toBe(false); }); }); ================================================ FILE: tests/unit/features/chat/ui/ExternalContextSelector.test.ts ================================================ import { createMockEl } from '@test/helpers/mockElement'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { ExternalContextSelector } from '@/features/chat/ui/InputToolbar'; // Mock obsidian jest.mock('obsidian', () => ({ Notice: jest.fn(), setIcon: jest.fn(), })); // Mock fs jest.mock('fs'); // Mock callbacks function createMockCallbacks() { return { onModelChange: jest.fn(), onThinkingBudgetChange: jest.fn(), onEffortLevelChange: jest.fn().mockResolvedValue(undefined), onPermissionModeChange: jest.fn(), getSettings: jest.fn().mockReturnValue({ model: 'haiku', thinkingBudget: 'off', effortLevel: 'high', permissionMode: 'yolo', }), getEnvironmentVariables: jest.fn().mockReturnValue(''), }; } describe('ExternalContextSelector', () => { let parentEl: any; let selector: ExternalContextSelector; let callbacks: ReturnType<typeof createMockCallbacks>; beforeEach(() => { jest.clearAllMocks(); // By default, all paths are valid (exist on filesystem) (fs.statSync as jest.Mock).mockReturnValue({ isDirectory: () => true }); parentEl = createMockEl(); callbacks = createMockCallbacks(); selector = new ExternalContextSelector(parentEl, callbacks); }); describe('Persistent Paths Management', () => { it('should initialize with empty persistent paths', () => { expect(selector.getPersistentPaths()).toEqual([]); }); it('should set persistent paths from settings', () => { selector.setPersistentPaths(['/path/a', '/path/b']); expect(selector.getPersistentPaths()).toEqual(['/path/a', '/path/b']); }); it('should merge persistent paths into external contexts when setting', () => { selector.setPersistentPaths(['/path/a', '/path/b']); // After setting persistent paths, they should be in external contexts expect(selector.getExternalContexts()).toContain('/path/a'); expect(selector.getExternalContexts()).toContain('/path/b'); }); it('should toggle persistence on - add path to persistent paths', () => { const onPersistenceChange = jest.fn(); selector.setOnPersistenceChange(onPersistenceChange); // First set some external context paths selector.setExternalContexts(['/path/a']); // Toggle persistence on for path/a selector.togglePersistence('/path/a'); expect(selector.getPersistentPaths()).toContain('/path/a'); expect(onPersistenceChange).toHaveBeenCalledWith(['/path/a']); }); it('should toggle persistence off - remove path from persistent paths', () => { const onPersistenceChange = jest.fn(); selector.setOnPersistenceChange(onPersistenceChange); // Set up with a persistent path selector.setPersistentPaths(['/path/a']); // Toggle persistence off selector.togglePersistence('/path/a'); expect(selector.getPersistentPaths()).not.toContain('/path/a'); expect(onPersistenceChange).toHaveBeenCalledWith([]); }); it('should handle multiple persistent paths', () => { const onPersistenceChange = jest.fn(); selector.setOnPersistenceChange(onPersistenceChange); selector.setPersistentPaths(['/path/a']); selector.togglePersistence('/path/b'); expect(selector.getPersistentPaths()).toContain('/path/a'); expect(selector.getPersistentPaths()).toContain('/path/b'); expect(onPersistenceChange).toHaveBeenCalledWith( expect.arrayContaining(['/path/a', '/path/b']) ); }); }); describe('clearExternalContexts', () => { it('should reset to persistent paths when called without parameter', () => { selector.setPersistentPaths(['/persistent/path']); selector.setExternalContexts(['/session/path', '/persistent/path']); selector.clearExternalContexts(); expect(selector.getExternalContexts()).toEqual(['/persistent/path']); }); it('should use provided paths when called with parameter', () => { selector.setPersistentPaths(['/old/path']); selector.clearExternalContexts(['/new/path/a', '/new/path/b']); expect(selector.getExternalContexts()).toEqual(['/new/path/a', '/new/path/b']); expect(selector.getPersistentPaths()).toEqual(['/new/path/a', '/new/path/b']); }); it('should update persistentPaths when called with parameter', () => { selector.setPersistentPaths(['/old/path']); selector.clearExternalContexts(['/new/path']); // Local persistentPaths should be updated expect(selector.getPersistentPaths()).toEqual(['/new/path']); }); }); describe('setExternalContexts', () => { it('should set exact paths without merging persistent paths', () => { selector.setPersistentPaths(['/persistent/path']); selector.setExternalContexts(['/session/path']); // Should only have the session path, not merged with persistent expect(selector.getExternalContexts()).toEqual(['/session/path']); }); it('should not modify persistent paths', () => { selector.setPersistentPaths(['/persistent/path']); selector.setExternalContexts(['/session/path']); // Persistent paths should remain unchanged expect(selector.getPersistentPaths()).toEqual(['/persistent/path']); }); it('should handle empty array', () => { selector.setPersistentPaths(['/persistent/path']); selector.setExternalContexts(['/session/path']); selector.setExternalContexts([]); expect(selector.getExternalContexts()).toEqual([]); expect(selector.getPersistentPaths()).toEqual(['/persistent/path']); }); }); describe('addExternalContext', () => { it('should reject empty input', () => { const onChange = jest.fn(); selector.setOnChange(onChange); const result = selector.addExternalContext(''); expect(result).toEqual({ success: false, error: 'No path provided. Usage: /add-dir /absolute/path', }); expect(selector.getExternalContexts()).toEqual([]); expect(onChange).not.toHaveBeenCalled(); }); it('should reject whitespace-only input', () => { const onChange = jest.fn(); selector.setOnChange(onChange); const result = selector.addExternalContext(' '); expect(result).toEqual({ success: false, error: 'No path provided. Usage: /add-dir /absolute/path', }); expect(selector.getExternalContexts()).toEqual([]); expect(onChange).not.toHaveBeenCalled(); }); it('should reject relative paths', () => { const onChange = jest.fn(); selector.setOnChange(onChange); const result = selector.addExternalContext('relative/path'); expect(result).toEqual({ success: false, error: 'Path must be absolute. Usage: /add-dir /absolute/path', }); expect(selector.getExternalContexts()).toEqual([]); expect(onChange).not.toHaveBeenCalled(); }); it('should add absolute paths and call onChange', () => { const onChange = jest.fn(); selector.setOnChange(onChange); const absolutePath = path.resolve('external', 'ctx'); const result = selector.addExternalContext(absolutePath); expect(result).toEqual({ success: true, normalizedPath: absolutePath }); expect(selector.getExternalContexts()).toEqual([absolutePath]); expect(onChange).toHaveBeenCalledWith([absolutePath]); }); it('should reject non-existent paths with specific error', () => { (fs.statSync as jest.Mock).mockImplementation(() => { const error = new Error('ENOENT') as NodeJS.ErrnoException; error.code = 'ENOENT'; throw error; }); const absolutePath = path.resolve('non', 'existent'); const result = selector.addExternalContext(absolutePath); expect(result.success).toBe(false); expect(result).toMatchObject({ error: expect.stringContaining('Path does not exist') }); }); it('should reject paths with permission denied error', () => { (fs.statSync as jest.Mock).mockImplementation(() => { const error = new Error('EACCES') as NodeJS.ErrnoException; error.code = 'EACCES'; throw error; }); const absolutePath = path.resolve('no', 'access'); const result = selector.addExternalContext(absolutePath); expect(result.success).toBe(false); expect(result).toMatchObject({ error: expect.stringContaining('Permission denied') }); }); it('should reject paths that exist but are not directories', () => { (fs.statSync as jest.Mock).mockReturnValue({ isDirectory: () => false }); const absolutePath = path.resolve('some', 'file.txt'); const result = selector.addExternalContext(absolutePath); expect(result.success).toBe(false); expect(result).toMatchObject({ error: expect.stringContaining('Path exists but is not a directory') }); }); it('should accept double-quoted absolute paths', () => { const absolutePath = path.resolve('external', 'dir with spaces'); const result = selector.addExternalContext(`"${absolutePath}"`); expect(result.success).toBe(true); expect(selector.getExternalContexts()).toEqual([absolutePath]); }); it('should accept single-quoted absolute paths', () => { const absolutePath = path.resolve('external', 'dir with spaces'); const result = selector.addExternalContext(`'${absolutePath}'`); expect(result.success).toBe(true); expect(selector.getExternalContexts()).toEqual([absolutePath]); }); it('should expand home paths', () => { const homeDir = os.homedir(); const result = selector.addExternalContext('~'); expect(result).toEqual({ success: true, normalizedPath: homeDir }); expect(selector.getExternalContexts()).toEqual([homeDir]); }); it('should reject duplicate paths', () => { const absolutePath = path.resolve('external', 'ctx'); selector.addExternalContext(absolutePath); const result = selector.addExternalContext(absolutePath); expect(result).toEqual({ success: false, error: 'This folder is already added as an external context.', }); expect(selector.getExternalContexts()).toEqual([absolutePath]); }); it('should reject nested paths (child inside parent)', () => { const parentPath = path.resolve('external'); const childPath = path.join(parentPath, 'child'); selector.addExternalContext(parentPath); const result = selector.addExternalContext(childPath); expect(result.success).toBe(false); expect(result).toMatchObject({ error: expect.stringContaining('inside existing path') }); expect(selector.getExternalContexts()).toEqual([parentPath]); }); it('should reject parent paths that would contain existing child', () => { const parentPath = path.resolve('external'); const childPath = path.join(parentPath, 'child'); // Add child first, then try to add parent selector.addExternalContext(childPath); const result = selector.addExternalContext(parentPath); expect(result.success).toBe(false); expect(result).toMatchObject({ error: expect.stringContaining('contains existing path') }); expect(selector.getExternalContexts()).toEqual([childPath]); }); }); describe('Callbacks', () => { it('should call onChange when paths are removed via removePath', () => { const onChange = jest.fn(); selector.setOnChange(onChange); selector.setExternalContexts(['/path/a', '/path/b']); selector.removePath('/path/a'); expect(onChange).toHaveBeenCalledTimes(1); expect(onChange).toHaveBeenCalledWith(['/path/b']); }); it('should call onPersistenceChange when persistence is toggled', () => { const onPersistenceChange = jest.fn(); selector.setOnPersistenceChange(onPersistenceChange); selector.setExternalContexts(['/path/a']); selector.togglePersistence('/path/a'); expect(onPersistenceChange).toHaveBeenCalledTimes(1); expect(onPersistenceChange).toHaveBeenCalledWith(['/path/a']); }); }) describe('removePath', () => { it('should remove path from external contexts', () => { selector.setExternalContexts(['/path/a', '/path/b']); selector.removePath('/path/a'); expect(selector.getExternalContexts()).toEqual(['/path/b']); }); it('should remove path from persistent paths if it was persistent', () => { const onPersistenceChange = jest.fn(); selector.setOnPersistenceChange(onPersistenceChange); selector.setPersistentPaths(['/path/a', '/path/b']); selector.removePath('/path/a'); expect(selector.getPersistentPaths()).toEqual(['/path/b']); expect(selector.getExternalContexts()).toEqual(['/path/b']); expect(onPersistenceChange).toHaveBeenCalledWith(['/path/b']); }); it('should not call onPersistenceChange when removing non-persistent path', () => { const onPersistenceChange = jest.fn(); selector.setOnPersistenceChange(onPersistenceChange); selector.setPersistentPaths(['/path/a']); selector.setExternalContexts(['/path/a', '/path/b']); // Clear mock calls from setPersistentPaths onPersistenceChange.mockClear(); selector.removePath('/path/b'); expect(selector.getExternalContexts()).toEqual(['/path/a']); expect(onPersistenceChange).not.toHaveBeenCalled(); }); it('should call onChange callback when removing path', () => { const onChange = jest.fn(); selector.setOnChange(onChange); selector.setExternalContexts(['/path/a', '/path/b']); selector.removePath('/path/a'); expect(onChange).toHaveBeenCalledWith(['/path/b']); }); }); describe('Edge Cases', () => { it('should handle duplicate paths in setPersistentPaths', () => { // All paths are valid for this test (fs.statSync as jest.Mock).mockReturnValue({ isDirectory: () => true }); selector.setPersistentPaths(['/path/a', '/path/a', '/path/b']); // Set uses deduplication const paths = selector.getPersistentPaths(); expect(paths.filter(p => p === '/path/a').length).toBe(1); }); it('should handle toggling same path multiple times', () => { // All paths are valid for this test (fs.statSync as jest.Mock).mockReturnValue({ isDirectory: () => true }); selector.setExternalContexts(['/path/a']); // Toggle on selector.togglePersistence('/path/a'); expect(selector.getPersistentPaths()).toContain('/path/a'); // Toggle off selector.togglePersistence('/path/a'); expect(selector.getPersistentPaths()).not.toContain('/path/a'); // Toggle on again selector.togglePersistence('/path/a'); expect(selector.getPersistentPaths()).toContain('/path/a'); }); it('should preserve persistent paths across setExternalContexts calls', () => { // All paths are valid for this test (fs.statSync as jest.Mock).mockReturnValue({ isDirectory: () => true }); selector.setPersistentPaths(['/persistent/path']); selector.setExternalContexts(['/session1']); selector.setExternalContexts(['/session2']); selector.setExternalContexts([]); // Persistent paths should remain unchanged expect(selector.getPersistentPaths()).toEqual(['/persistent/path']); }); }); describe('shortenPath', () => { it('should not shorten paths outside home directory', () => { const homeDir = os.homedir(); const outsidePath = path.join(path.parse(homeDir).root, 'tmp'); const result = (selector as any).shortenPath(outsidePath); expect(result).toBe(outsidePath); }); it('should shorten paths inside home directory', () => { const homeDir = os.homedir(); const insidePath = path.join(homeDir, 'project'); const result = (selector as any).shortenPath(insidePath); const normalizedHome = homeDir.replace(/\\/g, '/'); const normalizedInside = insidePath.replace(/\\/g, '/'); const expected = '~' + normalizedInside.slice(normalizedHome.length); expect(result).toBe(expected); }); }); describe('Path Validation', () => { beforeEach(() => { jest.clearAllMocks(); }); it('should filter out invalid paths on setPersistentPaths (app load)', () => { const onPersistenceChange = jest.fn(); selector.setOnPersistenceChange(onPersistenceChange); // Mock: /valid/path exists, /invalid/path does not (fs.statSync as jest.Mock).mockImplementation((p: string) => { if (p === '/valid/path') { return { isDirectory: () => true }; } throw new Error('ENOENT'); }); selector.setPersistentPaths(['/valid/path', '/invalid/path']); // Should only have the valid path expect(selector.getPersistentPaths()).toEqual(['/valid/path']); expect(selector.getExternalContexts()).toEqual(['/valid/path']); // Should save the updated list since invalid paths were removed expect(onPersistenceChange).toHaveBeenCalledWith(['/valid/path']); }); it('should not call onPersistenceChange when all paths are valid', () => { const onPersistenceChange = jest.fn(); selector.setOnPersistenceChange(onPersistenceChange); (fs.statSync as jest.Mock).mockReturnValue({ isDirectory: () => true }); selector.setPersistentPaths(['/path/a', '/path/b']); // All paths valid, no need to save expect(onPersistenceChange).not.toHaveBeenCalled(); expect(selector.getPersistentPaths()).toEqual(['/path/a', '/path/b']); }); it('should handle all paths being invalid', () => { const onPersistenceChange = jest.fn(); selector.setOnPersistenceChange(onPersistenceChange); (fs.statSync as jest.Mock).mockImplementation(() => { throw new Error('ENOENT'); }); selector.setPersistentPaths(['/invalid/a', '/invalid/b']); expect(selector.getPersistentPaths()).toEqual([]); expect(selector.getExternalContexts()).toEqual([]); expect(onPersistenceChange).toHaveBeenCalledWith([]); }); }); }); ================================================ FILE: tests/unit/features/chat/ui/FileContextManager.test.ts ================================================ import { createMockEl, type MockElement } from '@test/helpers/mockElement'; import { TFile } from 'obsidian'; import type { FileContextCallbacks } from '@/features/chat/ui/FileContext'; import { FileContextManager } from '@/features/chat/ui/FileContext'; import { VaultFolderCache } from '@/shared/mention/VaultMentionCache'; import type { ExternalContextFile } from '@/utils/externalContextScanner'; jest.mock('obsidian', () => { const actual = jest.requireActual('obsidian'); return { ...actual, setIcon: jest.fn(), Notice: jest.fn(), }; }); function createMockTFile(filePath: string): TFile { const file = new (TFile as any)(filePath) as TFile; (file as any).stat = { mtime: Date.now(), ctime: Date.now(), size: 0 }; return file; } let mockVaultPath = '/vault'; jest.mock('@/utils/path', () => { const actual = jest.requireActual('@/utils/path'); return { ...actual, getVaultPath: jest.fn(() => mockVaultPath), isPathWithinVault: jest.fn((candidatePath: string, vaultPath: string) => { if (!candidatePath) return false; if (!candidatePath.startsWith('/')) return true; return candidatePath.startsWith(vaultPath); }), }; }); const mockScanPaths = jest.fn<ExternalContextFile[], [string[]]>(() => []); jest.mock('@/utils/externalContextScanner', () => ({ externalContextScanner: { scanPaths: (paths: string[]) => mockScanPaths(paths), }, })); function findByClass(root: MockElement, className: string): MockElement | undefined { if (root.hasClass(className)) return root; for (const child of root.children) { const found = findByClass(child, className); if (found) return found; } return undefined; } function findAllByClass(root: MockElement, className: string): MockElement[] { const results: MockElement[] = []; const walk = (node: MockElement) => { if (node.hasClass(className)) { results.push(node); } node.children.forEach(walk); }; walk(root); return results; } function createMockApp(options: { files?: string[]; activeFilePath?: string | null; fileCacheByPath?: Map<string, any>; } = {}) { const { files = [], activeFilePath = null, fileCacheByPath = new Map() } = options; const fileMap = new Map<string, TFile>(); files.forEach((filePath) => { fileMap.set(filePath, createMockTFile(filePath)); }); return { vault: { on: jest.fn(() => ({ id: 'event-ref' })), offref: jest.fn(), getAbstractFileByPath: jest.fn((filePath: string) => fileMap.get(filePath) || null), getAllLoadedFiles: jest.fn(() => Array.from(fileMap.values())), getFiles: jest.fn(() => Array.from(fileMap.values())), }, workspace: { getActiveFile: jest.fn(() => { if (!activeFilePath) return null; return fileMap.get(activeFilePath) || createMockTFile(activeFilePath); }), getLeaf: jest.fn(() => ({ openFile: jest.fn().mockResolvedValue(undefined), })), }, metadataCache: { getFileCache: jest.fn((file: TFile) => fileCacheByPath.get(file.path) || null), }, } as any; } function createMockCallbacks(options: { externalContexts?: string[]; excludedTags?: string[]; } = {}): FileContextCallbacks { const { externalContexts = [], excludedTags = [] } = options; return { getExcludedTags: jest.fn(() => excludedTags), getExternalContexts: jest.fn(() => externalContexts), }; } describe('FileContextManager', () => { let containerEl: MockElement; let inputEl: HTMLTextAreaElement; beforeEach(() => { jest.useFakeTimers(); jest.clearAllMocks(); mockVaultPath = '/vault'; mockScanPaths.mockReturnValue([]); containerEl = createMockEl(); inputEl = { value: '', selectionStart: 0, selectionEnd: 0, focus: jest.fn(), } as unknown as HTMLTextAreaElement; }); afterEach(() => { jest.useRealTimers(); }); it('tracks current note send state per session', () => { const app = createMockApp(); const manager = new FileContextManager( app, containerEl as any, inputEl, createMockCallbacks() ); manager.setCurrentNote('notes/alpha.md'); expect(manager.shouldSendCurrentNote()).toBe(true); manager.markCurrentNoteSent(); expect(manager.shouldSendCurrentNote()).toBe(false); manager.resetForLoadedConversation(true); manager.setCurrentNote('notes/alpha.md'); expect(manager.shouldSendCurrentNote()).toBe(false); manager.resetForLoadedConversation(false); manager.setCurrentNote('notes/beta.md'); expect(manager.shouldSendCurrentNote()).toBe(true); manager.destroy(); }); it('should NOT resend current note when loading conversation with existing messages', () => { const app = createMockApp(); const manager = new FileContextManager( app, containerEl as any, inputEl, createMockCallbacks() ); // When loading a conversation that already has messages, the current note // should be marked as already sent to avoid re-sending context manager.resetForLoadedConversation(true); manager.setCurrentNote('notes/restored.md'); expect(manager.shouldSendCurrentNote()).toBe(false); manager.destroy(); }); it('should send current note when loading empty conversation', () => { const app = createMockApp(); const manager = new FileContextManager( app, containerEl as any, inputEl, createMockCallbacks() ); // When loading a conversation with no messages, the current note // should be sent with the first message manager.resetForLoadedConversation(false); manager.setCurrentNote('notes/new.md'); expect(manager.shouldSendCurrentNote()).toBe(true); manager.destroy(); }); it('renders current note chip and removes on click', () => { const app = createMockApp(); const manager = new FileContextManager( app, containerEl as any, inputEl, createMockCallbacks() ); manager.setCurrentNote('notes/chip.md'); const indicator = findByClass(containerEl, 'claudian-file-indicator'); expect(indicator).toBeDefined(); expect(indicator?.style.display).toBe('flex'); const removeEl = findByClass(containerEl, 'claudian-file-chip-remove'); expect(removeEl).toBeDefined(); removeEl!.click(); expect(manager.getCurrentNotePath()).toBeNull(); expect(indicator?.style.display).toBe('none'); manager.destroy(); }); it('auto-attaches active file unless excluded by tag', () => { const fileCacheByPath = new Map<string, any>([ ['notes/private.md', { frontmatter: { tags: ['private'] } }], ]); const app = createMockApp({ files: ['notes/private.md', 'notes/public.md'], activeFilePath: 'notes/private.md', fileCacheByPath, }); const manager = new FileContextManager( app, containerEl as any, inputEl, createMockCallbacks({ excludedTags: ['private'] }) ); manager.autoAttachActiveFile(); expect(manager.getCurrentNotePath()).toBeNull(); app.workspace.getActiveFile = jest.fn(() => createMockTFile('notes/public.md')); manager.autoAttachActiveFile(); expect(manager.getCurrentNotePath()).toBe('notes/public.md'); manager.destroy(); }); it('shows vault-relative path in @ dropdown and inserts full path on selection', () => { const app = createMockApp({ files: ['clipping/file.md'], }); const manager = new FileContextManager( app, containerEl as any, inputEl, createMockCallbacks() ); inputEl.value = '@file'; inputEl.selectionStart = 5; inputEl.selectionEnd = 5; manager.handleInputChange(); jest.advanceTimersByTime(200); const pathEl = findByClass(containerEl, 'claudian-mention-path'); expect(pathEl?.textContent).toBe('clipping/file.md'); manager.handleMentionKeydown({ key: 'Enter', preventDefault: jest.fn() } as any); // Now inserts full vault-relative path (WYSIWYG) expect(inputEl.value).toBe('@clipping/file.md '); const attached = manager.getAttachedFiles(); expect(attached.has('clipping/file.md')).toBe(true); manager.destroy(); }); it('wires getCachedVaultFolders through VaultFolderCache.getFolders', () => { const folder = { name: 'src', path: 'src' } as any; const getFoldersSpy = jest .spyOn(VaultFolderCache.prototype, 'getFolders') .mockReturnValue([folder]); const app = createMockApp(); const manager = new FileContextManager( app, containerEl as any, inputEl, createMockCallbacks() ); inputEl.value = '@src'; inputEl.selectionStart = 4; inputEl.selectionEnd = 4; manager.handleInputChange(); jest.advanceTimersByTime(200); expect(getFoldersSpy).toHaveBeenCalled(); const folderLabel = findByClass(containerEl, 'claudian-mention-name-folder'); expect(folderLabel?.textContent).toBe('@src/'); manager.destroy(); getFoldersSpy.mockRestore(); }); it('filters context files and attaches absolute path', () => { const app = createMockApp(); const manager = new FileContextManager( app, containerEl as any, inputEl, createMockCallbacks({ externalContexts: ['/external'] }) ); const contextFiles: ExternalContextFile[] = [ { path: '/external/src/app.md', name: 'app.md', relativePath: 'src/app.md', contextRoot: '/external', mtime: 1000, }, ]; mockScanPaths.mockReturnValue(contextFiles); inputEl.value = '@external/app'; inputEl.selectionStart = 13; inputEl.selectionEnd = 13; manager.handleInputChange(); jest.advanceTimersByTime(200); const nameEls = findAllByClass(containerEl, 'claudian-mention-name-context'); expect(nameEls[0]?.textContent).toBe('src/app.md'); manager.handleMentionKeydown({ key: 'Enter', preventDefault: jest.fn() } as any); // Display shows friendly name, but state stores mapping to absolute path expect(inputEl.value).toBe('@external/src/app.md '); const attached = manager.getAttachedFiles(); expect(attached.has('/external/src/app.md')).toBe(true); // Check transformation works const transformed = manager.transformContextMentions('@external/src/app.md'); expect(transformed).toBe('/external/src/app.md'); manager.destroy(); }); it('transforms pasted external context mention to absolute path without dropdown selection', () => { const app = createMockApp(); const manager = new FileContextManager( app, containerEl as any, inputEl, createMockCallbacks({ externalContexts: ['/external'] }) ); const contextFiles: ExternalContextFile[] = [ { path: '/external/src/app.md', name: 'app.md', relativePath: 'src/app.md', contextRoot: '/external', mtime: 1000, }, ]; mockScanPaths.mockReturnValue(contextFiles); const transformed = manager.transformContextMentions('Please review @external/src/app.md before merging.'); expect(transformed).toBe('Please review /external/src/app.md before merging.'); manager.destroy(); }); it('transforms pasted external context mention with spaces in path', () => { const app = createMockApp(); const manager = new FileContextManager( app, containerEl as any, inputEl, createMockCallbacks({ externalContexts: ['/external'] }) ); const contextFiles: ExternalContextFile[] = [ { path: '/external/src/my file.md', name: 'my file.md', relativePath: 'src/my file.md', contextRoot: '/external', mtime: 1000, }, ]; mockScanPaths.mockReturnValue(contextFiles); const transformed = manager.transformContextMentions('Please review @external/src/my file.md before merging.'); expect(transformed).toBe('Please review /external/src/my file.md before merging.'); manager.destroy(); }); it('keeps trailing punctuation when transforming pasted external context mention', () => { const app = createMockApp(); const manager = new FileContextManager( app, containerEl as any, inputEl, createMockCallbacks({ externalContexts: ['/external'] }) ); const contextFiles: ExternalContextFile[] = [ { path: '/external/src/app.md', name: 'app.md', relativePath: 'src/app.md', contextRoot: '/external', mtime: 1000, }, ]; mockScanPaths.mockReturnValue(contextFiles); const transformed = manager.transformContextMentions('Check @external/src/app.md, then continue.'); expect(transformed).toBe('Check /external/src/app.md, then continue.'); manager.destroy(); }); it('resolves pasted mention using disambiguated external context display name', () => { const app = createMockApp(); const manager = new FileContextManager( app, containerEl as any, inputEl, createMockCallbacks({ externalContexts: ['/work/a/external', '/work/b/external'], }) ); mockScanPaths.mockImplementation((paths: string[]) => { const contextRoot = paths[0]; if (contextRoot === '/work/a/external') { return [ { path: '/work/a/external/src/app.md', name: 'app.md', relativePath: 'src/app.md', contextRoot: '/work/a/external', mtime: 1000, }, ]; } if (contextRoot === '/work/b/external') { return [ { path: '/work/b/external/src/app.md', name: 'app.md', relativePath: 'src/app.md', contextRoot: '/work/b/external', mtime: 1000, }, ]; } return []; }); const transformed = manager.transformContextMentions('Use @a/external/src/app.md from workspace A'); expect(transformed).toBe('Use /work/a/external/src/app.md from workspace A'); manager.destroy(); }); describe('session lifecycle', () => { it('should report session not started initially', () => { const app = createMockApp(); const manager = new FileContextManager( app, containerEl as any, inputEl, createMockCallbacks() ); expect(manager.isSessionStarted()).toBe(false); manager.destroy(); }); it('should report session started after startSession', () => { const app = createMockApp(); const manager = new FileContextManager( app, containerEl as any, inputEl, createMockCallbacks() ); manager.startSession(); expect(manager.isSessionStarted()).toBe(true); manager.destroy(); }); it('should reset state for new conversation', () => { const app = createMockApp(); const manager = new FileContextManager( app, containerEl as any, inputEl, createMockCallbacks() ); manager.setCurrentNote('notes/test.md'); manager.startSession(); manager.resetForNewConversation(); expect(manager.getCurrentNotePath()).toBeNull(); expect(manager.isSessionStarted()).toBe(false); manager.destroy(); }); }); describe('handleFileOpen', () => { it('should update current note when session not started', () => { const app = createMockApp({ files: ['notes/new.md'] }); const manager = new FileContextManager( app, containerEl as any, inputEl, createMockCallbacks() ); const file = createMockTFile('notes/new.md'); manager.handleFileOpen(file); expect(manager.getCurrentNotePath()).toBe('notes/new.md'); manager.destroy(); }); it('should clear attachments when opening a new file before session starts', () => { const app = createMockApp({ files: ['notes/a.md', 'notes/b.md'] }); const manager = new FileContextManager( app, containerEl as any, inputEl, createMockCallbacks() ); manager.setCurrentNote('notes/a.md'); const fileB = createMockTFile('notes/b.md'); manager.handleFileOpen(fileB); expect(manager.getCurrentNotePath()).toBe('notes/b.md'); manager.destroy(); }); it('should not update current note when session is started', () => { const app = createMockApp({ files: ['notes/a.md'] }); const manager = new FileContextManager( app, containerEl as any, inputEl, createMockCallbacks() ); manager.setCurrentNote('notes/a.md'); manager.startSession(); const fileB = createMockTFile('notes/b.md'); manager.handleFileOpen(fileB); // Should NOT update because session is started expect(manager.getCurrentNotePath()).toBe('notes/a.md'); manager.destroy(); }); it('should not attach file with excluded tag', () => { const fileCacheByPath = new Map<string, any>([ ['notes/secret.md', { frontmatter: { tags: ['private'] } }], ]); const app = createMockApp({ files: ['notes/secret.md'], fileCacheByPath }); const manager = new FileContextManager( app, containerEl as any, inputEl, createMockCallbacks({ excludedTags: ['private'] }) ); const file = createMockTFile('notes/secret.md'); manager.handleFileOpen(file); expect(manager.getCurrentNotePath()).toBeNull(); manager.destroy(); }); }); describe('file rename handling', () => { it('should update current note path when file is renamed', () => { const app = createMockApp({ files: ['notes/old.md', 'notes/new.md'] }); const manager = new FileContextManager( app, containerEl as any, inputEl, createMockCallbacks() ); manager.setCurrentNote('notes/old.md'); expect(manager.getCurrentNotePath()).toBe('notes/old.md'); // Simulate vault rename event const renameHandler = (app.vault.on as jest.Mock).mock.calls .find((c: any[]) => c[0] === 'rename')?.[1]; expect(renameHandler).toBeDefined(); renameHandler(createMockTFile('notes/new.md'), 'notes/old.md'); expect(manager.getCurrentNotePath()).toBe('notes/new.md'); manager.destroy(); }); it('should update attached files when renamed', () => { const app = createMockApp({ files: ['notes/old.md'] }); const manager = new FileContextManager( app, containerEl as any, inputEl, createMockCallbacks() ); manager.setCurrentNote('notes/old.md'); const renameHandler = (app.vault.on as jest.Mock).mock.calls .find((c: any[]) => c[0] === 'rename')?.[1]; renameHandler(createMockTFile('notes/new.md'), 'notes/old.md'); expect(manager.getAttachedFiles().has('notes/new.md')).toBe(true); expect(manager.getAttachedFiles().has('notes/old.md')).toBe(false); manager.destroy(); }); it('should not update if renamed file is not attached', () => { const app = createMockApp({ files: ['notes/a.md', 'notes/unrelated.md'] }); const manager = new FileContextManager( app, containerEl as any, inputEl, createMockCallbacks() ); manager.setCurrentNote('notes/a.md'); const renameHandler = (app.vault.on as jest.Mock).mock.calls .find((c: any[]) => c[0] === 'rename')?.[1]; renameHandler(createMockTFile('notes/renamed.md'), 'notes/unrelated.md'); // Current note should remain unchanged expect(manager.getCurrentNotePath()).toBe('notes/a.md'); manager.destroy(); }); }); describe('file delete handling', () => { it('should clear current note when file is deleted', () => { const app = createMockApp({ files: ['notes/doomed.md'] }); const manager = new FileContextManager( app, containerEl as any, inputEl, createMockCallbacks() ); manager.setCurrentNote('notes/doomed.md'); const deleteHandler = (app.vault.on as jest.Mock).mock.calls .find((c: any[]) => c[0] === 'delete')?.[1]; expect(deleteHandler).toBeDefined(); deleteHandler(createMockTFile('notes/doomed.md')); expect(manager.getCurrentNotePath()).toBeNull(); manager.destroy(); }); it('should remove deleted file from attached files', () => { const app = createMockApp({ files: ['notes/a.md'] }); const manager = new FileContextManager( app, containerEl as any, inputEl, createMockCallbacks() ); manager.setCurrentNote('notes/a.md'); expect(manager.getAttachedFiles().has('notes/a.md')).toBe(true); const deleteHandler = (app.vault.on as jest.Mock).mock.calls .find((c: any[]) => c[0] === 'delete')?.[1]; deleteHandler(createMockTFile('notes/a.md')); expect(manager.getAttachedFiles().has('notes/a.md')).toBe(false); manager.destroy(); }); it('should not update if deleted file is not attached', () => { const app = createMockApp({ files: ['notes/a.md', 'notes/other.md'] }); const manager = new FileContextManager( app, containerEl as any, inputEl, createMockCallbacks() ); manager.setCurrentNote('notes/a.md'); const deleteHandler = (app.vault.on as jest.Mock).mock.calls .find((c: any[]) => c[0] === 'delete')?.[1]; deleteHandler(createMockTFile('notes/other.md')); expect(manager.getCurrentNotePath()).toBe('notes/a.md'); manager.destroy(); }); }); describe('hasExcludedTag edge cases', () => { it('should exclude file with inline tags (not just frontmatter)', () => { const fileCacheByPath = new Map<string, any>([ ['notes/tagged.md', { tags: [{ tag: '#system', position: { start: { line: 5, col: 0 }, end: { line: 5, col: 7 } } }], }], ]); const app = createMockApp({ files: ['notes/tagged.md'], activeFilePath: 'notes/tagged.md', fileCacheByPath, }); const manager = new FileContextManager( app, containerEl as any, inputEl, createMockCallbacks({ excludedTags: ['system'] }) ); manager.autoAttachActiveFile(); expect(manager.getCurrentNotePath()).toBeNull(); manager.destroy(); }); it('should exclude file with string frontmatter tag (not array)', () => { const fileCacheByPath = new Map<string, any>([ ['notes/single-tag.md', { frontmatter: { tags: 'private' } }], ]); const app = createMockApp({ files: ['notes/single-tag.md'], activeFilePath: 'notes/single-tag.md', fileCacheByPath, }); const manager = new FileContextManager( app, containerEl as any, inputEl, createMockCallbacks({ excludedTags: ['private'] }) ); manager.autoAttachActiveFile(); expect(manager.getCurrentNotePath()).toBeNull(); manager.destroy(); }); it('should handle tags with # prefix in cache', () => { const fileCacheByPath = new Map<string, any>([ ['notes/hash-tag.md', { frontmatter: { tags: ['#draft'] } }], ]); const app = createMockApp({ files: ['notes/hash-tag.md'], activeFilePath: 'notes/hash-tag.md', fileCacheByPath, }); const manager = new FileContextManager( app, containerEl as any, inputEl, createMockCallbacks({ excludedTags: ['draft'] }) ); manager.autoAttachActiveFile(); expect(manager.getCurrentNotePath()).toBeNull(); manager.destroy(); }); }); describe('cache dirty marking', () => { it('should not throw when marking file cache dirty', () => { const app = createMockApp(); const manager = new FileContextManager( app, containerEl as any, inputEl, createMockCallbacks() ); expect(() => manager.markFileCacheDirty()).not.toThrow(); manager.destroy(); }); it('should not throw when marking folder cache dirty', () => { const app = createMockApp(); const manager = new FileContextManager( app, containerEl as any, inputEl, createMockCallbacks() ); expect(() => manager.markFolderCacheDirty()).not.toThrow(); manager.destroy(); }); }); describe('MCP and agent support', () => { it('should expose getMentionedMcpServers', () => { const app = createMockApp(); const manager = new FileContextManager( app, containerEl as any, inputEl, createMockCallbacks() ); const servers = manager.getMentionedMcpServers(); expect(servers).toBeInstanceOf(Set); expect(servers.size).toBe(0); manager.destroy(); }); it('should clear MCP mentions', () => { const app = createMockApp(); const manager = new FileContextManager( app, containerEl as any, inputEl, createMockCallbacks() ); // Should not throw manager.clearMcpMentions(); expect(manager.getMentionedMcpServers().size).toBe(0); manager.destroy(); }); it('should set onMcpMentionChange callback without error', () => { const app = createMockApp(); const manager = new FileContextManager( app, containerEl as any, inputEl, createMockCallbacks() ); const callback = jest.fn(); expect(() => manager.setOnMcpMentionChange(callback)).not.toThrow(); manager.destroy(); }); it('should setMcpManager without error', () => { const app = createMockApp(); const manager = new FileContextManager( app, containerEl as any, inputEl, createMockCallbacks() ); expect(() => manager.setMcpManager(null)).not.toThrow(); manager.destroy(); }); it('should setAgentService without error', () => { const app = createMockApp(); const manager = new FileContextManager( app, containerEl as any, inputEl, createMockCallbacks() ); expect(() => manager.setAgentService(null)).not.toThrow(); manager.destroy(); }); it('should preScanExternalContexts without error', () => { const app = createMockApp(); const manager = new FileContextManager( app, containerEl as any, inputEl, createMockCallbacks() ); expect(() => manager.preScanExternalContexts()).not.toThrow(); manager.destroy(); }); }); describe('mention dropdown delegation', () => { it('should report isMentionDropdownVisible as false initially', () => { const app = createMockApp(); const manager = new FileContextManager( app, containerEl as any, inputEl, createMockCallbacks() ); expect(manager.isMentionDropdownVisible()).toBe(false); manager.destroy(); }); it('should hideMentionDropdown without error', () => { const app = createMockApp(); const manager = new FileContextManager( app, containerEl as any, inputEl, createMockCallbacks() ); expect(() => manager.hideMentionDropdown()).not.toThrow(); manager.destroy(); }); it('should containsElement return false for unrelated node', () => { const app = createMockApp(); const manager = new FileContextManager( app, containerEl as any, inputEl, createMockCallbacks() ); const unrelatedNode = createMockEl() as unknown as Node; expect(manager.containsElement(unrelatedNode)).toBe(false); manager.destroy(); }); }); describe('destroy', () => { it('should clean up event listeners', () => { const app = createMockApp(); const manager = new FileContextManager( app, containerEl as any, inputEl, createMockCallbacks() ); manager.destroy(); expect(app.vault.offref).toHaveBeenCalledTimes(2); }); }); describe('onOpenFile callback', () => { it('should show Notice when file not found in vault', async () => { const { Notice: NoticeMock } = jest.requireMock('obsidian'); const app = createMockApp(); const manager = new FileContextManager( app, containerEl as any, inputEl, createMockCallbacks() ); const chipsView = (manager as any).chipsView; const openCallback = chipsView.callbacks.onOpenFile; expect(openCallback).toBeDefined(); await openCallback('notes/missing.md'); expect(NoticeMock).toHaveBeenCalledWith(expect.stringContaining('Could not open file')); manager.destroy(); }); }); }); ================================================ FILE: tests/unit/features/chat/ui/ImageContext.test.ts ================================================ import { createMockEl } from '@test/helpers/mockElement'; import { Notice } from 'obsidian'; import type { ImageAttachment } from '@/core/types'; import { ImageContextManager } from '@/features/chat/ui/ImageContext'; jest.mock('obsidian', () => ({ Notice: jest.fn(), })); // Mock document.createElementNS for SVG elements created in setupDragAndDrop const mockSvgElement = () => { const el = createMockEl('svg'); el.appendChild = jest.fn(); return el; }; beforeAll(() => { if (typeof globalThis.document === 'undefined') { (globalThis as any).document = {}; } (globalThis.document as any).createElementNS = jest.fn(() => mockSvgElement()); }); function createMockCallbacks() { return { onImagesChanged: jest.fn(), }; } function createContainerWithInputWrapper(): { container: any; inputWrapper: any } { const container = createMockEl(); const inputWrapper = container.createDiv({ cls: 'claudian-input-wrapper' }); return { container, inputWrapper }; } function createMockTextArea(): any { const el = createMockEl('textarea'); el.value = ''; return el; } function createImageAttachment(overrides: Partial<ImageAttachment> = {}): ImageAttachment { return { id: 'img-test-1', name: 'test.png', mediaType: 'image/png', data: 'dGVzdA==', size: 1024, source: 'paste', ...overrides, }; } describe('ImageContextManager', () => { let container: any; let inputEl: any; let callbacks: ReturnType<typeof createMockCallbacks>; let manager: ImageContextManager; beforeEach(() => { jest.clearAllMocks(); const { container: c } = createContainerWithInputWrapper(); container = c; inputEl = createMockTextArea(); callbacks = createMockCallbacks(); manager = new ImageContextManager(container, inputEl, callbacks); }); describe('initial state', () => { it('should start with no images', () => { expect(manager.hasImages()).toBe(false); expect(manager.getAttachedImages()).toEqual([]); }); }); describe('getAttachedImages', () => { it('should return empty array when no images attached', () => { expect(manager.getAttachedImages()).toEqual([]); }); it('should return all attached images after setImages', () => { const images = [ createImageAttachment({ id: 'img-1', name: 'a.png' }), createImageAttachment({ id: 'img-2', name: 'b.jpg' }), ]; manager.setImages(images); const result = manager.getAttachedImages(); expect(result).toHaveLength(2); expect(result[0].id).toBe('img-1'); expect(result[1].id).toBe('img-2'); }); }); describe('hasImages', () => { it('should return false when no images', () => { expect(manager.hasImages()).toBe(false); }); it('should return true after setting images', () => { manager.setImages([createImageAttachment()]); expect(manager.hasImages()).toBe(true); }); it('should return false after clearing images', () => { manager.setImages([createImageAttachment()]); manager.clearImages(); expect(manager.hasImages()).toBe(false); }); }); describe('clearImages', () => { it('should remove all images', () => { manager.setImages([ createImageAttachment({ id: 'img-1' }), createImageAttachment({ id: 'img-2' }), ]); expect(manager.hasImages()).toBe(true); manager.clearImages(); expect(manager.hasImages()).toBe(false); expect(manager.getAttachedImages()).toEqual([]); }); it('should invoke onImagesChanged callback', () => { manager.setImages([createImageAttachment()]); callbacks.onImagesChanged.mockClear(); manager.clearImages(); expect(callbacks.onImagesChanged).toHaveBeenCalledTimes(1); }); }); describe('setImages', () => { it('should replace existing images', () => { manager.setImages([createImageAttachment({ id: 'old' })]); const newImages = [ createImageAttachment({ id: 'new-1', name: 'new1.png' }), createImageAttachment({ id: 'new-2', name: 'new2.jpg' }), ]; manager.setImages(newImages); const result = manager.getAttachedImages(); expect(result).toHaveLength(2); expect(result[0].id).toBe('new-1'); expect(result[1].id).toBe('new-2'); }); it('should invoke onImagesChanged callback', () => { manager.setImages([createImageAttachment()]); expect(callbacks.onImagesChanged).toHaveBeenCalledTimes(1); }); it('should handle empty array', () => { manager.setImages([createImageAttachment()]); manager.setImages([]); expect(manager.hasImages()).toBe(false); expect(manager.getAttachedImages()).toEqual([]); }); it('should deduplicate by id (last wins)', () => { const images = [ createImageAttachment({ id: 'same', name: 'first.png' }), createImageAttachment({ id: 'same', name: 'second.png' }), ]; manager.setImages(images); const result = manager.getAttachedImages(); expect(result).toHaveLength(1); expect(result[0].name).toBe('second.png'); }); }); describe('constructor with previewContainerEl', () => { it('should use previewContainerEl when provided', () => { const previewContainer = createMockEl(); const { container: c } = createContainerWithInputWrapper(); const input = createMockTextArea(); const cb = createMockCallbacks(); const mgr = new ImageContextManager(c, input, cb, previewContainer); expect(mgr).toBeDefined(); const previewEl = previewContainer.querySelector('.claudian-image-preview'); expect(previewEl).not.toBeNull(); }); it('should insert image preview before file indicator if present', () => { const previewContainer = createMockEl(); const fileIndicator = previewContainer.createDiv({ cls: 'claudian-file-indicator' }); // Patch parentElement to match check in constructor Object.defineProperty(fileIndicator, 'parentElement', { get: () => previewContainer }); const { container: c } = createContainerWithInputWrapper(); const input = createMockTextArea(); const cb = createMockCallbacks(); new ImageContextManager(c, input, cb, previewContainer); const children = previewContainer.children; const fileIndicatorIdx = children.indexOf(fileIndicator); const previewIdx = children.findIndex((el: any) => el.hasClass?.('claudian-image-preview')); expect(previewIdx).toBeLessThan(fileIndicatorIdx); }); }); }); // Test private helper methods via their observable effects. // We access privates through any cast, matching the project's pattern. describe('ImageContextManager - Private Helpers', () => { let manager: any; beforeEach(() => { jest.clearAllMocks(); const { container } = createContainerWithInputWrapper(); const inputEl = createMockTextArea(); const callbacks = createMockCallbacks(); manager = new ImageContextManager(container, inputEl, callbacks); }); describe('truncateName', () => { it('should return name unchanged when short enough', () => { expect(manager['truncateName']('test.png', 20)).toBe('test.png'); }); it('should truncate long names preserving extension', () => { const longName = 'this-is-a-very-long-filename.png'; const result = manager['truncateName'](longName, 20); expect(result.endsWith('.png')).toBe(true); expect(result).toContain('...'); expect(result.length).toBeLessThanOrEqual(20); }); it('should handle name exactly at max length', () => { const name = '12345678901234567890'; // 20 chars, no extension expect(manager['truncateName'](name, 20)).toBe(name); }); }); describe('formatSize', () => { it('should format bytes', () => { expect(manager['formatSize'](500)).toBe('500 B'); }); it('should format kilobytes', () => { expect(manager['formatSize'](2048)).toBe('2.0 KB'); }); it('should format megabytes', () => { expect(manager['formatSize'](5 * 1024 * 1024)).toBe('5.0 MB'); }); it('should format fractional KB', () => { expect(manager['formatSize'](1536)).toBe('1.5 KB'); }); it('should format 0 bytes', () => { expect(manager['formatSize'](0)).toBe('0 B'); }); }); describe('getMediaType', () => { it('should return correct media type for .jpg', () => { expect(manager['getMediaType']('photo.jpg')).toBe('image/jpeg'); }); it('should return correct media type for .jpeg', () => { expect(manager['getMediaType']('photo.jpeg')).toBe('image/jpeg'); }); it('should return correct media type for .png', () => { expect(manager['getMediaType']('image.png')).toBe('image/png'); }); it('should return correct media type for .gif', () => { expect(manager['getMediaType']('animation.gif')).toBe('image/gif'); }); it('should return correct media type for .webp', () => { expect(manager['getMediaType']('photo.webp')).toBe('image/webp'); }); it('should return null for unsupported extension', () => { expect(manager['getMediaType']('document.pdf')).toBeNull(); }); it('should return null for no extension', () => { expect(manager['getMediaType']('noextension')).toBeNull(); }); it('should handle uppercase extensions', () => { expect(manager['getMediaType']('PHOTO.JPG')).toBe('image/jpeg'); }); it('should handle mixed case extensions', () => { expect(manager['getMediaType']('image.Png')).toBe('image/png'); }); }); describe('isImageFile', () => { it('should return true for valid image file', () => { const file = { type: 'image/png', name: 'test.png' } as File; expect(manager['isImageFile'](file)).toBe(true); }); it('should return false for non-image file', () => { const file = { type: 'application/pdf', name: 'doc.pdf' } as File; expect(manager['isImageFile'](file)).toBe(false); }); it('should return false for image type but unsupported extension', () => { const file = { type: 'image/bmp', name: 'test.bmp' } as File; expect(manager['isImageFile'](file)).toBe(false); }); }); describe('generateId', () => { it('should generate unique IDs', () => { const id1 = manager['generateId'](); const id2 = manager['generateId'](); expect(id1).not.toBe(id2); }); it('should start with img- prefix', () => { const id = manager['generateId'](); expect(id.startsWith('img-')).toBe(true); }); }); describe('notifyImageError', () => { it('should create a Notice with the message', () => { manager['notifyImageError']('Test error'); expect(Notice).toHaveBeenCalledWith('Test error'); }); it('should append file not found for ENOENT error', () => { const error = new Error('ENOENT: no such file or directory'); manager['notifyImageError']('Failed to load image.', error); expect(Notice).toHaveBeenCalledWith('Failed to load image. (File not found)'); }); it('should append permission denied for EACCES error', () => { const error = new Error('EACCES: permission denied'); manager['notifyImageError']('Failed to load image.', error); expect(Notice).toHaveBeenCalledWith('Failed to load image. (Permission denied)'); }); it('should use original message for non-Error objects', () => { manager['notifyImageError']('Test error', 'not an error object'); expect(Notice).toHaveBeenCalledWith('Test error'); }); it('should use original message for errors without recognized patterns', () => { const error = new Error('Some other error'); manager['notifyImageError']('Test error', error); expect(Notice).toHaveBeenCalledWith('Test error'); }); }); describe('addImageFromFile', () => { it('should reject files exceeding size limit', async () => { const file = { name: 'huge.png', type: 'image/png', size: 6 * 1024 * 1024, // 6MB > 5MB limit arrayBuffer: jest.fn(), } as unknown as File; const result = await manager['addImageFromFile'](file, 'paste'); expect(result).toBe(false); expect(Notice).toHaveBeenCalledWith(expect.stringContaining('limit')); }); it('should reject files with unsupported media type', async () => { const file = { name: 'test.bmp', type: '', size: 1024, arrayBuffer: jest.fn(), } as unknown as File; const result = await manager['addImageFromFile'](file, 'drop'); expect(result).toBe(false); expect(Notice).toHaveBeenCalledWith('Unsupported image type.'); }); it('should add valid image file and invoke callback', async () => { const mockBuffer = new ArrayBuffer(4); const file = { name: 'test.png', type: 'image/png', size: 1024, arrayBuffer: jest.fn().mockResolvedValue(mockBuffer), } as unknown as File; const callbacks = createMockCallbacks(); const { container } = createContainerWithInputWrapper(); const inputEl = createMockTextArea(); const mgr: any = new ImageContextManager(container, inputEl, callbacks); const result = await mgr['addImageFromFile'](file, 'paste'); expect(result).toBe(true); expect(mgr.hasImages()).toBe(true); expect(callbacks.onImagesChanged).toHaveBeenCalled(); const images = mgr.getAttachedImages(); expect(images).toHaveLength(1); expect(images[0].name).toBe('test.png'); expect(images[0].mediaType).toBe('image/png'); expect(images[0].size).toBe(1024); expect(images[0].source).toBe('paste'); }); it('should handle arrayBuffer failure gracefully', async () => { const file = { name: 'test.png', type: 'image/png', size: 1024, arrayBuffer: jest.fn().mockRejectedValue(new Error('Read failed')), } as unknown as File; const result = await manager['addImageFromFile'](file, 'drop'); expect(result).toBe(false); expect(Notice).toHaveBeenCalledWith('Failed to attach image.'); }); it('should generate default name when file has no name', async () => { const mockBuffer = new ArrayBuffer(4); const file = { name: '', type: 'image/png', size: 512, arrayBuffer: jest.fn().mockResolvedValue(mockBuffer), } as unknown as File; const callbacks = createMockCallbacks(); const { container } = createContainerWithInputWrapper(); const inputEl = createMockTextArea(); const mgr: any = new ImageContextManager(container, inputEl, callbacks); await mgr['addImageFromFile'](file, 'paste'); const images = mgr.getAttachedImages(); expect(images[0].name).toMatch(/^image-\d+\.png$/); }); it('should use file.type as fallback media type when getMediaType returns null', async () => { const mockBuffer = new ArrayBuffer(4); // File with .svg extension (not in IMAGE_EXTENSIONS), but valid image/* type const file = { name: 'icon.svg', type: 'image/svg+xml', size: 512, arrayBuffer: jest.fn().mockResolvedValue(mockBuffer), } as unknown as File; // The getMediaType for .svg returns null, so file.type is used as fallback const callbacks = createMockCallbacks(); const { container } = createContainerWithInputWrapper(); const inputEl = createMockTextArea(); const mgr: any = new ImageContextManager(container, inputEl, callbacks); const result = await mgr['addImageFromFile'](file, 'paste'); expect(result).toBe(true); const images = mgr.getAttachedImages(); expect(images[0].mediaType).toBe('image/svg+xml'); }); }); describe('Drag and Drop handlers', () => { it('handleDragEnter should show overlay when dragging files', () => { const event = { preventDefault: jest.fn(), stopPropagation: jest.fn(), dataTransfer: { types: ['Files'] }, }; manager['handleDragEnter'](event as any); expect(event.preventDefault).toHaveBeenCalled(); expect(event.stopPropagation).toHaveBeenCalled(); expect(manager['dropOverlay']?.hasClass('visible')).toBe(true); }); it('handleDragEnter should not show overlay when not dragging files', () => { const event = { preventDefault: jest.fn(), stopPropagation: jest.fn(), dataTransfer: { types: ['text/plain'] }, }; manager['handleDragEnter'](event as any); expect(manager['dropOverlay']?.hasClass('visible')).toBeFalsy(); }); it('handleDragOver should prevent default', () => { const event = { preventDefault: jest.fn(), stopPropagation: jest.fn(), }; manager['handleDragOver'](event as any); expect(event.preventDefault).toHaveBeenCalled(); expect(event.stopPropagation).toHaveBeenCalled(); }); it('handleDragLeave should hide overlay when cursor leaves input wrapper', () => { // Show overlay first manager['dropOverlay']?.addClass('visible'); const event = { preventDefault: jest.fn(), stopPropagation: jest.fn(), clientX: -1, // Outside bounds clientY: -1, }; manager['handleDragLeave'](event as any); expect(event.preventDefault).toHaveBeenCalled(); expect(manager['dropOverlay']?.hasClass('visible')).toBe(false); }); it('handleDrop should hide overlay and process image files', async () => { manager['dropOverlay']?.addClass('visible'); const addImageSpy = jest.spyOn(manager as any, 'addImageFromFile').mockResolvedValue(true); const mockFile = { type: 'image/png', name: 'test.png', size: 1024 }; const event = { preventDefault: jest.fn(), stopPropagation: jest.fn(), dataTransfer: { files: { length: 1, 0: mockFile, [Symbol.iterator]: function* () { yield mockFile; } } }, }; await manager['handleDrop'](event as any); expect(event.preventDefault).toHaveBeenCalled(); expect(manager['dropOverlay']?.hasClass('visible')).toBe(false); expect(addImageSpy).toHaveBeenCalledWith(mockFile, 'drop'); addImageSpy.mockRestore(); }); it('handleDrop should skip non-image files', async () => { const addImageSpy = jest.spyOn(manager as any, 'addImageFromFile').mockResolvedValue(true); jest.spyOn(manager as any, 'isImageFile').mockReturnValue(false); const mockFile = { type: 'application/pdf', name: 'doc.pdf', size: 1024 }; const event = { preventDefault: jest.fn(), stopPropagation: jest.fn(), dataTransfer: { files: { length: 1, 0: mockFile } }, }; await manager['handleDrop'](event as any); expect(addImageSpy).not.toHaveBeenCalled(); addImageSpy.mockRestore(); }); it('handleDrop should handle no files gracefully', async () => { const event = { preventDefault: jest.fn(), stopPropagation: jest.fn(), dataTransfer: { files: undefined }, }; await manager['handleDrop'](event as any); // Should not throw and still call preventDefault expect(event.preventDefault).toHaveBeenCalled(); }); }); describe('Paste handler', () => { it('setupPasteHandler should register paste event on inputEl', () => { const input = manager['inputEl']; expect(input.getEventListenerCount('paste')).toBe(1); }); it('paste handler should process image items', async () => { const addImageSpy = jest.spyOn(manager as any, 'addImageFromFile').mockResolvedValue(true); const mockFile = { name: 'pasted.png', type: 'image/png', size: 1024 }; const pasteEvent = { type: 'paste', preventDefault: jest.fn(), clipboardData: { items: { length: 1, 0: { type: 'image/png', getAsFile: () => mockFile, }, }, }, }; manager['inputEl'].dispatchEvent(pasteEvent); // Wait for async paste handler await new Promise(resolve => setTimeout(resolve, 0)); expect(pasteEvent.preventDefault).toHaveBeenCalled(); expect(addImageSpy).toHaveBeenCalledWith(mockFile, 'paste'); addImageSpy.mockRestore(); }); it('paste handler should ignore non-image items', async () => { const addImageSpy = jest.spyOn(manager as any, 'addImageFromFile').mockResolvedValue(true); const pasteEvent = { type: 'paste', preventDefault: jest.fn(), clipboardData: { items: { length: 1, 0: { type: 'text/plain', getAsFile: () => null, }, }, }, }; manager['inputEl'].dispatchEvent(pasteEvent); await new Promise(resolve => setTimeout(resolve, 0)); expect(pasteEvent.preventDefault).not.toHaveBeenCalled(); expect(addImageSpy).not.toHaveBeenCalled(); addImageSpy.mockRestore(); }); it('paste handler should handle null clipboardData', async () => { const addImageSpy = jest.spyOn(manager as any, 'addImageFromFile').mockResolvedValue(true); const pasteEvent = { type: 'paste', preventDefault: jest.fn(), clipboardData: null, }; manager['inputEl'].dispatchEvent(pasteEvent); await new Promise(resolve => setTimeout(resolve, 0)); expect(addImageSpy).not.toHaveBeenCalled(); addImageSpy.mockRestore(); }); }); describe('Image preview rendering', () => { it('updateImagePreview should hide preview when no images', () => { manager['updateImagePreview'](); expect(manager['imagePreviewEl'].style.display).toBe('none'); }); it('updateImagePreview should show preview when images exist', () => { manager.setImages([createImageAttachment()]); expect(manager['imagePreviewEl'].style.display).toBe('flex'); }); it('renderImagePreview should create chip with thumbnail, info, and remove button', () => { manager.setImages([createImageAttachment({ id: 'img-1', name: 'photo.png', size: 2048 })]); const previewEl = manager['imagePreviewEl']; expect(previewEl.children.length).toBe(1); const chipEl = previewEl.children[0]; expect(chipEl.hasClass('claudian-image-chip')).toBe(true); const thumbEl = chipEl.querySelector('.claudian-image-thumb'); expect(thumbEl).not.toBeNull(); const infoEl = chipEl.querySelector('.claudian-image-info'); expect(infoEl).not.toBeNull(); const removeEl = chipEl.querySelector('.claudian-image-remove'); expect(removeEl).not.toBeNull(); }); it('remove button should delete the image and update preview', () => { const cb = createMockCallbacks(); const { container } = createContainerWithInputWrapper(); const input = createMockTextArea(); const mgr: any = new ImageContextManager(container, input, cb); mgr.setImages([ createImageAttachment({ id: 'img-1', name: 'a.png' }), createImageAttachment({ id: 'img-2', name: 'b.png' }), ]); expect(mgr.getAttachedImages()).toHaveLength(2); cb.onImagesChanged.mockClear(); const firstChip = mgr['imagePreviewEl'].children[0]; const removeEl = firstChip.querySelector('.claudian-image-remove'); removeEl.dispatchEvent({ type: 'click', stopPropagation: jest.fn() }); expect(mgr.getAttachedImages()).toHaveLength(1); expect(mgr.getAttachedImages()[0].id).toBe('img-2'); expect(cb.onImagesChanged).toHaveBeenCalled(); }); }); describe('showFullImage', () => { let origDocument: typeof globalThis.document; let overlayEl: any; let mockBody: any; let addEventSpy: jest.Mock; let removeEventSpy: jest.Mock; beforeEach(() => { overlayEl = createMockEl(); addEventSpy = jest.fn(); removeEventSpy = jest.fn(); mockBody = { createDiv: jest.fn().mockReturnValue(overlayEl) }; origDocument = globalThis.document; (globalThis as any).document = { body: mockBody, addEventListener: addEventSpy, removeEventListener: removeEventSpy, createElementNS: jest.fn(() => mockSvgElement()), }; }); afterEach(() => { (globalThis as any).document = origDocument; }); it('should create modal overlay with image', () => { const image = createImageAttachment({ name: 'test.png', mediaType: 'image/png', data: 'abc123' }); manager['showFullImage'](image); expect(mockBody.createDiv).toHaveBeenCalledWith({ cls: 'claudian-image-modal-overlay' }); }); it('should register Escape key handler and close button', () => { const image = createImageAttachment(); manager['showFullImage'](image); expect(addEventSpy).toHaveBeenCalledWith('keydown', expect.any(Function)); const escHandler = addEventSpy.mock.calls[0][1]; escHandler({ key: 'Escape' }); expect(removeEventSpy).toHaveBeenCalledWith('keydown', escHandler); }); it('should close modal when clicking on overlay background', () => { const image = createImageAttachment(); manager['showFullImage'](image); const clickHandler = overlayEl._eventListeners.get('click')?.[0]; expect(clickHandler).toBeDefined(); clickHandler({ target: overlayEl }); expect(removeEventSpy).toHaveBeenCalled(); }); }); describe('fileToBase64', () => { it('should convert file to base64 string', async () => { const textEncoder = new TextEncoder(); const bytes = textEncoder.encode('hello'); const mockBuffer = bytes.buffer; const file = { arrayBuffer: jest.fn().mockResolvedValue(mockBuffer), } as unknown as File; const result = await manager['fileToBase64'](file); expect(typeof result).toBe('string'); expect(result.length).toBeGreaterThan(0); // Verify it's valid base64 const decoded = Buffer.from(result, 'base64').toString(); expect(decoded).toBe('hello'); }); }); }); ================================================ FILE: tests/unit/features/chat/ui/InputToolbar.test.ts ================================================ import { createMockEl } from '@test/helpers/mockElement'; import type { UsageInfo } from '@/core/types'; import { ContextUsageMeter, createInputToolbar, McpServerSelector, ModelSelector, PermissionToggle, ThinkingBudgetSelector, } from '@/features/chat/ui/InputToolbar'; jest.mock('obsidian', () => ({ Notice: jest.fn(), setIcon: jest.fn(), })); function makeUsage(overrides: Partial<UsageInfo> = {}): UsageInfo { return { inputTokens: 0, cacheCreationInputTokens: 0, cacheReadInputTokens: 0, contextWindow: 200000, contextTokens: 0, percentage: 0, ...overrides, }; } function createMockCallbacks(overrides: Record<string, any> = {}) { return { onModelChange: jest.fn().mockResolvedValue(undefined), onThinkingBudgetChange: jest.fn().mockResolvedValue(undefined), onEffortLevelChange: jest.fn().mockResolvedValue(undefined), onPermissionModeChange: jest.fn().mockResolvedValue(undefined), getSettings: jest.fn().mockReturnValue({ model: 'sonnet', thinkingBudget: 'low', effortLevel: 'high', permissionMode: 'normal', enableOpus1M: false, enableSonnet1M: false, }), getEnvironmentVariables: jest.fn().mockReturnValue(''), ...overrides, }; } describe('ModelSelector', () => { let parentEl: any; let callbacks: ReturnType<typeof createMockCallbacks>; let selector: ModelSelector; beforeEach(() => { jest.clearAllMocks(); parentEl = createMockEl(); callbacks = createMockCallbacks(); selector = new ModelSelector(parentEl, callbacks); }); it('should create a container with model-selector class', () => { const container = parentEl.querySelector('.claudian-model-selector'); expect(container).not.toBeNull(); }); it('should display current model label', () => { // Default model is 'sonnet' which maps to 'Sonnet' const btn = parentEl.querySelector('.claudian-model-btn'); expect(btn).not.toBeNull(); const label = btn?.querySelector('.claudian-model-label'); expect(label).not.toBeNull(); expect(label?.textContent).toBe('Sonnet'); }); it('should display first model when current model not found', () => { callbacks.getSettings.mockReturnValue({ model: 'nonexistent', thinkingBudget: 'low', permissionMode: 'normal', enableOpus1M: false, enableSonnet1M: false, }); selector.updateDisplay(); const label = parentEl.querySelector('.claudian-model-label'); expect(label?.textContent).toBe('Haiku'); }); it('should render model options in reverse order', () => { const dropdown = parentEl.querySelector('.claudian-model-dropdown'); expect(dropdown).not.toBeNull(); // DEFAULT_CLAUDE_MODELS is [haiku, sonnet, opus] -> reversed is [opus, sonnet, haiku] const options = dropdown?.children || []; expect(options.length).toBe(3); // Text is in child span, check first child's textContent expect(options[0]?.children[0]?.textContent).toBe('Opus'); expect(options[1]?.children[0]?.textContent).toBe('Sonnet'); expect(options[2]?.children[0]?.textContent).toBe('Haiku'); }); it('should mark current model as selected', () => { const dropdown = parentEl.querySelector('.claudian-model-dropdown'); const options = dropdown?.children || []; // Sonnet is current (index 1 in reversed order) const sonnetOption = options.find((o: any) => o.children[0]?.textContent === 'Sonnet'); expect(sonnetOption?.hasClass('selected')).toBe(true); }); it('should call onModelChange when option clicked', async () => { const dropdown = parentEl.querySelector('.claudian-model-dropdown'); const options = dropdown?.children || []; const opusOption = options.find((o: any) => o.children[0]?.textContent === 'Opus'); await opusOption?.dispatchEvent('click', { stopPropagation: () => {} }); expect(callbacks.onModelChange).toHaveBeenCalledWith('opus'); }); it('should update display when setReady is called', () => { selector.setReady(true); const btn = parentEl.querySelector('.claudian-model-btn'); expect(btn?.hasClass('ready')).toBe(true); selector.setReady(false); expect(btn?.hasClass('ready')).toBe(false); }); it('should use custom models from environment variables', () => { callbacks.getEnvironmentVariables.mockReturnValue( 'CLAUDE_CODE_USE_BEDROCK=1\nANTHROPIC_MODEL=us.anthropic.claude-sonnet-4-20250514-v1:0' ); callbacks.getSettings.mockReturnValue({ model: 'us.anthropic.claude-sonnet-4-20250514-v1:0', thinkingBudget: 'low', permissionMode: 'normal', enableOpus1M: false, enableSonnet1M: false, }); selector.renderOptions(); selector.updateDisplay(); // Custom models should be available in dropdown const label = parentEl.querySelector('.claudian-model-label'); expect(label?.textContent).toBeDefined(); }); it('should not filter custom env models when 1M toggles are enabled', () => { callbacks.getEnvironmentVariables.mockReturnValue( 'ANTHROPIC_MODEL=opus' ); callbacks.getSettings.mockReturnValue({ model: 'opus', thinkingBudget: 'low', permissionMode: 'normal', enableOpus1M: true, enableSonnet1M: true, }); selector.renderOptions(); selector.updateDisplay(); const label = parentEl.querySelector('.claudian-model-label'); expect(label?.textContent).toBe('Opus'); }); it('should show 1M variants instead of standard variants when enabled', () => { callbacks.getSettings.mockReturnValue({ model: 'opus[1m]', thinkingBudget: 'medium', permissionMode: 'normal', enableOpus1M: true, enableSonnet1M: true, }); selector.renderOptions(); selector.updateDisplay(); const dropdown = parentEl.querySelector('.claudian-model-dropdown'); const options = dropdown?.children || []; expect(options.find((o: any) => o.children[0]?.textContent === 'Opus 1M')).toBeDefined(); expect(options.find((o: any) => o.children[0]?.textContent === 'Sonnet 1M')).toBeDefined(); expect(options.find((o: any) => o.children[0]?.textContent === 'Opus')).toBeUndefined(); expect(options.find((o: any) => o.children[0]?.textContent === 'Sonnet')).toBeUndefined(); expect(parentEl.querySelector('.claudian-model-label')?.textContent).toBe('Opus 1M'); }); }); describe('ThinkingBudgetSelector', () => { let parentEl: any; let callbacks: ReturnType<typeof createMockCallbacks>; let selector: ThinkingBudgetSelector; describe('adaptive mode (Claude models)', () => { beforeEach(() => { jest.clearAllMocks(); parentEl = createMockEl(); callbacks = createMockCallbacks(); selector = new ThinkingBudgetSelector(parentEl, callbacks); }); it('should create a container with thinking-selector class', () => { const container = parentEl.querySelector('.claudian-thinking-selector'); expect(container).not.toBeNull(); }); it('should show effort selector for Claude models', () => { const effort = parentEl.querySelector('.claudian-thinking-effort'); expect(effort).not.toBeNull(); expect(effort?.style?.display).not.toBe('none'); }); it('should hide budget selector for Claude models', () => { const budget = parentEl.querySelector('.claudian-thinking-budget'); expect(budget?.style?.display).toBe('none'); }); it('should display current effort level for Claude models', () => { const current = parentEl.querySelector('.claudian-thinking-current'); expect(current?.textContent).toBe('High'); }); }); describe('legacy mode (custom models)', () => { beforeEach(() => { jest.clearAllMocks(); parentEl = createMockEl(); callbacks = createMockCallbacks({ getSettings: jest.fn().mockReturnValue({ model: 'custom-model', thinkingBudget: 'low', effortLevel: 'high', permissionMode: 'normal', enableOpus1M: false, enableSonnet1M: false, }), }); selector = new ThinkingBudgetSelector(parentEl, callbacks); }); it('should hide effort selector for custom models', () => { const effort = parentEl.querySelector('.claudian-thinking-effort'); expect(effort?.style?.display).toBe('none'); }); it('should show budget selector for custom models', () => { const budget = parentEl.querySelector('.claudian-thinking-budget'); expect(budget?.style?.display).not.toBe('none'); }); it('should display current budget label', () => { const current = parentEl.querySelector('.claudian-thinking-current'); expect(current?.textContent).toBe('Low'); }); it('should display Off when budget is off', () => { callbacks.getSettings.mockReturnValue({ model: 'custom-model', thinkingBudget: 'off', permissionMode: 'normal', enableOpus1M: false, enableSonnet1M: false, }); selector.updateDisplay(); const current = parentEl.querySelector('.claudian-thinking-current'); expect(current?.textContent).toBe('Off'); }); it('should render budget options in reverse order', () => { const options = parentEl.querySelector('.claudian-thinking-options'); expect(options).not.toBeNull(); // THINKING_BUDGETS reversed: [xhigh, high, medium, low, off] const gears = options?.children || []; expect(gears.length).toBe(5); expect(gears[0]?.textContent).toBe('Ultra'); expect(gears[4]?.textContent).toBe('Off'); }); it('should mark current budget as selected', () => { const options = parentEl.querySelector('.claudian-thinking-options'); const gears = options?.children || []; const lowGear = gears.find((g: any) => g.textContent === 'Low'); expect(lowGear?.hasClass('selected')).toBe(true); }); it('should call onThinkingBudgetChange when gear clicked', async () => { const options = parentEl.querySelector('.claudian-thinking-options'); const gears = options?.children || []; const highGear = gears.find((g: any) => g.textContent === 'High'); await highGear?.dispatchEvent('click', { stopPropagation: () => {} }); expect(callbacks.onThinkingBudgetChange).toHaveBeenCalledWith('high'); }); it('should set title with token count for non-off budgets', () => { const options = parentEl.querySelector('.claudian-thinking-options'); const gears = options?.children || []; const highGear = gears.find((g: any) => g.textContent === 'High'); expect(highGear?.getAttribute('title')).toContain('16,000 tokens'); }); it('should set title as Disabled for off budget', () => { const options = parentEl.querySelector('.claudian-thinking-options'); const gears = options?.children || []; const offGear = gears.find((g: any) => g.textContent === 'Off'); expect(offGear?.getAttribute('title')).toBe('Disabled'); }); }); }); describe('PermissionToggle', () => { let parentEl: any; let callbacks: ReturnType<typeof createMockCallbacks>; beforeEach(() => { jest.clearAllMocks(); parentEl = createMockEl(); callbacks = createMockCallbacks(); new PermissionToggle(parentEl, callbacks); }); it('should create a container with permission-toggle class', () => { const container = parentEl.querySelector('.claudian-permission-toggle'); expect(container).not.toBeNull(); }); it('should display Safe label when in normal mode', () => { const label = parentEl.querySelector('.claudian-permission-label'); expect(label?.textContent).toBe('Safe'); }); it('should display YOLO label when in yolo mode', () => { callbacks.getSettings.mockReturnValue({ model: 'sonnet', thinkingBudget: 'low', permissionMode: 'yolo', enableOpus1M: false, enableSonnet1M: false, }); const parentEl2 = createMockEl(); new PermissionToggle(parentEl2, callbacks); const label = parentEl2.querySelector('.claudian-permission-label'); expect(label?.textContent).toBe('YOLO'); }); it('should show PLAN label and hide toggle in plan mode', () => { callbacks.getSettings.mockReturnValue({ model: 'sonnet', thinkingBudget: 'low', permissionMode: 'plan', enableOpus1M: false, enableSonnet1M: false, }); const parentEl2 = createMockEl(); new PermissionToggle(parentEl2, callbacks); const label = parentEl2.querySelector('.claudian-permission-label'); expect(label?.textContent).toBe('PLAN'); expect(label?.hasClass('plan-active')).toBe(true); const toggle = parentEl2.querySelector('.claudian-toggle-switch'); expect(toggle?.style.display).toBe('none'); }); it('should add active class when in yolo mode', () => { callbacks.getSettings.mockReturnValue({ model: 'sonnet', thinkingBudget: 'low', permissionMode: 'yolo', }); const parentEl2 = createMockEl(); new PermissionToggle(parentEl2, callbacks); const toggle = parentEl2.querySelector('.claudian-toggle-switch'); expect(toggle?.hasClass('active')).toBe(true); }); it('should not have active class in normal mode', () => { const toggle = parentEl.querySelector('.claudian-toggle-switch'); expect(toggle?.hasClass('active')).toBe(false); }); it('should toggle from normal to yolo on click', async () => { const toggle = parentEl.querySelector('.claudian-toggle-switch'); await toggle?.dispatchEvent('click'); expect(callbacks.onPermissionModeChange).toHaveBeenCalledWith('yolo'); }); it('should toggle from yolo to normal on click', async () => { callbacks.getSettings.mockReturnValue({ model: 'sonnet', thinkingBudget: 'low', permissionMode: 'yolo', }); const parentEl2 = createMockEl(); new PermissionToggle(parentEl2, callbacks); const toggle = parentEl2.querySelector('.claudian-toggle-switch'); await toggle?.dispatchEvent('click'); expect(callbacks.onPermissionModeChange).toHaveBeenCalledWith('normal'); }); }); describe('McpServerSelector', () => { let parentEl: any; let selector: McpServerSelector; function createMockMcpManager(servers: { name: string; enabled: boolean; contextSaving?: boolean }[] = []) { return { getServers: jest.fn().mockReturnValue( servers.map(s => ({ name: s.name, enabled: s.enabled, contextSaving: s.contextSaving ?? false, })) ), } as any; } beforeEach(() => { jest.clearAllMocks(); parentEl = createMockEl(); selector = new McpServerSelector(parentEl); }); it('should create container with mcp-selector class', () => { const container = parentEl.querySelector('.claudian-mcp-selector'); expect(container).not.toBeNull(); }); it('should return empty set of enabled servers initially', () => { expect(selector.getEnabledServers().size).toBe(0); }); it('should hide container when no servers configured', () => { selector.setMcpManager(createMockMcpManager([])); const container = parentEl.querySelector('.claudian-mcp-selector'); expect(container?.style.display).toBe('none'); }); it('should show container when servers are configured', () => { selector.setMcpManager(createMockMcpManager([{ name: 'test', enabled: true }])); const container = parentEl.querySelector('.claudian-mcp-selector'); expect(container?.style.display).toBe(''); }); it('should show empty message when all servers are disabled', () => { selector.setMcpManager(createMockMcpManager([{ name: 'test', enabled: false }])); const empty = parentEl.querySelector('.claudian-mcp-selector-empty'); expect(empty?.textContent).toBe('All MCP servers disabled'); }); it('should show no servers message when no servers configured', () => { selector.setMcpManager(createMockMcpManager([])); const empty = parentEl.querySelector('.claudian-mcp-selector-empty'); expect(empty?.textContent).toBe('No MCP servers configured'); }); it('should add mentioned servers', () => { selector.setMcpManager(createMockMcpManager([{ name: 'server1', enabled: true }])); selector.addMentionedServers(new Set(['server1'])); expect(selector.getEnabledServers().has('server1')).toBe(true); }); it('should not re-render when adding already enabled servers', () => { selector.setMcpManager(createMockMcpManager([{ name: 'server1', enabled: true }])); selector.addMentionedServers(new Set(['server1'])); const enabledBefore = selector.getEnabledServers(); selector.addMentionedServers(new Set(['server1'])); expect(selector.getEnabledServers()).toEqual(enabledBefore); }); it('should clear all enabled servers', () => { selector.setMcpManager(createMockMcpManager([ { name: 'server1', enabled: true }, { name: 'server2', enabled: true }, ])); selector.addMentionedServers(new Set(['server1', 'server2'])); expect(selector.getEnabledServers().size).toBe(2); selector.clearEnabled(); expect(selector.getEnabledServers().size).toBe(0); }); it('should set enabled servers from array', () => { selector.setMcpManager(createMockMcpManager([ { name: 'server1', enabled: true }, { name: 'server2', enabled: true }, ])); selector.setEnabledServers(['server1', 'server2']); expect(selector.getEnabledServers().size).toBe(2); }); it('should prune enabled servers that no longer exist in manager', () => { selector.setMcpManager(createMockMcpManager([ { name: 'server1', enabled: true }, { name: 'server2', enabled: true }, ])); selector.setEnabledServers(['server1', 'server2']); // Now update manager to only have server1 selector.setMcpManager(createMockMcpManager([{ name: 'server1', enabled: true }])); expect(selector.getEnabledServers().has('server1')).toBe(true); expect(selector.getEnabledServers().has('server2')).toBe(false); }); it('should invoke onChange callback when pruning removes servers', () => { const onChange = jest.fn(); selector.setOnChange(onChange); selector.setMcpManager(createMockMcpManager([ { name: 'server1', enabled: true }, { name: 'server2', enabled: true }, ])); selector.setEnabledServers(['server1', 'server2']); onChange.mockClear(); // Prune by removing server2 selector.setMcpManager(createMockMcpManager([{ name: 'server1', enabled: true }])); expect(onChange).toHaveBeenCalled(); }); it('should show badge when more than 1 server enabled', () => { selector.setMcpManager(createMockMcpManager([ { name: 'server1', enabled: true }, { name: 'server2', enabled: true }, ])); selector.setEnabledServers(['server1', 'server2']); selector.updateDisplay(); const badge = parentEl.querySelector('.claudian-mcp-selector-badge'); expect(badge?.hasClass('visible')).toBe(true); expect(badge?.textContent).toBe('2'); }); it('should not show badge when only 1 server enabled', () => { selector.setMcpManager(createMockMcpManager([{ name: 'server1', enabled: true }])); selector.setEnabledServers(['server1']); selector.updateDisplay(); const badge = parentEl.querySelector('.claudian-mcp-selector-badge'); expect(badge?.hasClass('visible')).toBe(false); }); it('should add active class to icon when servers are enabled', () => { selector.setMcpManager(createMockMcpManager([{ name: 'server1', enabled: true }])); selector.setEnabledServers(['server1']); selector.updateDisplay(); const icon = parentEl.querySelector('.claudian-mcp-selector-icon'); expect(icon?.hasClass('active')).toBe(true); }); it('should remove active class from icon when no servers enabled', () => { selector.setMcpManager(createMockMcpManager([{ name: 'server1', enabled: true }])); selector.clearEnabled(); selector.updateDisplay(); const icon = parentEl.querySelector('.claudian-mcp-selector-icon'); expect(icon?.hasClass('active')).toBe(false); }); it('should handle null mcpManager', () => { selector.setMcpManager(null); expect(selector.getEnabledServers().size).toBe(0); }); }); describe('ContextUsageMeter', () => { let parentEl: any; let meter: ContextUsageMeter; beforeEach(() => { jest.clearAllMocks(); parentEl = createMockEl(); meter = new ContextUsageMeter(parentEl); }); it('should create a container with context-meter class', () => { const container = parentEl.querySelector('.claudian-context-meter'); expect(container).not.toBeNull(); }); it('should be hidden initially', () => { const container = parentEl.querySelector('.claudian-context-meter'); expect(container?.style.display).toBe('none'); }); it('should remain hidden when update called with null', () => { meter.update(null); const container = parentEl.querySelector('.claudian-context-meter'); expect(container?.style.display).toBe('none'); }); it('should remain hidden when contextTokens is 0', () => { meter.update(makeUsage({ contextTokens: 0, contextWindow: 200000, percentage: 0 })); const container = parentEl.querySelector('.claudian-context-meter'); expect(container?.style.display).toBe('none'); }); it('should become visible when contextTokens > 0', () => { meter.update(makeUsage({ contextTokens: 50000, contextWindow: 200000, percentage: 25 })); const container = parentEl.querySelector('.claudian-context-meter'); expect(container?.style.display).toBe('flex'); }); it('should display percentage', () => { meter.update(makeUsage({ contextTokens: 50000, contextWindow: 200000, percentage: 25 })); const percent = parentEl.querySelector('.claudian-context-meter-percent'); expect(percent?.textContent).toBe('25%'); }); it('should add warning class when usage > 80%', () => { meter.update(makeUsage({ contextTokens: 170000, contextWindow: 200000, percentage: 85 })); const container = parentEl.querySelector('.claudian-context-meter'); expect(container?.hasClass('warning')).toBe(true); }); it('should remove warning class when usage drops below 80%', () => { meter.update(makeUsage({ contextTokens: 170000, contextWindow: 200000, percentage: 85 })); meter.update(makeUsage({ contextTokens: 50000, contextWindow: 200000, percentage: 25 })); const container = parentEl.querySelector('.claudian-context-meter'); expect(container?.hasClass('warning')).toBe(false); }); it('should set tooltip with formatted token counts', () => { meter.update(makeUsage({ contextTokens: 50000, contextWindow: 200000, percentage: 25 })); const container = parentEl.querySelector('.claudian-context-meter'); expect(container?.getAttribute('data-tooltip')).toBe('50k / 200k'); }); it('should format small token counts without k suffix', () => { meter.update(makeUsage({ contextTokens: 500, contextWindow: 200000, percentage: 0 })); const container = parentEl.querySelector('.claudian-context-meter'); expect(container?.getAttribute('data-tooltip')).toBe('500 / 200k'); }); it('should add compact reminder to tooltip when usage > 80%', () => { meter.update(makeUsage({ contextTokens: 170000, contextWindow: 200000, percentage: 85 })); const container = parentEl.querySelector('.claudian-context-meter'); expect(container?.getAttribute('data-tooltip')).toBe('170k / 200k (Approaching limit, run `/compact` to continue)'); }); it('should not add compact reminder to tooltip when usage ≤ 80%', () => { meter.update(makeUsage({ contextTokens: 160000, contextWindow: 200000, percentage: 80 })); const container = parentEl.querySelector('.claudian-context-meter'); expect(container?.getAttribute('data-tooltip')).toBe('160k / 200k'); }); }); describe('McpServerSelector - toggle and badges', () => { let parentEl: any; let selector: McpServerSelector; function createMockMcpManager(servers: { name: string; enabled: boolean; contextSaving?: boolean }[] = []) { return { getServers: jest.fn().mockReturnValue( servers.map(s => ({ name: s.name, enabled: s.enabled, contextSaving: s.contextSaving ?? false, })) ), } as any; } beforeEach(() => { jest.clearAllMocks(); parentEl = createMockEl(); selector = new McpServerSelector(parentEl); }); it('should render context-saving badge for servers with contextSaving', () => { selector.setMcpManager(createMockMcpManager([ { name: 'server1', enabled: true, contextSaving: true }, ])); const csBadge = parentEl.querySelector('.claudian-mcp-selector-cs-badge'); expect(csBadge).not.toBeNull(); expect(csBadge?.textContent).toBe('@'); }); it('should not render context-saving badge for servers without contextSaving', () => { selector.setMcpManager(createMockMcpManager([ { name: 'server1', enabled: true, contextSaving: false }, ])); const csBadge = parentEl.querySelector('.claudian-mcp-selector-cs-badge'); expect(csBadge).toBeNull(); }); it('should toggle server on mousedown and update display', () => { const onChange = jest.fn(); selector.setOnChange(onChange); selector.setMcpManager(createMockMcpManager([ { name: 'server1', enabled: true }, ])); // Find the server item and trigger mousedown const item = parentEl.querySelector('.claudian-mcp-selector-item'); expect(item).not.toBeNull(); // Simulate mousedown to enable const mousedownHandlers = item._eventListeners?.get('mousedown'); expect(mousedownHandlers).toBeDefined(); mousedownHandlers![0]({ preventDefault: jest.fn(), stopPropagation: jest.fn() }); expect(selector.getEnabledServers().has('server1')).toBe(true); expect(onChange).toHaveBeenCalled(); // Toggle again to disable onChange.mockClear(); mousedownHandlers![0]({ preventDefault: jest.fn(), stopPropagation: jest.fn() }); expect(selector.getEnabledServers().has('server1')).toBe(false); expect(onChange).toHaveBeenCalled(); }); it('should re-render dropdown on mouseenter', () => { selector.setMcpManager(createMockMcpManager([ { name: 'server1', enabled: true }, ])); // Get container and trigger mouseenter const container = parentEl.querySelector('.claudian-mcp-selector'); const mouseenterHandlers = container?._eventListeners?.get('mouseenter'); expect(mouseenterHandlers).toBeDefined(); // Should not throw expect(() => mouseenterHandlers![0]()).not.toThrow(); }); }); describe('createInputToolbar', () => { it('should return all toolbar components', () => { const parentEl = createMockEl(); const callbacks = createMockCallbacks(); const toolbar = createInputToolbar(parentEl, callbacks); expect(toolbar.modelSelector).toBeInstanceOf(ModelSelector); expect(toolbar.thinkingBudgetSelector).toBeInstanceOf(ThinkingBudgetSelector); expect(toolbar.contextUsageMeter).toBeInstanceOf(ContextUsageMeter); expect(toolbar.mcpServerSelector).toBeInstanceOf(McpServerSelector); expect(toolbar.permissionToggle).toBeInstanceOf(PermissionToggle); }); }); ================================================ FILE: tests/unit/features/chat/ui/InstructionModeManager.test.ts ================================================ import { InstructionModeManager } from '@/features/chat/ui/InstructionModeManager'; function createWrapper() { return { addClass: jest.fn(), removeClass: jest.fn(), } as any; } function createKeyEvent(key: string, options: { shiftKey?: boolean } = {}) { return { key, shiftKey: options.shiftKey ?? false, preventDefault: jest.fn(), } as any; } describe('InstructionModeManager', () => { it('should enter instruction mode on # keystroke when input is empty', () => { const wrapper = createWrapper(); const inputEl = { value: '', placeholder: 'Ask...' } as any; const callbacks = { onSubmit: jest.fn().mockResolvedValue(undefined), getInputWrapper: () => wrapper, }; const manager = new InstructionModeManager(inputEl, callbacks); const e = createKeyEvent('#'); const handled = manager.handleTriggerKey(e); expect(handled).toBe(true); expect(e.preventDefault).toHaveBeenCalled(); expect(manager.isActive()).toBe(true); expect(inputEl.placeholder).toBe('# Save in custom system prompt'); expect(wrapper.addClass).toHaveBeenCalledWith('claudian-input-instruction-mode'); }); it('should NOT enter instruction mode on # keystroke when input has content', () => { const wrapper = createWrapper(); const inputEl = { value: 'hello', placeholder: 'Ask...' } as any; const callbacks = { onSubmit: jest.fn().mockResolvedValue(undefined), getInputWrapper: () => wrapper, }; const manager = new InstructionModeManager(inputEl, callbacks); const e = createKeyEvent('#'); const handled = manager.handleTriggerKey(e); expect(handled).toBe(false); expect(e.preventDefault).not.toHaveBeenCalled(); expect(manager.isActive()).toBe(false); expect(wrapper.addClass).not.toHaveBeenCalled(); }); it('should NOT enter instruction mode when pasting "# hello"', () => { const wrapper = createWrapper(); const inputEl = { value: '', placeholder: 'Ask...' } as any; const callbacks = { onSubmit: jest.fn().mockResolvedValue(undefined), getInputWrapper: () => wrapper, }; const manager = new InstructionModeManager(inputEl, callbacks); // Simulate paste - no keystroke, just input change inputEl.value = '# hello'; manager.handleInputChange(); expect(manager.isActive()).toBe(false); expect(wrapper.addClass).not.toHaveBeenCalled(); }); it('should exit instruction mode when input is cleared', () => { const wrapper = createWrapper(); const inputEl = { value: '', placeholder: 'Ask...' } as any; const callbacks = { onSubmit: jest.fn().mockResolvedValue(undefined), getInputWrapper: () => wrapper, }; const manager = new InstructionModeManager(inputEl, callbacks); manager.handleTriggerKey(createKeyEvent('#')); expect(manager.isActive()).toBe(true); inputEl.value = ''; manager.handleInputChange(); expect(manager.isActive()).toBe(false); expect(inputEl.placeholder).toBe('Ask...'); expect(wrapper.removeClass).toHaveBeenCalledWith('claudian-input-instruction-mode'); }); it('should submit instruction on Enter (without Shift) and trim whitespace', async () => { const wrapper = createWrapper(); const inputEl = { value: '', placeholder: 'Ask...' } as any; const callbacks = { onSubmit: jest.fn().mockResolvedValue(undefined), getInputWrapper: () => wrapper, }; const manager = new InstructionModeManager(inputEl, callbacks); manager.handleTriggerKey(createKeyEvent('#')); inputEl.value = ' test '; manager.handleInputChange(); const e = createKeyEvent('Enter'); const handled = manager.handleKeydown(e); expect(handled).toBe(true); expect(e.preventDefault).toHaveBeenCalled(); expect(callbacks.onSubmit).toHaveBeenCalledWith('test'); }); it('should not handle Enter when instruction is empty', () => { const wrapper = createWrapper(); const inputEl = { value: '', placeholder: 'Ask...' } as any; const callbacks = { onSubmit: jest.fn().mockResolvedValue(undefined), getInputWrapper: () => wrapper, }; const manager = new InstructionModeManager(inputEl, callbacks); manager.handleTriggerKey(createKeyEvent('#')); inputEl.value = ' '; manager.handleInputChange(); const e = createKeyEvent('Enter'); const handled = manager.handleKeydown(e); expect(handled).toBe(false); expect(e.preventDefault).not.toHaveBeenCalled(); expect(callbacks.onSubmit).not.toHaveBeenCalled(); }); it('should cancel on Escape and clear input', () => { const wrapper = createWrapper(); const inputEl = { value: '', placeholder: 'Ask...' } as any; const callbacks = { onSubmit: jest.fn().mockResolvedValue(undefined), getInputWrapper: () => wrapper, }; const manager = new InstructionModeManager(inputEl, callbacks); manager.handleTriggerKey(createKeyEvent('#')); expect(manager.isActive()).toBe(true); inputEl.value = 'hello'; manager.handleInputChange(); const e = createKeyEvent('Escape'); const handled = manager.handleKeydown(e); expect(handled).toBe(true); expect(e.preventDefault).toHaveBeenCalled(); expect(inputEl.value).toBe(''); expect(manager.isActive()).toBe(false); }); it('should return false for non-Enter/Escape keys when active', () => { const wrapper = createWrapper(); const inputEl = { value: '', placeholder: 'Ask...' } as any; const callbacks = { onSubmit: jest.fn().mockResolvedValue(undefined), getInputWrapper: () => wrapper, }; const manager = new InstructionModeManager(inputEl, callbacks); manager.handleTriggerKey(createKeyEvent('#')); expect(manager.isActive()).toBe(true); inputEl.value = 'some text'; manager.handleInputChange(); const e = createKeyEvent('a'); const handled = manager.handleKeydown(e); expect(handled).toBe(false); expect(e.preventDefault).not.toHaveBeenCalled(); }); it('should return raw instruction text via getRawInstruction', () => { const wrapper = createWrapper(); const inputEl = { value: '', placeholder: 'Ask...' } as any; const callbacks = { onSubmit: jest.fn().mockResolvedValue(undefined), getInputWrapper: () => wrapper, }; const manager = new InstructionModeManager(inputEl, callbacks); manager.handleTriggerKey(createKeyEvent('#')); inputEl.value = 'my instruction'; manager.handleInputChange(); expect(manager.getRawInstruction()).toBe('my instruction'); }); it('should clear input, exit mode and reset input height on clear()', () => { const wrapper = createWrapper(); const inputEl = { value: '', placeholder: 'Ask...' } as any; const resetInputHeight = jest.fn(); const callbacks = { onSubmit: jest.fn().mockResolvedValue(undefined), getInputWrapper: () => wrapper, resetInputHeight, }; const manager = new InstructionModeManager(inputEl, callbacks); manager.handleTriggerKey(createKeyEvent('#')); expect(manager.isActive()).toBe(true); inputEl.value = 'instruction text'; manager.handleInputChange(); manager.clear(); expect(inputEl.value).toBe(''); expect(manager.isActive()).toBe(false); expect(resetInputHeight).toHaveBeenCalled(); }); it('should remove instruction mode class and restore placeholder on destroy()', () => { const wrapper = createWrapper(); const inputEl = { value: '', placeholder: 'Ask...' } as any; const callbacks = { onSubmit: jest.fn().mockResolvedValue(undefined), getInputWrapper: () => wrapper, }; const manager = new InstructionModeManager(inputEl, callbacks); manager.handleTriggerKey(createKeyEvent('#')); expect(manager.isActive()).toBe(true); expect(inputEl.placeholder).toBe('# Save in custom system prompt'); manager.destroy(); expect(wrapper.removeClass).toHaveBeenCalledWith('claudian-input-instruction-mode'); expect(inputEl.placeholder).toBe('Ask...'); }); }); ================================================ FILE: tests/unit/features/chat/ui/NavigationSidebar.test.ts ================================================ import { NavigationSidebar } from '@/features/chat/ui/NavigationSidebar'; // Mock obsidian jest.mock('obsidian', () => ({ setIcon: jest.fn((el: any, iconName: string) => { el.setAttribute('data-icon', iconName); }), })); type Listener = (event: any) => void; class MockClassList { private classes = new Set<string>(); add(...items: string[]): void { items.forEach((item) => this.classes.add(item)); } remove(...items: string[]): void { items.forEach((item) => this.classes.delete(item)); } contains(item: string): boolean { return this.classes.has(item); } toggle(item: string, force?: boolean): void { if (force === undefined) { if (this.classes.has(item)) { this.classes.delete(item); } else { this.classes.add(item); } return; } if (force) { this.classes.add(item); } else { this.classes.delete(item); } } clear(): void { this.classes.clear(); } toArray(): string[] { return Array.from(this.classes); } } class MockElement { tagName: string; classList = new MockClassList(); style: Record<string, string> = {}; children: MockElement[] = []; attributes: Record<string, string> = {}; dataset: Record<string, string> = {}; parent: MockElement | null = null; textContent = ''; private _scrollTop = 0; private _scrollHeight = 500; private _clientHeight = 500; private listeners: Record<string, Listener[]> = {}; public scrollToCalls: Array<{ top: number; behavior: string }> = []; offsetTop = 0; constructor(tagName: string) { this.tagName = tagName.toUpperCase(); } set className(value: string) { this.classList.clear(); value.split(/\s+/).filter(Boolean).forEach((cls) => this.classList.add(cls)); } get className(): string { return this.classList.toArray().join(' '); } get scrollHeight(): number { return this._scrollHeight; } set scrollHeight(value: number) { this._scrollHeight = value; } get clientHeight(): number { return this._clientHeight; } set clientHeight(value: number) { this._clientHeight = value; } get scrollTop(): number { return this._scrollTop; } set scrollTop(value: number) { this._scrollTop = value; } scrollTo(options: { top: number; behavior: string }): void { this.scrollToCalls.push(options); this._scrollTop = options.top; } appendChild(child: MockElement): MockElement { child.parent = this; this.children.push(child); return child; } remove(): void { if (!this.parent) return; this.parent.children = this.parent.children.filter((child) => child !== this); this.parent = null; } setAttribute(name: string, value: string): void { this.attributes[name] = value; } getAttribute(name: string): string | null { return this.attributes[name] ?? null; } addEventListener(type: string, listener: Listener, _options?: any): void { if (!this.listeners[type]) { this.listeners[type] = []; } this.listeners[type].push(listener); } removeEventListener(type: string, listener: Listener): void { if (!this.listeners[type]) return; this.listeners[type] = this.listeners[type].filter((l) => l !== listener); } dispatchEvent(event: any): void { const listeners = this.listeners[event.type] || []; for (const listener of listeners) { listener(event); } } click(): void { this.dispatchEvent({ type: 'click', stopPropagation: jest.fn(), preventDefault: jest.fn() }); } empty(): void { this.children = []; this.textContent = ''; } createDiv(options?: { cls?: string; text?: string; attr?: Record<string, string> }): MockElement { const el = new MockElement('div'); if (options?.cls) el.className = options.cls; if (options?.text) el.textContent = options.text; if (options?.attr) { for (const [key, value] of Object.entries(options.attr)) { el.setAttribute(key, value); } } this.appendChild(el); return el; } querySelector(selector: string): MockElement | null { return this.querySelectorAll(selector)[0] || null; } querySelectorAll(selector: string): MockElement[] { const matches: MockElement[] = []; const traverse = (el: MockElement): void => { // Handle class selectors if (selector.startsWith('.')) { const className = selector.slice(1); if (el.classList.contains(className)) { matches.push(el); } } for (const child of el.children) { traverse(child); } }; traverse(this); return matches; } } describe('NavigationSidebar', () => { let parentEl: MockElement; let messagesEl: MockElement; let sidebar: NavigationSidebar; beforeEach(() => { parentEl = new MockElement('div'); messagesEl = new MockElement('div'); parentEl.appendChild(messagesEl); }); afterEach(() => { sidebar?.destroy(); }); describe('initialization', () => { it('should create container with correct class', () => { sidebar = new NavigationSidebar( parentEl as unknown as HTMLElement, messagesEl as unknown as HTMLElement ); const container = parentEl.querySelector('.claudian-nav-sidebar'); expect(container).not.toBeNull(); }); it('should create four navigation buttons', () => { sidebar = new NavigationSidebar( parentEl as unknown as HTMLElement, messagesEl as unknown as HTMLElement ); const container = parentEl.querySelector('.claudian-nav-sidebar'); expect(container).not.toBeNull(); expect(container!.children.length).toBe(4); }); it('should set correct aria-labels on buttons', () => { sidebar = new NavigationSidebar( parentEl as unknown as HTMLElement, messagesEl as unknown as HTMLElement ); const container = parentEl.querySelector('.claudian-nav-sidebar'); const buttons = container!.children; expect(buttons[0].getAttribute('aria-label')).toBe('Scroll to top'); expect(buttons[1].getAttribute('aria-label')).toBe('Previous message'); expect(buttons[2].getAttribute('aria-label')).toBe('Next message'); expect(buttons[3].getAttribute('aria-label')).toBe('Scroll to bottom'); }); it('should set correct icons on buttons', () => { sidebar = new NavigationSidebar( parentEl as unknown as HTMLElement, messagesEl as unknown as HTMLElement ); const container = parentEl.querySelector('.claudian-nav-sidebar'); const buttons = container!.children; expect(buttons[0].getAttribute('data-icon')).toBe('chevrons-up'); expect(buttons[1].getAttribute('data-icon')).toBe('chevron-up'); expect(buttons[2].getAttribute('data-icon')).toBe('chevron-down'); expect(buttons[3].getAttribute('data-icon')).toBe('chevrons-down'); }); }); describe('visibility', () => { it('should be hidden when content does not overflow', () => { messagesEl.scrollHeight = 500; messagesEl.clientHeight = 500; sidebar = new NavigationSidebar( parentEl as unknown as HTMLElement, messagesEl as unknown as HTMLElement ); const container = parentEl.querySelector('.claudian-nav-sidebar'); expect(container!.classList.contains('visible')).toBe(false); }); it('should be visible when content overflows', () => { messagesEl.scrollHeight = 1000; messagesEl.clientHeight = 500; sidebar = new NavigationSidebar( parentEl as unknown as HTMLElement, messagesEl as unknown as HTMLElement ); const container = parentEl.querySelector('.claudian-nav-sidebar'); expect(container!.classList.contains('visible')).toBe(true); }); it('should update visibility when updateVisibility is called', () => { messagesEl.scrollHeight = 500; messagesEl.clientHeight = 500; sidebar = new NavigationSidebar( parentEl as unknown as HTMLElement, messagesEl as unknown as HTMLElement ); const container = parentEl.querySelector('.claudian-nav-sidebar'); expect(container!.classList.contains('visible')).toBe(false); // Simulate content growth messagesEl.scrollHeight = 1000; sidebar.updateVisibility(); expect(container!.classList.contains('visible')).toBe(true); }); }); describe('scroll to top button', () => { it('should scroll to top when clicked', () => { messagesEl.scrollHeight = 1000; messagesEl.clientHeight = 500; messagesEl.scrollTop = 500; sidebar = new NavigationSidebar( parentEl as unknown as HTMLElement, messagesEl as unknown as HTMLElement ); const container = parentEl.querySelector('.claudian-nav-sidebar'); const topBtn = container!.children[0]; topBtn.click(); expect(messagesEl.scrollToCalls.length).toBe(1); expect(messagesEl.scrollToCalls[0].top).toBe(0); expect(messagesEl.scrollToCalls[0].behavior).toBe('smooth'); }); }); describe('scroll to bottom button', () => { it('should scroll to bottom when clicked', () => { messagesEl.scrollHeight = 1000; messagesEl.clientHeight = 500; messagesEl.scrollTop = 0; sidebar = new NavigationSidebar( parentEl as unknown as HTMLElement, messagesEl as unknown as HTMLElement ); const container = parentEl.querySelector('.claudian-nav-sidebar'); const bottomBtn = container!.children[3]; bottomBtn.click(); expect(messagesEl.scrollToCalls.length).toBe(1); expect(messagesEl.scrollToCalls[0].top).toBe(1000); expect(messagesEl.scrollToCalls[0].behavior).toBe('smooth'); }); }); describe('previous/next message navigation', () => { function addUserMessage(el: MockElement, offset: number): MockElement { const msg = el.createDiv({ cls: 'claudian-message claudian-message-user' }); msg.offsetTop = offset; return msg; } function addAssistantMessage(el: MockElement, offset: number): MockElement { const msg = el.createDiv({ cls: 'claudian-message claudian-message-assistant' }); msg.offsetTop = offset; return msg; } function addConversation(el: MockElement, userOffsets: number[], assistantOffsets: number[]): void { // Interleave user and assistant messages in order const all = [ ...userOffsets.map(o => ({ offset: o, role: 'user' as const })), ...assistantOffsets.map(o => ({ offset: o, role: 'assistant' as const })), ].sort((a, b) => a.offset - b.offset); for (const m of all) { if (m.role === 'user') addUserMessage(el, m.offset); else addAssistantMessage(el, m.offset); } } function getButtons(parent: MockElement) { const container = parent.querySelector('.claudian-nav-sidebar')!; return { prev: container.children[1], next: container.children[2], }; } it('should scroll to next user message below current scroll position', () => { messagesEl.scrollHeight = 2000; messagesEl.clientHeight = 500; // user@0, assistant@100, user@400, assistant@500, user@800 addConversation(messagesEl, [0, 400, 800], [100, 500]); messagesEl.scrollTop = 0; sidebar = new NavigationSidebar( parentEl as unknown as HTMLElement, messagesEl as unknown as HTMLElement ); const { next } = getButtons(parentEl); next.click(); const lastCall = messagesEl.scrollToCalls[messagesEl.scrollToCalls.length - 1]; // Should skip assistant@100 and go to user@400 expect(lastCall.top).toBe(390); // offsetTop(400) - 10 expect(lastCall.behavior).toBe('smooth'); }); it('should scroll to previous user message above current scroll position', () => { messagesEl.scrollHeight = 2000; messagesEl.clientHeight = 500; addConversation(messagesEl, [0, 400, 800], [100, 500]); messagesEl.scrollTop = 800; sidebar = new NavigationSidebar( parentEl as unknown as HTMLElement, messagesEl as unknown as HTMLElement ); const { prev } = getButtons(parentEl); prev.click(); const lastCall = messagesEl.scrollToCalls[messagesEl.scrollToCalls.length - 1]; // Should skip assistant@500 and go to user@400 expect(lastCall.top).toBe(390); // offsetTop(400) - 10 expect(lastCall.behavior).toBe('smooth'); }); it('should not require double-click when scrolled to a user message', () => { messagesEl.scrollHeight = 2000; messagesEl.clientHeight = 500; addConversation(messagesEl, [0, 400, 800], [100, 500]); // Scrolled to user message at offset 400 (scroll position = 390) messagesEl.scrollTop = 390; sidebar = new NavigationSidebar( parentEl as unknown as HTMLElement, messagesEl as unknown as HTMLElement ); const { next } = getButtons(parentEl); next.click(); const lastCall = messagesEl.scrollToCalls[messagesEl.scrollToCalls.length - 1]; // Should go to user@800, not stay at user@400 expect(lastCall.top).toBe(790); // offsetTop(800) - 10 }); it('should not require double-click for prev when scrolled to a user message', () => { messagesEl.scrollHeight = 2000; messagesEl.clientHeight = 500; addConversation(messagesEl, [0, 400, 800], [100, 500]); // Scrolled to user message at offset 800 messagesEl.scrollTop = 790; sidebar = new NavigationSidebar( parentEl as unknown as HTMLElement, messagesEl as unknown as HTMLElement ); const { prev } = getButtons(parentEl); prev.click(); const lastCall = messagesEl.scrollToCalls[messagesEl.scrollToCalls.length - 1]; // Should go to user@400, not stay at user@800 expect(lastCall.top).toBe(390); // offsetTop(400) - 10 }); it('should scroll to bottom when at the last user message and next is clicked', () => { messagesEl.scrollHeight = 2000; messagesEl.clientHeight = 500; addConversation(messagesEl, [0, 400, 800], [100, 500]); messagesEl.scrollTop = 790; sidebar = new NavigationSidebar( parentEl as unknown as HTMLElement, messagesEl as unknown as HTMLElement ); const { next } = getButtons(parentEl); next.click(); const lastCall = messagesEl.scrollToCalls[messagesEl.scrollToCalls.length - 1]; expect(lastCall.top).toBe(2000); }); it('should scroll to top when at the first user message and prev is clicked', () => { messagesEl.scrollHeight = 2000; messagesEl.clientHeight = 500; addConversation(messagesEl, [0, 400, 800], [100, 500]); messagesEl.scrollTop = 0; sidebar = new NavigationSidebar( parentEl as unknown as HTMLElement, messagesEl as unknown as HTMLElement ); const { prev } = getButtons(parentEl); prev.click(); const lastCall = messagesEl.scrollToCalls[messagesEl.scrollToCalls.length - 1]; expect(lastCall.top).toBe(0); }); }); describe('destroy', () => { it('should remove container from DOM', () => { sidebar = new NavigationSidebar( parentEl as unknown as HTMLElement, messagesEl as unknown as HTMLElement ); expect(parentEl.querySelector('.claudian-nav-sidebar')).not.toBeNull(); sidebar.destroy(); expect(parentEl.querySelector('.claudian-nav-sidebar')).toBeNull(); }); }); }); ================================================ FILE: tests/unit/features/chat/ui/StatusPanel.test.ts ================================================ import type { TodoItem } from '@/core/tools'; import { StatusPanel } from '@/features/chat/ui/StatusPanel'; // Mock obsidian jest.mock('obsidian', () => ({ setIcon: jest.fn((el: any, iconName: string) => { el.setAttribute('data-icon', iconName); }), Notice: jest.fn(), })); type Listener = (event: any) => void; class MockClassList { private classes = new Set<string>(); add(...items: string[]): void { items.forEach((item) => this.classes.add(item)); } remove(...items: string[]): void { items.forEach((item) => this.classes.delete(item)); } contains(item: string): boolean { return this.classes.has(item); } has(item: string): boolean { return this.classes.has(item); } toggle(item: string, force?: boolean): void { if (force === undefined) { if (this.classes.has(item)) { this.classes.delete(item); } else { this.classes.add(item); } return; } if (force) { this.classes.add(item); } else { this.classes.delete(item); } } clear(): void { this.classes.clear(); } toArray(): string[] { return Array.from(this.classes); } } class MockElement { tagName: string; classList = new MockClassList(); style: Record<string, string> = {}; children: MockElement[] = []; attributes: Record<string, string> = {}; dataset: Record<string, string> = {}; parent: MockElement | null = null; textContent = ''; private _scrollTop = 0; private listeners: Record<string, Listener[]> = {}; constructor(tagName: string) { this.tagName = tagName.toUpperCase(); } set className(value: string) { this.classList.clear(); value.split(/\s+/).filter(Boolean).forEach((cls) => this.classList.add(cls)); } get className(): string { return this.classList.toArray().join(' '); } get scrollHeight(): number { return 1000; } get scrollTop(): number { return this._scrollTop; } set scrollTop(_value: number) { this._scrollTop = _value; } appendChild(child: MockElement): MockElement { child.parent = this; this.children.push(child); return child; } remove(): void { if (!this.parent) return; this.parent.children = this.parent.children.filter((child) => child !== this); this.parent = null; } setAttribute(name: string, value: string): void { this.attributes[name] = value; } getAttribute(name: string): string | null { // Check attributes first if (this.attributes[name] !== undefined) { return this.attributes[name]; } // For data-* attributes, also check dataset if (name.startsWith('data-')) { const dataKey = name.slice(5).replace(/-([a-z])/g, (_, c) => c.toUpperCase()); return this.dataset[dataKey] ?? null; } return null; } addEventListener(type: string, listener: Listener): void { if (!this.listeners[type]) { this.listeners[type] = []; } this.listeners[type].push(listener); } removeEventListener(type: string, listener: Listener): void { if (!this.listeners[type]) return; this.listeners[type] = this.listeners[type].filter((l) => l !== listener); } dispatchEvent(event: any): void { const listeners = this.listeners[event.type] || []; for (const listener of listeners) { listener(event); } } click(): void { this.dispatchEvent({ type: 'click', stopPropagation: jest.fn(), preventDefault: jest.fn() }); } empty(): void { this.children = []; this.textContent = ''; } // Obsidian-style helper methods createDiv(options?: { cls?: string; text?: string }): MockElement { const el = new MockElement('div'); if (options?.cls) el.className = options.cls; if (options?.text) el.textContent = options.text; this.appendChild(el); return el; } createSpan(options?: { cls?: string; text?: string }): MockElement { const el = new MockElement('span'); if (options?.cls) el.className = options.cls; if (options?.text) el.textContent = options.text; this.appendChild(el); return el; } setText(text: string): void { this.textContent = text; } querySelector(selector: string): MockElement | null { return this.querySelectorAll(selector)[0] || null; } querySelectorAll(selector: string): MockElement[] { const matches: MockElement[] = []; const match = (el: MockElement): boolean => { // Handle attribute selectors like [data-icon] const attrMatch = selector.match(/\[([a-zA-Z0-9_-]+)\]/); if (attrMatch) { const attrName = attrMatch[1]; // Convert data-* attributes to dataset keys (data-foo-bar -> fooBar) if (attrName.startsWith('data-')) { const dataKey = attrName.slice(5).replace(/-([a-z])/g, (_, c) => c.toUpperCase()); return el.dataset[dataKey] !== undefined; } return el.attributes[attrName] !== undefined; } // Handle class selectors like .claudian-status-panel const classMatch = selector.match(/\.([a-zA-Z0-9_-]+)/g); if (classMatch) { for (const cls of classMatch) { const className = cls.slice(1); if (!el.classList.has(className)) { return false; } } } return classMatch !== null; }; const walk = (el: MockElement) => { if (match(el)) { matches.push(el); } for (const child of el.children) { walk(child); } }; for (const child of this.children) { walk(child); } return matches; } } function createMockDocument() { return { createElement: (tag: string) => new MockElement(tag), }; } describe('StatusPanel', () => { let containerEl: MockElement; let panel: StatusPanel; let originalDocument: any; let originalNavigator: any; let writeTextMock: jest.Mock; beforeEach(() => { originalDocument = (global as any).document; originalNavigator = (global as any).navigator; (global as any).document = createMockDocument(); writeTextMock = jest.fn().mockResolvedValue(undefined); (global as any).navigator = { clipboard: { writeText: writeTextMock } }; containerEl = new MockElement('div'); panel = new StatusPanel(); }); afterEach(() => { panel.destroy(); (global as any).document = originalDocument; (global as any).navigator = originalNavigator; }); describe('mount', () => { it('should create panel element when mounted', () => { panel.mount(containerEl as unknown as HTMLElement); expect(containerEl.querySelector('.claudian-status-panel')).not.toBeNull(); }); it('should create hidden todo container initially', () => { panel.mount(containerEl as unknown as HTMLElement); const todoContainer = containerEl.querySelector('.claudian-status-panel-todos'); expect(todoContainer).not.toBeNull(); expect(todoContainer!.style.display).toBe('none'); }); }); describe('updateTodos', () => { beforeEach(() => { panel.mount(containerEl as unknown as HTMLElement); }); it('should show panel when todos are provided', () => { const todos: TodoItem[] = [ { content: 'Task 1', status: 'pending', activeForm: 'Doing Task 1' }, ]; panel.updateTodos(todos); const todoContainer = containerEl.querySelector('.claudian-status-panel-todos'); expect(todoContainer!.style.display).toBe('block'); }); it('should hide panel when todos is null', () => { const todos: TodoItem[] = [ { content: 'Task 1', status: 'pending', activeForm: 'Doing Task 1' }, ]; panel.updateTodos(todos); panel.updateTodos(null); const todoContainer = containerEl.querySelector('.claudian-status-panel-todos'); expect(todoContainer!.style.display).toBe('none'); }); it('should hide panel when todos is empty array', () => { const todos: TodoItem[] = [ { content: 'Task 1', status: 'pending', activeForm: 'Doing Task 1' }, ]; panel.updateTodos(todos); panel.updateTodos([]); const todoContainer = containerEl.querySelector('.claudian-status-panel-todos'); expect(todoContainer!.style.display).toBe('none'); }); it('should display correct task count', () => { const todos: TodoItem[] = [ { content: 'Task 1', status: 'completed', activeForm: 'Doing Task 1' }, { content: 'Task 2', status: 'pending', activeForm: 'Doing Task 2' }, { content: 'Task 3', status: 'in_progress', activeForm: 'Working on Task 3' }, ]; panel.updateTodos(todos); const label = containerEl.querySelector('.claudian-status-panel-label'); expect(label?.textContent).toBe('Tasks (1/3)'); }); it('should show current task in collapsed header', () => { const todos: TodoItem[] = [ { content: 'Task 1', status: 'pending', activeForm: 'Doing Task 1' }, { content: 'Task 2', status: 'in_progress', activeForm: 'Working on Task 2' }, ]; panel.updateTodos(todos); const current = containerEl.querySelector('.claudian-status-panel-current'); expect(current?.textContent).toBe('Working on Task 2'); }); it('should render all todo items in content area', () => { const todos: TodoItem[] = [ { content: 'Task 1', status: 'pending', activeForm: 'Doing Task 1' }, { content: 'Task 2', status: 'completed', activeForm: 'Doing Task 2' }, ]; panel.updateTodos(todos); const items = containerEl.querySelectorAll('.claudian-todo-item'); expect(items.length).toBe(2); }); it('should apply correct status classes to items', () => { const todos: TodoItem[] = [ { content: 'Task 1', status: 'pending', activeForm: 'Task 1' }, { content: 'Task 2', status: 'in_progress', activeForm: 'Task 2' }, { content: 'Task 3', status: 'completed', activeForm: 'Task 3' }, ]; panel.updateTodos(todos); expect(containerEl.querySelector('.claudian-todo-pending')).not.toBeNull(); expect(containerEl.querySelector('.claudian-todo-in_progress')).not.toBeNull(); expect(containerEl.querySelector('.claudian-todo-completed')).not.toBeNull(); }); it('should handle updateTodos called before mount with todos to display', () => { const unmountedPanel = new StatusPanel(); // Should not throw, just silently handle unmounted state expect(() => { unmountedPanel.updateTodos([{ content: 'Task', status: 'pending', activeForm: 'Task' }]); }).not.toThrow(); }); it('should handle updateTodos called with null before mount', () => { const unmountedPanel = new StatusPanel(); // Should not throw expect(() => { unmountedPanel.updateTodos(null); }).not.toThrow(); }); }); describe('toggle', () => { beforeEach(() => { panel.mount(containerEl as unknown as HTMLElement); panel.updateTodos([ { content: 'Task 1', status: 'in_progress', activeForm: 'Doing Task 1' }, ]); }); it('should expand content on header click', () => { const header = containerEl.querySelector('.claudian-status-panel-header'); const content = containerEl.querySelector('.claudian-status-panel-content'); expect(content!.style.display).toBe('none'); header!.click(); expect(content!.style.display).toBe('block'); }); it('should collapse content on second click', () => { const header = containerEl.querySelector('.claudian-status-panel-header'); const content = containerEl.querySelector('.claudian-status-panel-content'); header!.click(); expect(content!.style.display).toBe('block'); header!.click(); expect(content!.style.display).toBe('none'); }); it('should show list icon in header', () => { const icon = containerEl.querySelector('.claudian-status-panel-icon'); expect(icon).not.toBeNull(); expect(icon?.getAttribute('data-icon')).toBe('list-checks'); }); it('should hide current task when expanded', () => { const header = containerEl.querySelector('.claudian-status-panel-header'); expect(containerEl.querySelector('.claudian-status-panel-current')).not.toBeNull(); header!.click(); expect(containerEl.querySelector('.claudian-status-panel-current')).toBeNull(); }); it('should toggle on Enter key', () => { const header = containerEl.querySelector('.claudian-status-panel-header'); const content = containerEl.querySelector('.claudian-status-panel-content'); const event = { type: 'keydown', key: 'Enter', preventDefault: jest.fn() }; header!.dispatchEvent(event); expect(content!.style.display).toBe('block'); expect(event.preventDefault).toHaveBeenCalled(); }); it('should toggle on Space key', () => { const header = containerEl.querySelector('.claudian-status-panel-header'); const content = containerEl.querySelector('.claudian-status-panel-content'); const event = { type: 'keydown', key: ' ', preventDefault: jest.fn() }; header!.dispatchEvent(event); expect(content!.style.display).toBe('block'); expect(event.preventDefault).toHaveBeenCalled(); }); it('should not toggle on other keys', () => { const header = containerEl.querySelector('.claudian-status-panel-header'); const content = containerEl.querySelector('.claudian-status-panel-content'); const event = { type: 'keydown', key: 'Tab', preventDefault: jest.fn() }; header!.dispatchEvent(event); expect(content!.style.display).toBe('none'); expect(event.preventDefault).not.toHaveBeenCalled(); }); }); describe('accessibility', () => { beforeEach(() => { panel.mount(containerEl as unknown as HTMLElement); }); it('should set tabindex on header', () => { const header = containerEl.querySelector('.claudian-status-panel-header'); expect(header?.getAttribute('tabindex')).toBe('0'); }); it('should set role button on header', () => { const header = containerEl.querySelector('.claudian-status-panel-header'); expect(header?.getAttribute('role')).toBe('button'); }); it('should update aria-expanded on toggle', () => { panel.updateTodos([{ content: 'Task', status: 'pending', activeForm: 'Task' }]); const header = containerEl.querySelector('.claudian-status-panel-header'); expect(header!.getAttribute('aria-expanded')).toBe('false'); header!.click(); expect(header!.getAttribute('aria-expanded')).toBe('true'); header!.click(); expect(header!.getAttribute('aria-expanded')).toBe('false'); }); it('should set descriptive aria-label', () => { panel.updateTodos([ { content: 'Task 1', status: 'completed', activeForm: 'Task 1' }, { content: 'Task 2', status: 'pending', activeForm: 'Task 2' }, ]); const header = containerEl.querySelector('.claudian-status-panel-header'); expect(header?.getAttribute('aria-label')).toBe('Expand task list - 1 of 2 completed'); }); it('should hide status icons from screen readers', () => { panel.updateTodos([{ content: 'Task', status: 'pending', activeForm: 'Task' }]); const icon = containerEl.querySelector('.claudian-todo-status-icon'); expect(icon?.getAttribute('aria-hidden')).toBe('true'); }); }); describe('remount', () => { it('should re-create panel structure after remount', () => { panel.mount(containerEl as unknown as HTMLElement); panel.updateTodos([ { content: 'Task 1', status: 'completed', activeForm: 'Doing Task 1' }, { content: 'Task 2', status: 'in_progress', activeForm: 'Doing Task 2' }, ]); panel.remount(); expect(containerEl.querySelector('.claudian-status-panel')).not.toBeNull(); const label = containerEl.querySelector('.claudian-status-panel-label'); expect(label?.textContent).toBe('Tasks (1/2)'); }); it('should not throw when called without mount', () => { const unmountedPanel = new StatusPanel(); expect(() => unmountedPanel.remount()).not.toThrow(); }); it('should clean up event listeners before remounting', () => { panel.mount(containerEl as unknown as HTMLElement); panel.updateTodos([ { content: 'Task 1', status: 'in_progress', activeForm: 'Doing Task 1' }, ]); const header = containerEl.querySelector('.claudian-status-panel-header'); header!.click(); panel.remount(); const content = containerEl.querySelector('.claudian-status-panel-content'); expect(content!.style.display).toBe('none'); }); }); describe('completion status icon', () => { beforeEach(() => { panel.mount(containerEl as unknown as HTMLElement); }); it('should show check icon when all todos are completed', () => { panel.updateTodos([ { content: 'Task 1', status: 'completed', activeForm: 'Task 1' }, { content: 'Task 2', status: 'completed', activeForm: 'Task 2' }, ]); const status = containerEl.querySelector('.status-completed'); expect(status).not.toBeNull(); expect(status?.getAttribute('data-icon')).toBe('check'); }); it('should not show check icon when some todos are incomplete', () => { panel.updateTodos([ { content: 'Task 1', status: 'completed', activeForm: 'Task 1' }, { content: 'Task 2', status: 'pending', activeForm: 'Task 2' }, ]); const status = containerEl.querySelector('.status-completed'); expect(status).toBeNull(); }); }); describe('destroy', () => { it('should remove panel from DOM', () => { panel.mount(containerEl as unknown as HTMLElement); expect(containerEl.querySelector('.claudian-status-panel')).not.toBeNull(); panel.destroy(); expect(containerEl.querySelector('.claudian-status-panel')).toBeNull(); }); it('should be safe to call multiple times', () => { panel.mount(containerEl as unknown as HTMLElement); expect(() => { panel.destroy(); panel.destroy(); }).not.toThrow(); }); it('should handle destroy without mount', () => { const unmountedPanel = new StatusPanel(); expect(() => { unmountedPanel.destroy(); }).not.toThrow(); }); }); describe('bash outputs', () => { beforeEach(() => { panel.mount(containerEl as unknown as HTMLElement); }); it('should render bash section with header and entries', () => { panel.addBashOutput({ id: 'bash-1', command: 'echo hello', status: 'completed', output: 'hello', exitCode: 0, }); const bashContainer = containerEl.querySelector('.claudian-status-panel-bash'); expect(bashContainer).not.toBeNull(); expect(bashContainer!.style.display).toBe('block'); const header = containerEl.querySelector('.claudian-status-panel-bash-header'); expect(header).not.toBeNull(); const label = header!.querySelector('.claudian-tool-label'); expect(label).not.toBeNull(); expect(label!.textContent).toBe('Command panel'); const entries = containerEl.querySelectorAll('.claudian-status-panel-bash-entry'); expect(entries.length).toBe(1); }); it('should collapse and expand the bash section', () => { panel.addBashOutput({ id: 'bash-1', command: 'echo hello', status: 'completed', output: 'hello', exitCode: 0, }); const content = containerEl.querySelector('.claudian-status-panel-bash-content'); expect(content).not.toBeNull(); expect(content!.style.display).toBe('block'); const header = containerEl.querySelector('.claudian-status-panel-bash-header'); expect(header).not.toBeNull(); const label = header!.querySelector('.claudian-tool-label'); expect(label).not.toBeNull(); expect(label!.textContent).toBe('Command panel'); header!.click(); expect(content!.style.display).toBe('none'); const collapsedHeader = containerEl.querySelector('.claudian-status-panel-bash-header'); expect(collapsedHeader).not.toBeNull(); const collapsedLabel = collapsedHeader!.querySelector('.claudian-tool-label'); expect(collapsedLabel).not.toBeNull(); expect(collapsedLabel!.textContent).toBe('echo hello'); header!.click(); expect(content!.style.display).toBe('block'); const expandedHeaderAgain = containerEl.querySelector('.claudian-status-panel-bash-header'); expect(expandedHeaderAgain).not.toBeNull(); const expandedLabelAgain = expandedHeaderAgain!.querySelector('.claudian-tool-label'); expect(expandedLabelAgain).not.toBeNull(); expect(expandedLabelAgain!.textContent).toBe('Command panel'); }); it('should collapse and expand individual bash output entries', () => { panel.addBashOutput({ id: 'bash-1', command: 'echo hello', status: 'completed', output: 'hello', exitCode: 0, }); const entry = containerEl.querySelector('.claudian-status-panel-bash-entry'); expect(entry).not.toBeNull(); const entryHeader = entry!.querySelector('.claudian-tool-header'); const entryContent = entry!.querySelector('.claudian-tool-content'); expect(entryContent).not.toBeNull(); expect(entryContent!.style.display).toBe('block'); expect(entryHeader!.getAttribute('aria-expanded')).toBe('true'); entryHeader!.click(); const entryAfterClick = containerEl.querySelector('.claudian-status-panel-bash-entry'); const contentAfterClick = entryAfterClick!.querySelector('.claudian-tool-content'); const headerAfterClick = entryAfterClick!.querySelector('.claudian-tool-header'); expect(contentAfterClick!.style.display).toBe('none'); expect(headerAfterClick!.getAttribute('aria-expanded')).toBe('false'); const event = { type: 'keydown', key: 'Enter', preventDefault: jest.fn() }; headerAfterClick!.dispatchEvent(event); const entryAfterKeydown = containerEl.querySelector('.claudian-status-panel-bash-entry'); const contentAfterKeydown = entryAfterKeydown!.querySelector('.claudian-tool-content'); const headerAfterKeydown = entryAfterKeydown!.querySelector('.claudian-tool-header'); expect(event.preventDefault).toHaveBeenCalled(); expect(contentAfterKeydown!.style.display).toBe('block'); expect(headerAfterKeydown!.getAttribute('aria-expanded')).toBe('true'); }); it('should clear bash outputs via action button', () => { panel.addBashOutput({ id: 'bash-1', command: 'echo hello', status: 'completed', output: 'hello', exitCode: 0, }); const clearButton = containerEl.querySelector('.claudian-status-panel-bash-action-clear'); expect(clearButton).not.toBeNull(); clearButton!.click(); const bashContainer = containerEl.querySelector('.claudian-status-panel-bash'); expect(bashContainer).not.toBeNull(); expect(bashContainer!.style.display).toBe('none'); }); it('should stopPropagation on clear button keydown to prevent header toggle', () => { panel.addBashOutput({ id: 'bash-1', command: 'echo hello', status: 'completed', output: 'hello', exitCode: 0, }); const content = containerEl.querySelector('.claudian-status-panel-bash-content'); expect(content!.style.display).toBe('block'); const clearButton = containerEl.querySelector('.claudian-status-panel-bash-action-clear'); expect(clearButton).not.toBeNull(); const event = { type: 'keydown', key: 'Enter', preventDefault: jest.fn(), stopPropagation: jest.fn() }; clearButton!.dispatchEvent(event); expect(event.preventDefault).toHaveBeenCalled(); expect(event.stopPropagation).toHaveBeenCalled(); }); it('should copy latest bash output via action button', async () => { panel.addBashOutput({ id: 'bash-1', command: 'echo hello', status: 'completed', output: 'hello', exitCode: 0, }); const copyButton = containerEl.querySelector('.claudian-status-panel-bash-action-copy'); expect(copyButton).not.toBeNull(); copyButton!.click(); await Promise.resolve(); expect(writeTextMock).toHaveBeenCalledWith('$ echo hello\nhello'); }); it('should stopPropagation on copy button keydown to prevent header toggle', async () => { panel.addBashOutput({ id: 'bash-1', command: 'echo hello', status: 'completed', output: 'hello', exitCode: 0, }); const content = containerEl.querySelector('.claudian-status-panel-bash-content'); expect(content!.style.display).toBe('block'); const copyButton = containerEl.querySelector('.claudian-status-panel-bash-action-copy'); expect(copyButton).not.toBeNull(); const event = { type: 'keydown', key: ' ', preventDefault: jest.fn(), stopPropagation: jest.fn() }; copyButton!.dispatchEvent(event); expect(event.preventDefault).toHaveBeenCalled(); expect(event.stopPropagation).toHaveBeenCalled(); await Promise.resolve(); expect(writeTextMock).toHaveBeenCalledWith('$ echo hello\nhello'); }); it('should cap bash outputs to the most recent entries', () => { for (let i = 0; i < 55; i++) { panel.addBashOutput({ id: `bash-${i}`, command: `echo ${i}`, status: 'completed', output: `${i}`, exitCode: 0, }); } const entries = containerEl.querySelectorAll('.claudian-status-panel-bash-entry'); expect(entries.length).toBe(50); }); it('should scroll bash content to bottom when outputs update', () => { panel.addBashOutput({ id: 'bash-1', command: 'echo hello', status: 'completed', output: 'hello', exitCode: 0, }); const content = containerEl.querySelector('.claudian-status-panel-bash-content'); expect(content).not.toBeNull(); expect((content as any).scrollTop).toBe((content as any).scrollHeight); }); it('should update a running bash output to completed with output', () => { panel.addBashOutput({ id: 'bash-1', command: 'echo hello', status: 'running', output: '', }); let entry = containerEl.querySelector('.claudian-status-panel-bash-entry'); let text = entry!.querySelector('.claudian-tool-result-text'); expect(text!.textContent).toBe('Running...'); panel.updateBashOutput('bash-1', { status: 'completed', output: 'hello', exitCode: 0 }); entry = containerEl.querySelector('.claudian-status-panel-bash-entry'); text = entry!.querySelector('.claudian-tool-result-text'); expect(text!.textContent).toBe('hello'); const statusEl = entry!.querySelector('.claudian-tool-status'); expect(statusEl!.classList.contains('status-completed')).toBe(true); }); it('should update a running bash output to error', () => { panel.addBashOutput({ id: 'bash-1', command: 'bad-command', status: 'running', output: '', }); panel.updateBashOutput('bash-1', { status: 'error', output: 'command not found', exitCode: 127 }); const entry = containerEl.querySelector('.claudian-status-panel-bash-entry'); const text = entry!.querySelector('.claudian-tool-result-text'); expect(text!.textContent).toBe('command not found'); const statusEl = entry!.querySelector('.claudian-tool-status'); expect(statusEl!.classList.contains('status-error')).toBe(true); }); it('should be a no-op when updating a non-existent bash output', () => { panel.addBashOutput({ id: 'bash-1', command: 'echo hello', status: 'running', output: '', }); panel.updateBashOutput('nonexistent', { status: 'completed', output: 'done' }); const entry = containerEl.querySelector('.claudian-status-panel-bash-entry'); const text = entry!.querySelector('.claudian-tool-result-text'); expect(text!.textContent).toBe('Running...'); }); it('should set aria-expanded on the bash section header', () => { panel.addBashOutput({ id: 'bash-1', command: 'echo hello', status: 'completed', output: 'hello', exitCode: 0, }); const header = containerEl.querySelector('.claudian-status-panel-bash-header'); expect(header!.getAttribute('aria-expanded')).toBe('true'); header!.click(); const headerAfterCollapse = containerEl.querySelector('.claudian-status-panel-bash-header'); expect(headerAfterCollapse!.getAttribute('aria-expanded')).toBe('false'); headerAfterCollapse!.click(); const headerAfterExpand = containerEl.querySelector('.claudian-status-panel-bash-header'); expect(headerAfterExpand!.getAttribute('aria-expanded')).toBe('true'); }); it('should handle clipboard failure gracefully', async () => { writeTextMock.mockRejectedValueOnce(new Error('Clipboard denied')); panel.addBashOutput({ id: 'bash-1', command: 'echo hello', status: 'completed', output: 'hello', exitCode: 0, }); const copyButton = containerEl.querySelector('.claudian-status-panel-bash-action-copy'); expect(copyButton).not.toBeNull(); copyButton!.click(); await Promise.resolve(); expect(writeTextMock).toHaveBeenCalled(); }); }); }); ================================================ FILE: tests/unit/features/chat/ui/file-context/state/FileContextState.test.ts ================================================ import { FileContextState } from '@/features/chat/ui/file-context/state/FileContextState'; describe('FileContextState', () => { let state: FileContextState; beforeEach(() => { state = new FileContextState(); }); describe('initial state', () => { it('should start with no attached files', () => { expect(state.getAttachedFiles().size).toBe(0); }); it('should start with session not started', () => { expect(state.isSessionStarted()).toBe(false); }); it('should start with current note not sent', () => { expect(state.hasSentCurrentNote()).toBe(false); }); it('should start with no MCP mentions', () => { expect(state.getMentionedMcpServers().size).toBe(0); }); }); describe('session lifecycle', () => { it('should mark session as started', () => { state.startSession(); expect(state.isSessionStarted()).toBe(true); }); it('should mark current note as sent', () => { state.markCurrentNoteSent(); expect(state.hasSentCurrentNote()).toBe(true); }); }); describe('resetForNewConversation', () => { it('should reset all state', () => { state.startSession(); state.markCurrentNoteSent(); state.attachFile('file1.md'); state.addMentionedMcpServer('server1'); state.resetForNewConversation(); expect(state.isSessionStarted()).toBe(false); expect(state.hasSentCurrentNote()).toBe(false); expect(state.getAttachedFiles().size).toBe(0); expect(state.getMentionedMcpServers().size).toBe(0); }); }); describe('resetForLoadedConversation', () => { it('should set state based on whether conversation has messages', () => { state.attachFile('file1.md'); state.addMentionedMcpServer('server1'); state.resetForLoadedConversation(true); expect(state.isSessionStarted()).toBe(true); expect(state.hasSentCurrentNote()).toBe(true); expect(state.getAttachedFiles().size).toBe(0); expect(state.getMentionedMcpServers().size).toBe(0); }); it('should not mark as started when no messages', () => { state.resetForLoadedConversation(false); expect(state.isSessionStarted()).toBe(false); expect(state.hasSentCurrentNote()).toBe(false); }); }); describe('file attachments', () => { it('should attach a file', () => { state.attachFile('test.md'); expect(state.getAttachedFiles().has('test.md')).toBe(true); }); it('should return a copy of attached files (not the internal set)', () => { state.attachFile('test.md'); const files = state.getAttachedFiles(); files.add('other.md'); expect(state.getAttachedFiles().has('other.md')).toBe(false); }); it('should detach a file', () => { state.attachFile('test.md'); state.detachFile('test.md'); expect(state.getAttachedFiles().has('test.md')).toBe(false); }); it('should set attached files replacing existing', () => { state.attachFile('old.md'); state.setAttachedFiles(['new1.md', 'new2.md']); const files = state.getAttachedFiles(); expect(files.has('old.md')).toBe(false); expect(files.has('new1.md')).toBe(true); expect(files.has('new2.md')).toBe(true); }); it('should clear all attachments', () => { state.attachFile('a.md'); state.clearAttachments(); expect(state.getAttachedFiles().size).toBe(0); }); }); describe('MCP server mentions', () => { it('should add a mentioned MCP server', () => { state.addMentionedMcpServer('server1'); expect(state.getMentionedMcpServers().has('server1')).toBe(true); }); it('should return a copy of mentioned servers', () => { state.addMentionedMcpServer('server1'); const servers = state.getMentionedMcpServers(); servers.add('server2'); expect(state.getMentionedMcpServers().has('server2')).toBe(false); }); it('should clear MCP mentions', () => { state.addMentionedMcpServer('server1'); state.clearMcpMentions(); expect(state.getMentionedMcpServers().size).toBe(0); }); it('should set mentioned MCP servers and return true when changed', () => { const changed = state.setMentionedMcpServers(new Set(['a', 'b'])); expect(changed).toBe(true); expect(state.getMentionedMcpServers()).toEqual(new Set(['a', 'b'])); }); it('should return false when setting same servers', () => { state.setMentionedMcpServers(new Set(['a', 'b'])); const changed = state.setMentionedMcpServers(new Set(['a', 'b'])); expect(changed).toBe(false); }); it('should return true when sizes differ', () => { state.setMentionedMcpServers(new Set(['a'])); const changed = state.setMentionedMcpServers(new Set(['a', 'b'])); expect(changed).toBe(true); }); it('should return true when same size but different contents', () => { state.setMentionedMcpServers(new Set(['a', 'b'])); const changed = state.setMentionedMcpServers(new Set(['a', 'c'])); expect(changed).toBe(true); }); }); }); ================================================ FILE: tests/unit/features/inline-edit/InlineEditService.test.ts ================================================ // eslint-disable-next-line jest/no-mocks-import import { getLastOptions, resetMockMessages, setMockMessages, } from '@test/__mocks__/claude-agent-sdk'; import * as fs from 'fs'; import * as os from 'os'; // Mock fs module jest.mock('fs'); // Now import after all mocks are set up import { getPathFromToolInput } from '@/core/tools/toolInput'; import type { InlineEditRequest } from '@/features/inline-edit/InlineEditService'; import { buildInlineEditPrompt, createReadOnlyHook, createVaultRestrictionHook, extractTextFromSdkMessage, InlineEditService, parseInlineEditResponse, } from '@/features/inline-edit/InlineEditService'; import { buildCursorContext } from '@/utils/editor'; // Create a mock plugin function createMockPlugin(settings = {}) { return { settings: { model: 'sonnet', thinkingBudget: 'off', allowExternalAccess: false, ...settings, }, app: { vault: { adapter: { basePath: '/test/vault/path', }, }, }, getActiveEnvironmentVariables: jest.fn().mockReturnValue(''), getResolvedClaudeCliPath: jest.fn().mockReturnValue('/fake/claude'), } as any; } // Hook functions accept typed HookInput / return typed HookJSONOutput, but the // implementation only reads tool_name/tool_input. Cast I/O to any in tests. // eslint-disable-next-line @typescript-eslint/no-explicit-any const callHook = async (hook: any, input: any, ...rest: any[]): Promise<any> => hook(input, ...rest); describe('InlineEditService', () => { let service: InlineEditService; let mockPlugin: any; beforeEach(() => { jest.clearAllMocks(); resetMockMessages(); mockPlugin = createMockPlugin(); service = new InlineEditService(mockPlugin); }); describe('vault restriction hook', () => { beforeEach(() => { const normalizePath = (p: string) => { // eslint-disable-next-line @typescript-eslint/no-require-imports const pathModule = require('path'); return pathModule.resolve(p); }; (fs.realpathSync as any) = jest.fn(normalizePath); if (fs.realpathSync) { (fs.realpathSync as any).native = jest.fn(normalizePath); } }); afterEach(() => { jest.restoreAllMocks(); }); it('should block Read outside vault', async () => { const hook = createVaultRestrictionHook('/test/vault/path'); const res = await callHook(hook.hooks[0], { tool_name: 'Read', tool_input: { file_path: '/etc/passwd' } }, 'tool-1', {}, ); expect(res.continue).toBe(false); expect(res.hookSpecificOutput.permissionDecisionReason).toContain('outside allowed paths'); }); it('should allow Read inside vault', async () => { const hook = createVaultRestrictionHook('/test/vault/path'); const res = await callHook(hook.hooks[0], { tool_name: 'Read', tool_input: { file_path: '/test/vault/path/notes/a.md' } }, 'tool-2', {}, ); expect(res.continue).toBe(true); }); it('should block Glob escaping pattern', async () => { const hook = createVaultRestrictionHook('/test/vault/path'); const res = await callHook(hook.hooks[0], { tool_name: 'Glob', tool_input: { pattern: '../**/*.md' } }, 'tool-3', {}, ); expect(res.continue).toBe(false); }); it('should allow Read inside ~/.claude/ directory', async () => { // Mock os.homedir to return a known path jest.spyOn(os, 'homedir').mockReturnValue('/home/test'); const hook = createVaultRestrictionHook('/test/vault/path'); const res = await callHook(hook.hooks[0], { tool_name: 'Read', tool_input: { file_path: '/home/test/.claude/settings.json' } }, 'tool-4', {}, ); expect(res.continue).toBe(true); }); it('should allow Glob inside ~/.claude/ directory', async () => { jest.spyOn(os, 'homedir').mockReturnValue('/home/test'); const hook = createVaultRestrictionHook('/test/vault/path'); const res = await callHook(hook.hooks[0], { tool_name: 'Glob', tool_input: { pattern: '/home/test/.claude/**/*.md' } }, 'tool-5', {}, ); expect(res.continue).toBe(true); }); it('should still block paths outside vault and ~/.claude/', async () => { jest.spyOn(os, 'homedir').mockReturnValue('/home/test'); const hook = createVaultRestrictionHook('/test/vault/path'); const res = await callHook(hook.hooks[0], { tool_name: 'Read', tool_input: { file_path: '/home/test/.ssh/id_rsa' } }, 'tool-6', {}, ); expect(res.continue).toBe(false); expect(res.hookSpecificOutput.permissionDecisionReason).toContain('outside allowed paths'); }); it('should block path traversal via ~/.claude/../ to escape allowed directory', async () => { jest.spyOn(os, 'homedir').mockReturnValue('/home/test'); const hook = createVaultRestrictionHook('/test/vault/path'); const res = await callHook(hook.hooks[0], { tool_name: 'Read', tool_input: { file_path: '/home/test/.claude/../.ssh/id_rsa' } }, 'tool-7', {}, ); expect(res.continue).toBe(false); expect(res.hookSpecificOutput.permissionDecisionReason).toContain('outside allowed paths'); }); it('should deny when path cannot be determined (fail-closed)', async () => { const hook = createVaultRestrictionHook('/test/vault/path'); const res = await callHook(hook.hooks[0], { tool_name: 'Read', tool_input: {} }, 'tool-8', {}, ); expect(res.continue).toBe(false); expect(res.hookSpecificOutput.permissionDecision).toBe('deny'); expect(res.hookSpecificOutput.permissionDecisionReason).toContain('Could not determine path'); }); it('should deny when path validation throws (fail-closed)', async () => { const hook = createVaultRestrictionHook('/test/vault/path'); const res = await callHook(hook.hooks[0], { tool_name: 'Read', tool_input: { file_path: '/outside/vault/file.txt' } }, 'tool-9', {}, ); expect(res.continue).toBe(false); expect(res.hookSpecificOutput.permissionDecision).toBe('deny'); }); }); describe('buildPrompt', () => { it('should build prompt with correct format', () => { const request: InlineEditRequest = { mode: 'selection', selectedText: 'Hello world', instruction: 'Fix the greeting', notePath: 'notes/test.md', }; const prompt = buildInlineEditPrompt(request); // New format: instruction first, then XML context (no <query> wrapper) expect(prompt).toContain('Fix the greeting'); expect(prompt).toContain('<editor_selection path="notes/test.md">'); expect(prompt).toContain('Hello world'); expect(prompt).toContain('</editor_selection>'); // Verify instruction comes before selection expect(prompt.indexOf('Fix the greeting')).toBeLessThan(prompt.indexOf('<editor_selection')); }); it('should preserve selected text with newlines', () => { const request: InlineEditRequest = { mode: 'selection', selectedText: 'Line 1\nLine 2\nLine 3', instruction: 'Fix formatting', notePath: 'doc.md', }; const prompt = buildInlineEditPrompt(request); expect(prompt).toContain('Line 1\nLine 2\nLine 3'); }); it('should handle empty selected text', () => { const request: InlineEditRequest = { mode: 'selection', selectedText: '', instruction: 'Add content', notePath: 'empty.md', }; const prompt = buildInlineEditPrompt(request); // New format: instruction first, then XML context (no <query> wrapper) expect(prompt).toContain('Add content'); expect(prompt).toContain('<editor_selection path="empty.md">'); // Verify instruction comes before selection expect(prompt.indexOf('Add content')).toBeLessThan(prompt.indexOf('<editor_selection')); }); it('should append context files when provided', () => { const request: InlineEditRequest = { mode: 'selection', selectedText: 'test', instruction: 'Fix this', notePath: 'test.md', contextFiles: ['notes/helper.md', 'docs/api.md'], }; const prompt = buildInlineEditPrompt(request); // Context files should be appended with <context_files> tag expect(prompt).toContain('<context_files>'); expect(prompt).toContain('notes/helper.md'); expect(prompt).toContain('docs/api.md'); expect(prompt).toContain('</context_files>'); // Original content should still be present expect(prompt).toContain('<editor_selection'); expect(prompt).toContain('Fix this'); // Verify order: instruction, selection, context_files expect(prompt.indexOf('Fix this')).toBeLessThan(prompt.indexOf('<editor_selection')); expect(prompt.indexOf('<editor_selection')).toBeLessThan(prompt.indexOf('<context_files')); }); it('should not modify prompt when contextFiles is empty', () => { const request: InlineEditRequest = { mode: 'selection', selectedText: 'test', instruction: 'Fix this', notePath: 'test.md', contextFiles: [], }; const prompt = buildInlineEditPrompt(request); expect(prompt).not.toContain('<context_files>'); expect(prompt).toContain('<editor_selection'); }); it('should not modify prompt when contextFiles is undefined', () => { const request: InlineEditRequest = { mode: 'selection', selectedText: 'test', instruction: 'Fix this', notePath: 'test.md', }; const prompt = buildInlineEditPrompt(request); expect(prompt).not.toContain('<context_files>'); expect(prompt).toContain('<editor_selection'); }); it('should prepend context files for cursor mode', () => { const request: InlineEditRequest = { mode: 'cursor', instruction: 'insert here', notePath: 'test.md', cursorContext: { beforeCursor: 'before', afterCursor: 'after', isInbetween: false, line: 0, column: 6, }, contextFiles: ['utils.ts'], }; const prompt = buildInlineEditPrompt(request); expect(prompt).toContain('<context_files>'); expect(prompt).toContain('utils.ts'); expect(prompt).toContain('<editor_cursor'); }); }); describe('parseResponse', () => { it('should extract text from replacement tags', () => { const response = 'Here is the edit:\n<replacement>Fixed text here</replacement>'; const result = parseInlineEditResponse(response); expect(result.success).toBe(true); expect(result.editedText).toBe('Fixed text here'); }); it('should handle multiline replacement content', () => { const response = '<replacement>Line 1\nLine 2\nLine 3</replacement>'; const result = parseInlineEditResponse(response); expect(result.success).toBe(true); expect(result.editedText).toBe('Line 1\nLine 2\nLine 3'); }); it('should return clarification when no replacement tags', () => { const response = 'Could you please clarify what you mean by "fix"?'; const result = parseInlineEditResponse(response); expect(result.success).toBe(true); expect(result.clarification).toBe('Could you please clarify what you mean by "fix"?'); expect(result.editedText).toBeUndefined(); }); it('should return error for empty response', () => { const result = parseInlineEditResponse(''); expect(result.success).toBe(false); expect(result.error).toBe('Empty response'); }); it('should return error for whitespace-only response', () => { const result = parseInlineEditResponse(' \n\t '); expect(result.success).toBe(false); expect(result.error).toBe('Empty response'); }); it('should handle replacement tags with special characters', () => { const response = '<replacement>const x = a < b && c > d;</replacement>'; const result = parseInlineEditResponse(response); expect(result.success).toBe(true); expect(result.editedText).toBe('const x = a < b && c > d;'); }); it('should extract first replacement tag if multiple exist', () => { const response = '<replacement>first</replacement> then <replacement>second</replacement>'; const result = parseInlineEditResponse(response); expect(result.success).toBe(true); expect(result.editedText).toBe('first'); }); it('should handle empty replacement tags', () => { const response = '<replacement></replacement>'; const result = parseInlineEditResponse(response); expect(result.success).toBe(true); expect(result.editedText).toBe(''); }); }); describe('editText', () => { beforeEach(() => { (fs.existsSync as jest.Mock).mockReturnValue(true); }); it('should return error when vault path cannot be determined', async () => { mockPlugin.app.vault.adapter.basePath = undefined; service = new InlineEditService(mockPlugin); const result = await service.editText({ mode: 'selection', selectedText: 'test', instruction: 'fix', notePath: 'test.md', }); expect(result.success).toBe(false); expect(result.error).toContain('vault path'); }); it('should return error when claude CLI not found', async () => { mockPlugin.getResolvedClaudeCliPath.mockReturnValue(null); const result = await service.editText({ mode: 'selection', selectedText: 'test', instruction: 'fix', notePath: 'test.md', }); expect(result.success).toBe(false); expect(result.error).toContain('Claude CLI not found'); }); it('should use restricted read-only tools', async () => { setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, { type: 'assistant', message: { content: [{ type: 'text', text: '<replacement>fixed</replacement>' }] }, }, { type: 'result' }, ]); await service.editText({ mode: 'selection', selectedText: 'test', instruction: 'fix', notePath: 'test.md', }); const options = getLastOptions(); expect(options?.tools).toContain('Read'); expect(options?.tools).toContain('Grep'); expect(options?.tools).toContain('Glob'); expect(options?.tools).toContain('LS'); expect(options?.tools).toContain('WebSearch'); expect(options?.tools).toContain('WebFetch'); // Should NOT include write tools expect(options?.tools).not.toContain('Write'); expect(options?.tools).not.toContain('Edit'); expect(options?.tools).not.toContain('Bash'); }); it('should bypass permissions for read-only tools', async () => { setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, { type: 'assistant', message: { content: [{ type: 'text', text: '<replacement>fixed</replacement>' }] }, }, { type: 'result' }, ]); await service.editText({ mode: 'selection', selectedText: 'test', instruction: 'fix', notePath: 'test.md', }); const options = getLastOptions(); expect(options?.permissionMode).toBe('bypassPermissions'); }); it('should omit vault restriction hook when external access is enabled', async () => { mockPlugin.settings.allowExternalAccess = true; service = new InlineEditService(mockPlugin); setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, { type: 'assistant', message: { content: [{ type: 'text', text: '<replacement>fixed</replacement>' }] }, }, { type: 'result' }, ]); await service.editText({ mode: 'selection', selectedText: 'test', instruction: 'fix', notePath: 'test.md', }); const options = getLastOptions(); expect(options?.hooks?.PreToolUse).toHaveLength(1); const hookResult = await callHook( options?.hooks?.PreToolUse?.[0].hooks[0], { tool_name: 'Read', tool_input: { file_path: '/etc/passwd' } }, 'tool-allow-external', {}, ); expect(hookResult.continue).toBe(true); }); it('should set settingSources to project only when loadUserClaudeSettings is false', async () => { mockPlugin.settings.loadUserClaudeSettings = false; service = new InlineEditService(mockPlugin); setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, { type: 'assistant', message: { content: [{ type: 'text', text: '<replacement>fixed</replacement>' }] }, }, { type: 'result' }, ]); await service.editText({ mode: 'selection', selectedText: 'test', instruction: 'fix', notePath: 'test.md', }); const options = getLastOptions(); expect(options?.settingSources).toEqual(['project']); }); it('should set settingSources to include user when loadUserClaudeSettings is true', async () => { mockPlugin.settings.loadUserClaudeSettings = true; service = new InlineEditService(mockPlugin); setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, { type: 'assistant', message: { content: [{ type: 'text', text: '<replacement>fixed</replacement>' }] }, }, { type: 'result' }, ]); await service.editText({ mode: 'selection', selectedText: 'test', instruction: 'fix', notePath: 'test.md', }); const options = getLastOptions(); expect(options?.settingSources).toEqual(['user', 'project']); }); it('should set adaptive thinking for Claude models', async () => { mockPlugin.settings.model = 'sonnet'; service = new InlineEditService(mockPlugin); setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, { type: 'assistant', message: { content: [{ type: 'text', text: '<replacement>fixed</replacement>' }] }, }, { type: 'result' }, ]); await service.editText({ mode: 'selection', selectedText: 'test', instruction: 'fix', notePath: 'test.md', }); const options = getLastOptions(); expect(options?.thinking).toEqual({ type: 'adaptive' }); expect(options?.maxThinkingTokens).toBeUndefined(); }); it('should set thinking budget for custom models', async () => { mockPlugin.settings.model = 'custom-model'; mockPlugin.settings.thinkingBudget = 'medium'; service = new InlineEditService(mockPlugin); setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, { type: 'assistant', message: { content: [{ type: 'text', text: '<replacement>fixed</replacement>' }] }, }, { type: 'result' }, ]); await service.editText({ mode: 'selection', selectedText: 'test', instruction: 'fix', notePath: 'test.md', }); const options = getLastOptions(); expect(options?.maxThinkingTokens).toBeGreaterThan(0); expect(options?.thinking).toBeUndefined(); }); it('should capture session ID for conversation continuity', async () => { setMockMessages([ { type: 'system', subtype: 'init', session_id: 'inline-session-123' }, { type: 'assistant', message: { content: [{ type: 'text', text: 'What do you want to change?' }] }, }, { type: 'result' }, ]); await service.editText({ mode: 'selection', selectedText: 'test', instruction: 'fix', notePath: 'test.md', }); // Verify session was captured by checking continueConversation resumes it setMockMessages([ { type: 'assistant', message: { content: [{ type: 'text', text: '<replacement>fixed</replacement>' }] }, }, { type: 'result' }, ]); await service.continueConversation('make it better'); const options = getLastOptions(); expect(options?.resume).toBe('inline-session-123'); }); it('should return clarification response', async () => { setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, { type: 'assistant', message: { content: [{ type: 'text', text: 'Could you clarify what "fix" means?' }] }, }, { type: 'result' }, ]); const result = await service.editText({ mode: 'selection', selectedText: 'broken code', instruction: 'fix', notePath: 'test.md', }); expect(result.success).toBe(true); expect(result.clarification).toBe('Could you clarify what "fix" means?'); }); }); describe('continueConversation', () => { beforeEach(() => { (fs.existsSync as jest.Mock).mockReturnValue(true); }); it('should return error when no active conversation', async () => { const result = await service.continueConversation('more details'); expect(result.success).toBe(false); expect(result.error).toContain('No active conversation'); }); it('should resume session on follow-up', async () => { // First message to establish session setMockMessages([ { type: 'system', subtype: 'init', session_id: 'continue-session' }, { type: 'assistant', message: { content: [{ type: 'text', text: 'What do you want?' }] }, }, { type: 'result' }, ]); await service.editText({ mode: 'selection', selectedText: 'test', instruction: 'fix', notePath: 'test.md', }); // Follow-up message setMockMessages([ { type: 'assistant', message: { content: [{ type: 'text', text: '<replacement>final result</replacement>' }] }, }, { type: 'result' }, ]); await service.continueConversation('make it blue'); const options = getLastOptions(); expect(options?.resume).toBe('continue-session'); }); it('should prepend context files when provided', async () => { // First message to establish session setMockMessages([ { type: 'system', subtype: 'init', session_id: 'context-session' }, { type: 'assistant', message: { content: [{ type: 'text', text: 'What do you want?' }] }, }, { type: 'result' }, ]); await service.editText({ mode: 'selection', selectedText: 'test', instruction: 'fix', notePath: 'test.md', }); // Follow-up message with context files setMockMessages([ { type: 'assistant', message: { content: [{ type: 'text', text: '<replacement>final result</replacement>' }] }, }, { type: 'result' }, ]); await service.continueConversation('make it blue', ['notes/helper.md', 'docs/api.md']); // The prompt should include the context files // Since we can't directly access the prompt, we verify the session resumed const options = getLastOptions(); expect(options?.resume).toBe('context-session'); }); it('should not modify prompt when no context files provided', async () => { // First message to establish session setMockMessages([ { type: 'system', subtype: 'init', session_id: 'no-context-session' }, { type: 'assistant', message: { content: [{ type: 'text', text: 'What do you want?' }] }, }, { type: 'result' }, ]); await service.editText({ mode: 'selection', selectedText: 'test', instruction: 'fix', notePath: 'test.md', }); // Follow-up without context files setMockMessages([ { type: 'assistant', message: { content: [{ type: 'text', text: '<replacement>result</replacement>' }] }, }, { type: 'result' }, ]); await service.continueConversation('make it blue'); const options = getLastOptions(); expect(options?.resume).toBe('no-context-session'); }); it('should handle empty context files array', async () => { // First message to establish session setMockMessages([ { type: 'system', subtype: 'init', session_id: 'empty-context-session' }, { type: 'assistant', message: { content: [{ type: 'text', text: 'What do you want?' }] }, }, { type: 'result' }, ]); await service.editText({ mode: 'selection', selectedText: 'test', instruction: 'fix', notePath: 'test.md', }); // Follow-up with empty context files array setMockMessages([ { type: 'assistant', message: { content: [{ type: 'text', text: '<replacement>result</replacement>' }] }, }, { type: 'result' }, ]); await service.continueConversation('make it blue', []); const options = getLastOptions(); expect(options?.resume).toBe('empty-context-session'); }); }); describe('resetConversation', () => { it('should clear session so continueConversation fails', async () => { (fs.existsSync as jest.Mock).mockReturnValue(true); // First establish a session setMockMessages([ { type: 'system', subtype: 'init', session_id: 'some-session' }, { type: 'assistant', message: { content: [{ type: 'text', text: 'What do you want?' }] }, }, { type: 'result' }, ]); await service.editText({ mode: 'selection', selectedText: 'test', instruction: 'fix', notePath: 'test.md', }); // Reset should clear the session service.resetConversation(); // continueConversation should fail since session is cleared const result = await service.continueConversation('more details'); expect(result.success).toBe(false); expect(result.error).toContain('No active conversation'); }); }); describe('cancel', () => { it('should abort ongoing request', async () => { (fs.existsSync as jest.Mock).mockReturnValue(true); setMockMessages([ { type: 'system', subtype: 'init', session_id: 'test-session' }, { type: 'assistant', message: { content: [{ type: 'text', text: '<replacement>fixed</replacement>' }] }, }, ]); const editPromise = service.editText({ mode: 'selection', selectedText: 'test', instruction: 'fix', notePath: 'test.md', }); // Cancel immediately service.cancel(); const result = await editPromise; expect(result.success).toBe(false); expect(result.error).toBe('Cancelled'); }); it('should handle cancel when no request is running', () => { expect(() => service.cancel()).not.toThrow(); }); }); describe('read-only hook enforcement', () => { it('should create hook that allows read-only tools', () => { const hook = createReadOnlyHook(); expect(hook.hooks).toHaveLength(1); }); it('should allow Read tool through hook', async () => { const hook = createReadOnlyHook(); const result = await callHook(hook.hooks[0], { tool_name: 'Read', tool_input: { file_path: 'test.md' } }); expect(result.continue).toBe(true); }); it('should allow Grep tool through hook', async () => { const hook = createReadOnlyHook(); const result = await callHook(hook.hooks[0], { tool_name: 'Grep', tool_input: { pattern: 'test' } }); expect(result.continue).toBe(true); }); it('should allow WebSearch tool through hook', async () => { const hook = createReadOnlyHook(); const result = await callHook(hook.hooks[0], { tool_name: 'WebSearch', tool_input: { query: 'test' } }); expect(result.continue).toBe(true); }); it('should block Write tool through hook', async () => { const hook = createReadOnlyHook(); const result = await callHook(hook.hooks[0], { tool_name: 'Write', tool_input: { file_path: 'test.md' } }); expect(result.continue).toBe(false); expect(result.hookSpecificOutput.permissionDecision).toBe('deny'); expect(result.hookSpecificOutput.permissionDecisionReason).toContain('not allowed'); }); it('should block Bash tool through hook', async () => { const hook = createReadOnlyHook(); const result = await callHook(hook.hooks[0], { tool_name: 'Bash', tool_input: { command: 'rm -rf /' } }); expect(result.continue).toBe(false); expect(result.hookSpecificOutput.permissionDecision).toBe('deny'); }); it('should block Edit tool through hook', async () => { const hook = createReadOnlyHook(); const result = await callHook(hook.hooks[0], { tool_name: 'Edit', tool_input: { file_path: 'test.md' } }); expect(result.continue).toBe(false); }); }); describe('extractTextFromMessage', () => { it('should extract text from assistant message', () => { const message = { type: 'assistant', message: { content: [{ type: 'text', text: 'Hello world' }], }, }; const text = extractTextFromSdkMessage(message); expect(text).toBe('Hello world'); }); it('should extract text from content_block_start stream event', () => { const message = { type: 'stream_event', event: { type: 'content_block_start', content_block: { type: 'text', text: 'Starting...' }, }, }; const text = extractTextFromSdkMessage(message); expect(text).toBe('Starting...'); }); it('should extract text from content_block_delta stream event', () => { const message = { type: 'stream_event', event: { type: 'content_block_delta', delta: { type: 'text_delta', text: ' more text' }, }, }; const text = extractTextFromSdkMessage(message); expect(text).toBe(' more text'); }); it('should return null for non-text messages', () => { const message = { type: 'system', subtype: 'init', }; const text = extractTextFromSdkMessage(message); expect(text).toBeNull(); }); it('should return null for thinking blocks', () => { const message = { type: 'assistant', message: { content: [{ type: 'thinking', thinking: 'Let me think...' }], }, }; const text = extractTextFromSdkMessage(message); expect(text).toBeNull(); }); }); describe('error handling', () => { beforeEach(() => { (fs.existsSync as jest.Mock).mockReturnValue(true); }); it('should surface SDK query errors', async () => { // eslint-disable-next-line @typescript-eslint/no-require-imports const sdk = require('@anthropic-ai/claude-agent-sdk'); const spy = jest.spyOn(sdk, 'query').mockImplementation(() => { throw new Error('boom'); }); try { const result = await service.editText({ mode: 'selection', selectedText: 'text', instruction: 'edit', notePath: 'note.md', }); expect(result.success).toBe(false); expect(result.error).toBe('boom'); } finally { spy.mockRestore(); } }); it('returns null path for unknown tool input', () => { expect(getPathFromToolInput('Unknown', {})).toBeNull(); }); it('allows non-file tools in vault restriction hook', async () => { const hook = createVaultRestrictionHook('/test/vault/path'); const res = await callHook(hook.hooks[0], { tool_name: 'WebSearch', tool_input: {} }, 't', {}); expect(res.continue).toBe(true); }); it('extracts LS path from tool input', () => { expect(getPathFromToolInput('LS', { path: 'notes' })).toBe('notes'); }); }); describe('buildCursorContext', () => { // Helper to create mock getLine function from array of lines const createGetLine = (lines: string[]) => (line: number) => lines[line] ?? ''; describe('inline mode detection', () => { it('should detect inline when cursor is in middle of text', () => { const lines = ['Hello world']; const ctx = buildCursorContext(createGetLine(lines), 1, 0, 6); expect(ctx.isInbetween).toBe(false); expect(ctx.beforeCursor).toBe('Hello '); expect(ctx.afterCursor).toBe('world'); expect(ctx.line).toBe(0); expect(ctx.column).toBe(6); }); it('should detect inline when cursor is at line start with text after', () => { const lines = ['Hello world']; const ctx = buildCursorContext(createGetLine(lines), 1, 0, 0); expect(ctx.isInbetween).toBe(false); expect(ctx.beforeCursor).toBe(''); expect(ctx.afterCursor).toBe('Hello world'); }); it('should detect inline when cursor is at line end with text before', () => { const lines = ['Hello world']; const ctx = buildCursorContext(createGetLine(lines), 1, 0, 11); expect(ctx.isInbetween).toBe(false); expect(ctx.beforeCursor).toBe('Hello world'); expect(ctx.afterCursor).toBe(''); }); it('should preserve whitespace around cursor', () => { const lines = [' hello world ']; const ctx = buildCursorContext(createGetLine(lines), 1, 0, 9); expect(ctx.isInbetween).toBe(false); expect(ctx.beforeCursor).toBe(' hello '); expect(ctx.afterCursor).toBe(' world '); }); }); describe('inbetween mode detection', () => { it('should detect inbetween on empty line', () => { const lines = ['First paragraph', '', 'Second paragraph']; const ctx = buildCursorContext(createGetLine(lines), 3, 1, 0); expect(ctx.isInbetween).toBe(true); expect(ctx.beforeCursor).toBe('First paragraph'); expect(ctx.afterCursor).toBe('Second paragraph'); }); it('should detect inbetween on whitespace-only line', () => { const lines = ['First paragraph', ' ', 'Second paragraph']; const ctx = buildCursorContext(createGetLine(lines), 3, 1, 2); expect(ctx.isInbetween).toBe(true); expect(ctx.beforeCursor).toBe('First paragraph'); expect(ctx.afterCursor).toBe('Second paragraph'); }); it('should detect inbetween when cursor on line with only whitespace before and after', () => { const lines = ['Content', ' \t ', 'More content']; const ctx = buildCursorContext(createGetLine(lines), 3, 1, 2); expect(ctx.isInbetween).toBe(true); }); it('should find nearest non-empty line before cursor', () => { const lines = ['Content', '', '', '', 'More']; const ctx = buildCursorContext(createGetLine(lines), 5, 2, 0); expect(ctx.isInbetween).toBe(true); expect(ctx.beforeCursor).toBe('Content'); }); it('should find nearest non-empty line after cursor', () => { const lines = ['Content', '', '', '', 'More']; const ctx = buildCursorContext(createGetLine(lines), 5, 2, 0); expect(ctx.isInbetween).toBe(true); expect(ctx.afterCursor).toBe('More'); }); it('should handle cursor at document start (empty first line)', () => { const lines = ['', 'First content']; const ctx = buildCursorContext(createGetLine(lines), 2, 0, 0); expect(ctx.isInbetween).toBe(true); expect(ctx.beforeCursor).toBe(''); expect(ctx.afterCursor).toBe('First content'); }); it('should handle cursor at document end (empty last line)', () => { const lines = ['Last content', '']; const ctx = buildCursorContext(createGetLine(lines), 2, 1, 0); expect(ctx.isInbetween).toBe(true); expect(ctx.beforeCursor).toBe('Last content'); expect(ctx.afterCursor).toBe(''); }); it('should handle multiple consecutive empty lines', () => { const lines = ['Para A', '', '', '', 'Para B']; const ctx = buildCursorContext(createGetLine(lines), 5, 2, 0); expect(ctx.isInbetween).toBe(true); expect(ctx.beforeCursor).toBe('Para A'); expect(ctx.afterCursor).toBe('Para B'); }); }); describe('edge cases', () => { it('should handle single line document with cursor', () => { const lines = ['Only line']; const ctx = buildCursorContext(createGetLine(lines), 1, 0, 5); expect(ctx.isInbetween).toBe(false); expect(ctx.beforeCursor).toBe('Only '); expect(ctx.afterCursor).toBe('line'); }); it('should handle empty document', () => { const lines = ['']; const ctx = buildCursorContext(createGetLine(lines), 1, 0, 0); expect(ctx.isInbetween).toBe(true); expect(ctx.beforeCursor).toBe(''); expect(ctx.afterCursor).toBe(''); }); it('should preserve line and column in context', () => { const lines = ['Line 0', 'Line 1', 'Line 2']; const ctx = buildCursorContext(createGetLine(lines), 3, 1, 3); expect(ctx.line).toBe(1); expect(ctx.column).toBe(3); }); }); }); describe('buildCursorPrompt', () => { it('should build inline cursor prompt correctly', () => { const request: InlineEditRequest = { mode: 'cursor', instruction: 'add missing word', notePath: 'notes/test.md', cursorContext: { beforeCursor: 'The quick brown ', afterCursor: ' jumps over', isInbetween: false, line: 5, column: 16, }, }; const prompt = buildInlineEditPrompt(request); // New format: instruction first, then XML context (no <query> wrapper) expect(prompt).toContain('add missing word'); expect(prompt).toContain('<editor_cursor path="notes/test.md" line="6">'); expect(prompt).toContain('The quick brown | jumps over #inline'); expect(prompt).toContain('</editor_cursor>'); // Verify instruction comes before cursor context expect(prompt.indexOf('add missing word')).toBeLessThan(prompt.indexOf('<editor_cursor')); }); it('should build inbetween cursor prompt with surrounding context', () => { const request: InlineEditRequest = { mode: 'cursor', instruction: 'add a new section', notePath: 'docs/readme.md', cursorContext: { beforeCursor: '# Introduction', afterCursor: '## Features', isInbetween: true, line: 3, column: 0, }, }; const prompt = buildInlineEditPrompt(request); // New format: instruction first, then XML context (no <query> wrapper) expect(prompt).toContain('add a new section'); expect(prompt).toContain('<editor_cursor path="docs/readme.md" line="4">'); expect(prompt).toContain('# Introduction'); expect(prompt).toContain('| #inbetween'); expect(prompt).toContain('## Features'); expect(prompt).toContain('</editor_cursor>'); // Verify instruction comes before cursor context expect(prompt.indexOf('add a new section')).toBeLessThan(prompt.indexOf('<editor_cursor')); }); it('should handle inbetween with no content before cursor', () => { const request: InlineEditRequest = { mode: 'cursor', instruction: 'add header', notePath: 'empty.md', cursorContext: { beforeCursor: '', afterCursor: 'First paragraph', isInbetween: true, line: 0, column: 0, }, }; const prompt = buildInlineEditPrompt(request); expect(prompt).toContain('| #inbetween'); expect(prompt).toContain('First paragraph'); expect(prompt).not.toMatch(/\n\n\| #inbetween/); // No double newline before marker }); it('should handle inbetween with no content after cursor', () => { const request: InlineEditRequest = { mode: 'cursor', instruction: 'add footer', notePath: 'doc.md', cursorContext: { beforeCursor: 'Last paragraph', afterCursor: '', isInbetween: true, line: 10, column: 0, }, }; const prompt = buildInlineEditPrompt(request); expect(prompt).toContain('Last paragraph'); expect(prompt).toContain('| #inbetween'); }); }); describe('buildPrompt mode dispatch', () => { it('should dispatch to selection prompt for selection mode', () => { const request: InlineEditRequest = { mode: 'selection', instruction: 'fix this', notePath: 'test.md', selectedText: 'selected text here', }; const prompt = buildInlineEditPrompt(request); expect(prompt).toContain('selected text here'); expect(prompt).not.toContain('#inline'); expect(prompt).not.toContain('#inbetween'); }); it('should dispatch to cursor prompt for cursor mode', () => { const request: InlineEditRequest = { mode: 'cursor', instruction: 'insert here', notePath: 'test.md', cursorContext: { beforeCursor: 'before', afterCursor: 'after', isInbetween: false, line: 0, column: 6, }, }; const prompt = buildInlineEditPrompt(request); expect(prompt).toContain('before|after #inline'); }); }); describe('parseResponse with insertion tags', () => { it('should extract text from insertion tags', () => { const response = 'Here is the content:\n<insertion>inserted text here</insertion>'; const result = parseInlineEditResponse(response); expect(result.success).toBe(true); expect(result.insertedText).toBe('inserted text here'); expect(result.editedText).toBeUndefined(); }); it('should handle multiline insertion content', () => { const response = '<insertion>Line 1\nLine 2\nLine 3</insertion>'; const result = parseInlineEditResponse(response); expect(result.success).toBe(true); expect(result.insertedText).toBe('Line 1\nLine 2\nLine 3'); }); it('should prefer replacement tags over insertion tags', () => { const response = '<replacement>replaced</replacement><insertion>inserted</insertion>'; const result = parseInlineEditResponse(response); expect(result.success).toBe(true); expect(result.editedText).toBe('replaced'); expect(result.insertedText).toBeUndefined(); }); it('should handle insertion tags with leading/trailing newlines', () => { const response = '<insertion>\n## New Section\n\nContent here\n</insertion>'; const result = parseInlineEditResponse(response); expect(result.success).toBe(true); expect(result.insertedText).toBe('\n## New Section\n\nContent here\n'); }); it('should handle empty insertion tags', () => { const response = '<insertion></insertion>'; const result = parseInlineEditResponse(response); expect(result.success).toBe(true); expect(result.insertedText).toBe(''); }); it('should handle insertion with special characters', () => { const response = '<insertion>const x = a < b && c > d;</insertion>'; const result = parseInlineEditResponse(response); expect(result.success).toBe(true); expect(result.insertedText).toBe('const x = a < b && c > d;'); }); it('should return clarification when no tags present', () => { const response = 'What would you like me to insert?'; const result = parseInlineEditResponse(response); expect(result.success).toBe(true); expect(result.clarification).toBe('What would you like me to insert?'); expect(result.insertedText).toBeUndefined(); expect(result.editedText).toBeUndefined(); }); }); describe('editText with cursor mode', () => { beforeEach(() => { (fs.existsSync as jest.Mock).mockReturnValue(true); }); it('should handle cursor mode request', async () => { setMockMessages([ { type: 'system', subtype: 'init', session_id: 'cursor-session' }, { type: 'assistant', message: { content: [{ type: 'text', text: '<insertion>fox</insertion>' }] }, }, { type: 'result' }, ]); const result = await service.editText({ mode: 'cursor', instruction: 'what animal?', notePath: 'test.md', cursorContext: { beforeCursor: 'The quick brown ', afterCursor: ' jumps over', isInbetween: false, line: 0, column: 16, }, }); expect(result.success).toBe(true); expect(result.insertedText).toBe('fox'); }); it('should handle inbetween mode request', async () => { setMockMessages([ { type: 'system', subtype: 'init', session_id: 'inbetween-session' }, { type: 'assistant', message: { content: [{ type: 'text', text: '<insertion>## Description\n\nNew section content</insertion>' }] }, }, { type: 'result' }, ]); const result = await service.editText({ mode: 'cursor', instruction: 'add description section', notePath: 'readme.md', cursorContext: { beforeCursor: '# Title', afterCursor: '## Features', isInbetween: true, line: 2, column: 0, }, }); expect(result.success).toBe(true); expect(result.insertedText).toContain('## Description'); }); }); }); ================================================ FILE: tests/unit/features/inline-edit/ui/InlineEditModal.openAndWait.test.ts ================================================ import { createMockEl } from '@test/helpers/mockElement'; import { Notice } from 'obsidian'; import { type InlineEditContext, InlineEditModal } from '@/features/inline-edit/ui/InlineEditModal'; import { VaultFolderCache } from '@/shared/mention/VaultMentionCache'; import * as editorUtils from '@/utils/editor'; const mentionDropdownCtor = jest.fn(); jest.mock('@/shared/mention/MentionDropdownController', () => ({ MentionDropdownController: function MockMentionDropdownController(...args: any[]) { mentionDropdownCtor(...args); return { handleInputChange: jest.fn(), handleKeydown: jest.fn().mockReturnValue(false), destroy: jest.fn(), }; }, })); jest.mock('@/shared/components/SlashCommandDropdown', () => ({ SlashCommandDropdown: jest.fn().mockImplementation(() => ({ handleKeydown: jest.fn().mockReturnValue(false), destroy: jest.fn(), })), })); jest.mock('@/utils/externalContextScanner', () => ({ externalContextScanner: { scanPaths: jest.fn().mockReturnValue([]), }, })); describe('InlineEditModal - openAndWait', () => { beforeEach(() => { jest.clearAllMocks(); }); it('uses editorCallback references first and falls back to view.editor before rejecting', async () => { const callbackEditor = {} as any; const fallbackEditor = {} as any; const app = { workspace: { getActiveViewOfType: jest.fn(), }, } as any; const plugin = {} as any; const view = { editor: fallbackEditor } as any; const editContext: InlineEditContext = { mode: 'cursor', cursorContext: { beforeCursor: '', afterCursor: '', isInbetween: true, line: 0, column: 0, }, }; const getEditorViewSpy = jest .spyOn(editorUtils, 'getEditorView') .mockReturnValueOnce(undefined) .mockReturnValueOnce(undefined); const modal = new InlineEditModal(app, plugin, callbackEditor, view, editContext, 'note.md'); const result = await modal.openAndWait(); expect(result).toEqual({ decision: 'reject' }); expect(getEditorViewSpy).toHaveBeenNthCalledWith(1, callbackEditor); expect(getEditorViewSpy).toHaveBeenNthCalledWith(2, fallbackEditor); expect(app.workspace.getActiveViewOfType).not.toHaveBeenCalled(); const noticeMock = Notice as unknown as jest.Mock; expect(noticeMock).toHaveBeenCalledWith( 'Inline edit unavailable: could not access the active editor. Try reopening the note.' ); }); it('wires mention getCachedVaultFolders through VaultFolderCache.getFolders', async () => { const originalDocument = (global as any).document; (global as any).document = { body: createMockEl('body'), createElement: (tagName: string) => createMockEl(tagName), addEventListener: jest.fn(), removeEventListener: jest.fn(), }; try { const app = { vault: { getFiles: jest.fn().mockReturnValue([]), getAllLoadedFiles: jest.fn().mockReturnValue([]), }, workspace: { getActiveViewOfType: jest.fn(), }, } as any; const plugin = { settings: { hiddenSlashCommands: [], }, getSdkCommands: jest.fn().mockReturnValue([]), } as any; const editor = {} as any; const view = { editor } as any; let widgetRef: any = null; const dispatch = jest.fn((transaction: any) => { const effects = Array.isArray(transaction?.effects) ? transaction.effects : transaction?.effects ? [transaction.effects] : []; for (const effect of effects) { const widget = effect?.value?.widget; if (widget && typeof widget.createInputDOM === 'function') { widgetRef = widget; widget.createInputDOM(); } } }); const editorView = { state: { doc: { line: jest.fn(() => ({ from: 0 })), lineAt: jest.fn(() => ({ from: 0 })), }, }, dispatch, dom: { addEventListener: jest.fn(), removeEventListener: jest.fn(), }, } as any; const getEditorViewSpy = jest .spyOn(editorUtils, 'getEditorView') .mockReturnValue(editorView); const getFoldersSpy = jest .spyOn(VaultFolderCache.prototype, 'getFolders') .mockReturnValue([{ name: 'src', path: 'src' } as any]); const editContext: InlineEditContext = { mode: 'cursor', cursorContext: { beforeCursor: '', afterCursor: '', isInbetween: true, line: 0, column: 0, }, }; const modal = new InlineEditModal(app, plugin, editor, view, editContext, 'note.md'); const resultPromise = modal.openAndWait(); expect(mentionDropdownCtor).toHaveBeenCalled(); const callbacks = mentionDropdownCtor.mock.calls[0]?.[2]; expect(callbacks).toBeDefined(); expect(callbacks.getCachedVaultFolders()).toEqual([{ name: 'src', path: 'src' }]); expect(getFoldersSpy).toHaveBeenCalledTimes(1); widgetRef?.reject(); await expect(resultPromise).resolves.toEqual({ decision: 'reject' }); getEditorViewSpy.mockRestore(); getFoldersSpy.mockRestore(); } finally { (global as any).document = originalDocument; } }); it('shows a single notice and degrades gracefully when getFiles throws', async () => { const originalDocument = (global as any).document; (global as any).document = { body: createMockEl('body'), createElement: (tagName: string) => createMockEl(tagName), addEventListener: jest.fn(), removeEventListener: jest.fn(), }; try { const app = { vault: { adapter: { basePath: '/vault' }, getFiles: jest.fn().mockImplementation(() => { throw new Error('vault unavailable'); }), getAllLoadedFiles: jest.fn().mockReturnValue([]), }, workspace: { getActiveViewOfType: jest.fn(), }, } as any; const plugin = { settings: { hiddenSlashCommands: [], }, getSdkCommands: jest.fn().mockReturnValue([]), } as any; const editor = {} as any; const view = { editor } as any; let widgetRef: any = null; const dispatch = jest.fn((transaction: any) => { const effects = Array.isArray(transaction?.effects) ? transaction.effects : transaction?.effects ? [transaction.effects] : []; for (const effect of effects) { const widget = effect?.value?.widget; if (widget && typeof widget.createInputDOM === 'function') { widgetRef = widget; widget.createInputDOM(); } } }); const editorView = { state: { doc: { line: jest.fn(() => ({ from: 0 })), lineAt: jest.fn(() => ({ from: 0, number: 1 })), }, }, dispatch, dom: { addEventListener: jest.fn(), removeEventListener: jest.fn(), }, } as any; const getEditorViewSpy = jest .spyOn(editorUtils, 'getEditorView') .mockReturnValue(editorView); const { externalContextScanner } = jest.requireMock('@/utils/externalContextScanner'); (externalContextScanner.scanPaths as jest.Mock).mockImplementation((paths: string[]) => { if (paths[0] === '/external') { return [ { path: '/external/src/app.md', name: 'app.md', relativePath: 'src/app.md', contextRoot: '/external', mtime: 1000, }, ]; } return []; }); const editContext: InlineEditContext = { mode: 'cursor', cursorContext: { beforeCursor: '', afterCursor: '', isInbetween: true, line: 0, column: 0, }, }; const modal = new InlineEditModal( app, plugin, editor, view, editContext, 'note.md', () => ['/external'] ); const resultPromise = modal.openAndWait(); const callbacks = mentionDropdownCtor.mock.calls[0]?.[2]; expect(callbacks.getCachedVaultFiles()).toEqual([]); expect(callbacks.getCachedVaultFiles()).toEqual([]); const editTextMock = jest.fn().mockResolvedValue({ success: true, clarification: 'Need more detail', }); widgetRef.inlineEditService = { editText: editTextMock, continueConversation: jest.fn(), cancel: jest.fn(), resetConversation: jest.fn(), }; widgetRef.inputEl.value = 'Please check @external/src/app.md.'; await widgetRef.generate(); expect(editTextMock).toHaveBeenCalledTimes(1); expect(editTextMock.mock.calls[0][0].contextFiles).toEqual(['/external/src/app.md']); const noticeMock = Notice as unknown as jest.Mock; expect(noticeMock).toHaveBeenCalledTimes(1); expect(noticeMock).toHaveBeenCalledWith( 'Failed to load vault files. Vault @-mentions may be unavailable.' ); widgetRef.reject(); await expect(resultPromise).resolves.toEqual({ decision: 'reject' }); getEditorViewSpy.mockRestore(); } finally { (global as any).document = originalDocument; } }); it('parses @mentions into contextFiles at send time without dropdown attachment state', async () => { const originalDocument = (global as any).document; (global as any).document = { body: createMockEl('body'), createElement: (tagName: string) => createMockEl(tagName), addEventListener: jest.fn(), removeEventListener: jest.fn(), }; try { const app = { vault: { adapter: { basePath: '/vault' }, getFiles: jest.fn().mockReturnValue([ { path: 'notes/a.md' }, { path: 'notes/b.md' }, ]), getAllLoadedFiles: jest.fn().mockReturnValue([]), }, workspace: { getActiveViewOfType: jest.fn(), }, } as any; const plugin = { settings: { hiddenSlashCommands: [], }, getSdkCommands: jest.fn().mockReturnValue([]), } as any; const editor = {} as any; const view = { editor } as any; let widgetRef: any = null; const dispatch = jest.fn((transaction: any) => { const effects = Array.isArray(transaction?.effects) ? transaction.effects : transaction?.effects ? [transaction.effects] : []; for (const effect of effects) { const widget = effect?.value?.widget; if (widget && typeof widget.createInputDOM === 'function') { widgetRef = widget; widget.createInputDOM(); } } }); const editorView = { state: { doc: { line: jest.fn(() => ({ from: 0 })), lineAt: jest.fn(() => ({ from: 0, number: 1 })), }, }, dispatch, dom: { addEventListener: jest.fn(), removeEventListener: jest.fn(), }, } as any; const getEditorViewSpy = jest .spyOn(editorUtils, 'getEditorView') .mockReturnValue(editorView); const editContext: InlineEditContext = { mode: 'cursor', cursorContext: { beforeCursor: '', afterCursor: '', isInbetween: true, line: 0, column: 0, }, }; const modal = new InlineEditModal(app, plugin, editor, view, editContext, 'note.md'); const resultPromise = modal.openAndWait(); const editTextMock = jest.fn().mockResolvedValue({ success: true, clarification: 'Need more detail', }); widgetRef.inlineEditService = { editText: editTextMock, continueConversation: jest.fn(), cancel: jest.fn(), resetConversation: jest.fn(), }; widgetRef.inputEl.value = 'Please check @notes/a.md and @notes/a.md.'; await widgetRef.generate(); expect(editTextMock).toHaveBeenCalledTimes(1); expect(editTextMock.mock.calls[0][0].contextFiles).toEqual(['notes/a.md']); widgetRef.reject(); await expect(resultPromise).resolves.toEqual({ decision: 'reject' }); getEditorViewSpy.mockRestore(); } finally { (global as any).document = originalDocument; } }); it('resolves external context @mentions into contextFiles at send time', async () => { const originalDocument = (global as any).document; (global as any).document = { body: createMockEl('body'), createElement: (tagName: string) => createMockEl(tagName), addEventListener: jest.fn(), removeEventListener: jest.fn(), }; try { const app = { vault: { adapter: { basePath: '/vault' }, getFiles: jest.fn().mockReturnValue([{ path: 'notes/local.md' }]), getAllLoadedFiles: jest.fn().mockReturnValue([]), }, workspace: { getActiveViewOfType: jest.fn(), }, } as any; const plugin = { settings: { hiddenSlashCommands: [], }, getSdkCommands: jest.fn().mockReturnValue([]), } as any; const editor = {} as any; const view = { editor } as any; let widgetRef: any = null; const dispatch = jest.fn((transaction: any) => { const effects = Array.isArray(transaction?.effects) ? transaction.effects : transaction?.effects ? [transaction.effects] : []; for (const effect of effects) { const widget = effect?.value?.widget; if (widget && typeof widget.createInputDOM === 'function') { widgetRef = widget; widget.createInputDOM(); } } }); const editorView = { state: { doc: { line: jest.fn(() => ({ from: 0 })), lineAt: jest.fn(() => ({ from: 0, number: 1 })), }, }, dispatch, dom: { addEventListener: jest.fn(), removeEventListener: jest.fn(), }, } as any; const getEditorViewSpy = jest .spyOn(editorUtils, 'getEditorView') .mockReturnValue(editorView); const editContext: InlineEditContext = { mode: 'cursor', cursorContext: { beforeCursor: '', afterCursor: '', isInbetween: true, line: 0, column: 0, }, }; const { externalContextScanner } = jest.requireMock('@/utils/externalContextScanner'); (externalContextScanner.scanPaths as jest.Mock).mockImplementation((paths: string[]) => { if (paths[0] === '/external') { return [ { path: '/external/src/app.md', name: 'app.md', relativePath: 'src/app.md', contextRoot: '/external', mtime: 1000, }, ]; } return []; }); const modal = new InlineEditModal( app, plugin, editor, view, editContext, 'note.md', () => ['/external'] ); const resultPromise = modal.openAndWait(); const editTextMock = jest.fn().mockResolvedValue({ success: true, clarification: 'Need more detail', }); widgetRef.inlineEditService = { editText: editTextMock, continueConversation: jest.fn(), cancel: jest.fn(), resetConversation: jest.fn(), }; widgetRef.inputEl.value = 'Please check @external/src/app.md.'; await widgetRef.generate(); expect(editTextMock).toHaveBeenCalledTimes(1); expect(editTextMock.mock.calls[0][0].contextFiles).toEqual(['/external/src/app.md']); widgetRef.reject(); await expect(resultPromise).resolves.toEqual({ decision: 'reject' }); getEditorViewSpy.mockRestore(); } finally { (global as any).document = originalDocument; } }); it('parses vault @mentions with spaces into contextFiles at send time', async () => { const originalDocument = (global as any).document; (global as any).document = { body: createMockEl('body'), createElement: (tagName: string) => createMockEl(tagName), addEventListener: jest.fn(), removeEventListener: jest.fn(), }; try { const app = { vault: { adapter: { basePath: '/vault' }, getFiles: jest.fn().mockReturnValue([ { path: 'notes/my note.md' }, ]), getAllLoadedFiles: jest.fn().mockReturnValue([]), }, workspace: { getActiveViewOfType: jest.fn(), }, } as any; const plugin = { settings: { hiddenSlashCommands: [], }, getSdkCommands: jest.fn().mockReturnValue([]), } as any; const editor = {} as any; const view = { editor } as any; let widgetRef: any = null; const dispatch = jest.fn((transaction: any) => { const effects = Array.isArray(transaction?.effects) ? transaction.effects : transaction?.effects ? [transaction.effects] : []; for (const effect of effects) { const widget = effect?.value?.widget; if (widget && typeof widget.createInputDOM === 'function') { widgetRef = widget; widget.createInputDOM(); } } }); const editorView = { state: { doc: { line: jest.fn(() => ({ from: 0 })), lineAt: jest.fn(() => ({ from: 0, number: 1 })), }, }, dispatch, dom: { addEventListener: jest.fn(), removeEventListener: jest.fn(), }, } as any; const getEditorViewSpy = jest .spyOn(editorUtils, 'getEditorView') .mockReturnValue(editorView); const editContext: InlineEditContext = { mode: 'cursor', cursorContext: { beforeCursor: '', afterCursor: '', isInbetween: true, line: 0, column: 0, }, }; const modal = new InlineEditModal(app, plugin, editor, view, editContext, 'note.md'); const resultPromise = modal.openAndWait(); const editTextMock = jest.fn().mockResolvedValue({ success: true, clarification: 'Need more detail', }); widgetRef.inlineEditService = { editText: editTextMock, continueConversation: jest.fn(), cancel: jest.fn(), resetConversation: jest.fn(), }; widgetRef.inputEl.value = 'Please check @notes/my note.md.'; await widgetRef.generate(); expect(editTextMock).toHaveBeenCalledTimes(1); expect(editTextMock.mock.calls[0][0].contextFiles).toEqual(['notes/my note.md']); widgetRef.reject(); await expect(resultPromise).resolves.toEqual({ decision: 'reject' }); getEditorViewSpy.mockRestore(); } finally { (global as any).document = originalDocument; } }); it('resolves external @mentions when vault has no files', async () => { const originalDocument = (global as any).document; (global as any).document = { body: createMockEl('body'), createElement: (tagName: string) => createMockEl(tagName), addEventListener: jest.fn(), removeEventListener: jest.fn(), }; try { const app = { vault: { adapter: { basePath: '/vault' }, getFiles: jest.fn().mockReturnValue([]), getAllLoadedFiles: jest.fn().mockReturnValue([]), }, workspace: { getActiveViewOfType: jest.fn(), }, } as any; const plugin = { settings: { hiddenSlashCommands: [], }, getSdkCommands: jest.fn().mockReturnValue([]), } as any; const editor = {} as any; const view = { editor } as any; let widgetRef: any = null; const dispatch = jest.fn((transaction: any) => { const effects = Array.isArray(transaction?.effects) ? transaction.effects : transaction?.effects ? [transaction.effects] : []; for (const effect of effects) { const widget = effect?.value?.widget; if (widget && typeof widget.createInputDOM === 'function') { widgetRef = widget; widget.createInputDOM(); } } }); const editorView = { state: { doc: { line: jest.fn(() => ({ from: 0 })), lineAt: jest.fn(() => ({ from: 0, number: 1 })), }, }, dispatch, dom: { addEventListener: jest.fn(), removeEventListener: jest.fn(), }, } as any; const getEditorViewSpy = jest .spyOn(editorUtils, 'getEditorView') .mockReturnValue(editorView); const editContext: InlineEditContext = { mode: 'cursor', cursorContext: { beforeCursor: '', afterCursor: '', isInbetween: true, line: 0, column: 0, }, }; const { externalContextScanner } = jest.requireMock('@/utils/externalContextScanner'); (externalContextScanner.scanPaths as jest.Mock).mockImplementation((paths: string[]) => { if (paths[0] === '/external') { return [ { path: '/external/src/my file.md', name: 'my file.md', relativePath: 'src/my file.md', contextRoot: '/external', mtime: 1000, }, ]; } return []; }); const modal = new InlineEditModal( app, plugin, editor, view, editContext, 'note.md', () => ['/external'] ); const resultPromise = modal.openAndWait(); const editTextMock = jest.fn().mockResolvedValue({ success: true, clarification: 'Need more detail', }); widgetRef.inlineEditService = { editText: editTextMock, continueConversation: jest.fn(), cancel: jest.fn(), resetConversation: jest.fn(), }; widgetRef.inputEl.value = 'Please check @external/src/my file.md.'; await widgetRef.generate(); expect(editTextMock).toHaveBeenCalledTimes(1); expect(editTextMock.mock.calls[0][0].contextFiles).toEqual(['/external/src/my file.md']); widgetRef.reject(); await expect(resultPromise).resolves.toEqual({ decision: 'reject' }); getEditorViewSpy.mockRestore(); } finally { (global as any).document = originalDocument; } }); }); ================================================ FILE: tests/unit/features/inline-edit/ui/InlineEditModal.test.ts ================================================ import { escapeHtml, normalizeInsertionText } from '@/utils/inlineEdit'; import { normalizePathForVault } from '@/utils/path'; // Copy of the diff algorithm from InlineEditModal for testing interface DiffOp { type: 'equal' | 'insert' | 'delete'; text: string; } function computeDiff(oldText: string, newText: string): DiffOp[] { const oldWords = oldText.split(/(\s+)/); const newWords = newText.split(/(\s+)/); const m = oldWords.length, n = newWords.length; const dp: number[][] = Array(m + 1) .fill(null) .map(() => Array(n + 1).fill(0)); for (let i = 1; i <= m; i++) { for (let j = 1; j <= n; j++) { dp[i][j] = oldWords[i - 1] === newWords[j - 1] ? dp[i - 1][j - 1] + 1 : Math.max(dp[i - 1][j], dp[i][j - 1]); } } const ops: DiffOp[] = []; let i = m, j = n; const temp: DiffOp[] = []; while (i > 0 || j > 0) { if (i > 0 && j > 0 && oldWords[i - 1] === newWords[j - 1]) { temp.push({ type: 'equal', text: oldWords[i - 1] }); i--; j--; } else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) { temp.push({ type: 'insert', text: newWords[j - 1] }); j--; } else { temp.push({ type: 'delete', text: oldWords[i - 1] }); i--; } } temp.reverse(); for (const op of temp) { if (ops.length > 0 && ops[ops.length - 1].type === op.type) { ops[ops.length - 1].text += op.text; } else { ops.push({ ...op }); } } return ops; } function diffToHtml(ops: DiffOp[]): string { return ops .map((op) => { const escaped = op.text.replace(/</g, '<').replace(/>/g, '>'); switch (op.type) { case 'delete': return `<span class="claudian-diff-del">${escaped}</span>`; case 'insert': return `<span class="claudian-diff-ins">${escaped}</span>`; default: return escaped; } }) .join(''); } describe('InlineEditModal - Insertion Newline Trimming', () => { describe('normalizeInsertionText', () => { it('should remove leading newlines', () => { const input = '\n\nContent here'; const result = normalizeInsertionText(input); expect(result).toBe('Content here'); }); it('should remove trailing newlines', () => { const input = 'Content here\n\n'; const result = normalizeInsertionText(input); expect(result).toBe('Content here'); }); it('should remove both leading and trailing newlines', () => { const input = '\n\nContent here\n\n'; const result = normalizeInsertionText(input); expect(result).toBe('Content here'); }); it('should preserve internal newlines', () => { const input = '\n## Section\n\nParagraph content\n'; const result = normalizeInsertionText(input); expect(result).toBe('## Section\n\nParagraph content'); }); it('should handle text with no newlines', () => { const input = 'Simple text'; const result = normalizeInsertionText(input); expect(result).toBe('Simple text'); }); it('should handle only newlines', () => { const input = '\n\n\n'; const result = normalizeInsertionText(input); expect(result).toBe(''); }); it('should handle empty string', () => { const input = ''; const result = normalizeInsertionText(input); expect(result).toBe(''); }); it('should not trim spaces (only newlines)', () => { const input = ' Content with spaces '; const result = normalizeInsertionText(input); expect(result).toBe(' Content with spaces '); }); it('should handle multiline markdown content', () => { const input = '\n## Description\n\nThis project provides tools for managing notes.\n\n### Features\n- Feature 1\n- Feature 2\n'; const result = normalizeInsertionText(input); expect(result).toBe('## Description\n\nThis project provides tools for managing notes.\n\n### Features\n- Feature 1\n- Feature 2'); }); it('should handle code blocks with newlines', () => { const input = '\n```javascript\nconst x = 1;\n```\n'; const result = normalizeInsertionText(input); expect(result).toBe('```javascript\nconst x = 1;\n```'); }); it('should handle CRLF newlines', () => { const input = '\r\n\r\nContent\r\n'; const result = normalizeInsertionText(input); expect(result).toBe('Content'); }); }); }); describe('inlineEditUtils - escapeHtml', () => { it('should escape angle brackets and ampersands', () => { expect(escapeHtml('a < b && c > d')).toBe('a < b && c > d'); }); it('should escape ampersands', () => { expect(escapeHtml('a & b')).toBe('a & b'); }); it('should handle empty string', () => { expect(escapeHtml('')).toBe(''); }); }); describe('InlineEditModal - Word-level Diff', () => { describe('computeDiff', () => { it('should return equal for identical text', () => { const result = computeDiff('hello world', 'hello world'); expect(result).toHaveLength(1); expect(result[0].type).toBe('equal'); expect(result[0].text).toBe('hello world'); }); it('should detect single word replacement', () => { const result = computeDiff('hello world', 'hello universe'); const types = result.map((op) => op.type); expect(types).toContain('equal'); expect(types).toContain('delete'); expect(types).toContain('insert'); const deleted = result.find((op) => op.type === 'delete'); const inserted = result.find((op) => op.type === 'insert'); expect(deleted?.text).toBe('world'); expect(inserted?.text).toBe('universe'); }); it('should detect word insertion', () => { const result = computeDiff('hello world', 'hello beautiful world'); const inserted = result.find((op) => op.type === 'insert'); expect(inserted).toBeDefined(); expect(inserted?.text).toContain('beautiful'); }); it('should detect word deletion', () => { const result = computeDiff('hello beautiful world', 'hello world'); const deleted = result.find((op) => op.type === 'delete'); expect(deleted).toBeDefined(); expect(deleted?.text).toContain('beautiful'); }); it('should handle complete replacement', () => { const result = computeDiff('foo bar baz', 'one two three'); const deleted = result.filter((op) => op.type === 'delete'); const inserted = result.filter((op) => op.type === 'insert'); expect(deleted.length).toBeGreaterThan(0); expect(inserted.length).toBeGreaterThan(0); }); it('should preserve whitespace in diff', () => { const result = computeDiff('a b', 'a b'); // The diff should handle different amounts of whitespace expect(result.length).toBeGreaterThanOrEqual(1); }); it('should handle empty old text', () => { const result = computeDiff('', 'new text'); const inserted = result.filter((op) => op.type === 'insert'); expect(inserted.length).toBeGreaterThan(0); }); it('should handle empty new text', () => { const result = computeDiff('old text', ''); const deleted = result.filter((op) => op.type === 'delete'); expect(deleted.length).toBeGreaterThan(0); }); it('should merge consecutive operations of same type', () => { const result = computeDiff('a b c', 'x y z'); // Should merge consecutive deletes and inserts const types = result.map((op) => op.type); // Check no two consecutive same types (they should be merged) for (let i = 1; i < types.length; i++) { if (types[i] === types[i - 1] && types[i] !== 'equal') { // Whitespace might separate merged ops // eslint-disable-next-line jest/no-conditional-expect expect(result[i - 1].text.trim() || result[i].text.trim()).toBeTruthy(); } } }); it('should handle text with punctuation', () => { const result = computeDiff('Hello, world!', 'Hello, universe!'); const deleted = result.find((op) => op.type === 'delete'); const inserted = result.find((op) => op.type === 'insert'); expect(deleted?.text).toContain('world'); expect(inserted?.text).toContain('universe'); }); it('should handle text with newlines', () => { const result = computeDiff('line1\nline2', 'line1\nmodified'); const deleted = result.find((op) => op.type === 'delete'); const inserted = result.find((op) => op.type === 'insert'); expect(deleted).toBeDefined(); expect(inserted).toBeDefined(); }); it('should handle multiline text', () => { const oldText = 'First line\nSecond line\nThird line'; const newText = 'First line\nModified line\nThird line'; const result = computeDiff(oldText, newText); expect(result.some((op) => op.type === 'delete')).toBe(true); expect(result.some((op) => op.type === 'insert')).toBe(true); }); }); describe('diffToHtml', () => { it('should return plain text for equal ops', () => { const ops: DiffOp[] = [{ type: 'equal', text: 'hello' }]; const html = diffToHtml(ops); expect(html).toBe('hello'); expect(html).not.toContain('span'); }); it('should wrap deleted text with del class', () => { const ops: DiffOp[] = [{ type: 'delete', text: 'removed' }]; const html = diffToHtml(ops); expect(html).toContain('claudian-diff-del'); expect(html).toContain('removed'); }); it('should wrap inserted text with ins class', () => { const ops: DiffOp[] = [{ type: 'insert', text: 'added' }]; const html = diffToHtml(ops); expect(html).toContain('claudian-diff-ins'); expect(html).toContain('added'); }); it('should escape HTML special characters', () => { const ops: DiffOp[] = [{ type: 'insert', text: '<script>alert("xss")</script>' }]; const html = diffToHtml(ops); expect(html).toContain('<script>'); expect(html).not.toContain('<script>'); }); it('should handle multiple operations', () => { const ops: DiffOp[] = [ { type: 'equal', text: 'Hello ' }, { type: 'delete', text: 'world' }, { type: 'insert', text: 'universe' }, ]; const html = diffToHtml(ops); expect(html).toContain('Hello '); expect(html).toContain('claudian-diff-del'); expect(html).toContain('world'); expect(html).toContain('claudian-diff-ins'); expect(html).toContain('universe'); }); it('should handle empty text', () => { const ops: DiffOp[] = [{ type: 'equal', text: '' }]; const html = diffToHtml(ops); expect(html).toBe(''); }); it('should preserve whitespace', () => { const ops: DiffOp[] = [{ type: 'equal', text: ' spaces ' }]; const html = diffToHtml(ops); expect(html).toBe(' spaces '); }); it('should handle special characters in deleted/inserted text', () => { const ops: DiffOp[] = [ { type: 'delete', text: 'a < b' }, { type: 'insert', text: 'a > b' }, ]; const html = diffToHtml(ops); expect(html).toContain('<'); expect(html).toContain('>'); }); }); describe('integration: diff and render', () => { it('should produce valid HTML for simple edit', () => { const ops = computeDiff('old text', 'new text'); const html = diffToHtml(ops); // Should have both del and ins spans expect(html).toContain('claudian-diff-del'); expect(html).toContain('claudian-diff-ins'); }); it('should produce plain text for no changes', () => { const ops = computeDiff('same text', 'same text'); const html = diffToHtml(ops); expect(html).toBe('same text'); expect(html).not.toContain('span'); }); it('should handle code snippet changes', () => { const oldCode = 'const x = 1;'; const newCode = 'const x = 2;'; const ops = computeDiff(oldCode, newCode); const html = diffToHtml(ops); expect(html).toContain('1'); expect(html).toContain('2'); }); it('should handle markdown formatting changes', () => { const oldText = '**bold** text'; const newText = '*italic* text'; const ops = computeDiff(oldText, newText); diffToHtml(ops); // Verify it doesn't throw expect(ops.some((op) => op.type === 'delete')).toBe(true); expect(ops.some((op) => op.type === 'insert')).toBe(true); }); }); }); /** * Tests for normalizePathForVault edge cases. */ describe('normalizePathForVault edge cases', () => { it('should return null for null path', () => { expect(normalizePathForVault(null, '/test/vault')).toBeNull(); }); it('should return null for undefined path', () => { expect(normalizePathForVault(undefined, '/test/vault')).toBeNull(); }); it('should return null for empty string path', () => { expect(normalizePathForVault('', '/test/vault')).toBeNull(); }); it('should normalize path within vault to relative path', () => { const vaultPath = '/test/vault'; const filePath = '/test/vault/notes/file.md'; const result = normalizePathForVault(filePath, vaultPath); expect(result).toBe('notes/file.md'); }); it('should handle relative path within vault', () => { const vaultPath = '/test/vault'; const filePath = 'notes/file.md'; const result = normalizePathForVault(filePath, vaultPath); expect(result).toBe('notes/file.md'); }); it('should handle path outside vault', () => { const vaultPath = '/test/vault'; const filePath = '/other/location/file.md'; const result = normalizePathForVault(filePath, vaultPath); // Should return the normalized path as-is expect(result).toBe('/other/location/file.md'); }); it('should handle null vault path', () => { const filePath = '/some/path/file.md'; const result = normalizePathForVault(filePath, null); expect(result).toBe('/some/path/file.md'); }); it('should normalize backslashes to forward slashes', () => { const vaultPath = '/test/vault'; const filePath = 'notes\\subfolder\\file.md'; const result = normalizePathForVault(filePath, vaultPath); expect(result).toContain('/'); expect(result).not.toContain('\\'); }); it('should handle paths with spaces', () => { const vaultPath = '/test/vault'; const filePath = '/test/vault/my notes/file.md'; const result = normalizePathForVault(filePath, vaultPath); expect(result).toBe('my notes/file.md'); }); it('should return null for empty relative result', () => { const vaultPath = '/test/vault'; // When path.relative returns empty string (same dir) const filePath = '/test/vault'; const result = normalizePathForVault(filePath, vaultPath); // Empty relative path returns null expect(result).toBeNull(); }); it('should handle tilde paths after normalization', () => { const vaultPath = '/home/user/vault'; // normalizePathForFilesystem should handle tilde expansion const filePath = '/home/user/vault/notes/file.md'; const result = normalizePathForVault(filePath, vaultPath); expect(result).toBe('notes/file.md'); }); }); ================================================ FILE: tests/unit/features/settings/AgentSettings.test.ts ================================================ import { createMockEl } from '@test/helpers/mockElement'; import { Notice } from 'obsidian'; import type { AgentDefinition } from '@/core/types'; import { AgentSettings } from '@/features/settings/ui/AgentSettings'; function createAgent(name: string, filePath?: string): AgentDefinition { return { id: name, name, description: `${name} description`, prompt: `${name} prompt`, source: 'vault', filePath, }; } describe('AgentSettings save orchestration', () => { let saveMock: jest.Mock; let deleteMock: jest.Mock; let plugin: any; let settings: AgentSettings; beforeEach(() => { jest.clearAllMocks(); saveMock = jest.fn().mockResolvedValue(undefined); deleteMock = jest.fn().mockResolvedValue(undefined); plugin = { app: {}, storage: { agents: { load: jest.fn(), save: saveMock, delete: deleteMock, }, }, agentManager: { getAvailableAgents: jest.fn().mockReturnValue([]), loadAgents: jest.fn().mockResolvedValue(undefined), }, }; settings = new AgentSettings(createMockEl('div') as unknown as HTMLElement, plugin); }); it('renaming saves with filePath undefined, then deletes old file', async () => { const existing = createAgent('old-name', '.claude/agents/custom-old.md'); const renamed = createAgent('new-name', '.claude/agents/custom-old.md'); await (settings as any).saveAgent(renamed, existing); expect(saveMock).toHaveBeenCalledTimes(1); expect(saveMock).toHaveBeenCalledWith({ ...renamed, filePath: undefined }); expect(deleteMock).toHaveBeenCalledTimes(1); expect(deleteMock).toHaveBeenCalledWith(existing); expect(saveMock.mock.invocationCallOrder[0]).toBeLessThan(deleteMock.mock.invocationCallOrder[0]); }); it('non-rename saves original agent and does not delete', async () => { const existing = createAgent('same-name', '.claude/agents/custom-name.md'); const updated = createAgent('same-name', '.claude/agents/custom-name.md'); await (settings as any).saveAgent(updated, existing); expect(saveMock).toHaveBeenCalledTimes(1); expect(saveMock).toHaveBeenCalledWith(updated); expect(deleteMock).not.toHaveBeenCalled(); }); it('shows notice and aborts when loading existing agent fails', async () => { const existing = createAgent('existing-agent', '.claude/agents/existing-agent.md'); plugin.storage.agents.load.mockRejectedValue(new Error('permission denied')); await (settings as any).openAgentModal(existing); expect(Notice).toHaveBeenCalledWith('Failed to load subagent "existing-agent": permission denied'); }); }); ================================================ FILE: tests/unit/features/settings/keyboardNavigation.test.ts ================================================ import { buildNavMappingText, parseNavMappings } from '@/features/settings/keyboardNavigation'; describe('keyboardNavigation', () => { describe('buildNavMappingText', () => { it('should build mapping lines in order', () => { const result = buildNavMappingText({ scrollUpKey: 'w', scrollDownKey: 's', focusInputKey: 'i', }); expect(result).toBe('map w scrollUp\nmap s scrollDown\nmap i focusInput'); }); }); describe('parseNavMappings', () => { it('should parse valid mappings', () => { const result = parseNavMappings('map w scrollUp\nmap s scrollDown\nmap i focusInput'); expect(result.settings).toEqual({ scrollUp: 'w', scrollDown: 's', focusInput: 'i', }); }); it('should ignore empty lines', () => { const result = parseNavMappings('\nmap w scrollUp\n\nmap s scrollDown\nmap i focusInput\n'); expect(result.settings).toEqual({ scrollUp: 'w', scrollDown: 's', focusInput: 'i', }); }); it('should reject invalid formats', () => { const result = parseNavMappings('map w scrollUp extra'); expect(result.error).toBe('Each line must follow "map <key> <action>"'); }); it('should reject unknown actions', () => { const result = parseNavMappings('map w jump\nmap s scrollDown\nmap i focusInput'); expect(result.error).toBe('Unknown action: jump'); }); it('should reject multi-character keys', () => { const result = parseNavMappings('map ww scrollUp\nmap s scrollDown\nmap i focusInput'); expect(result.error).toBe('Key must be a single character for scrollUp'); }); it('should reject duplicate action mappings', () => { const result = parseNavMappings('map w scrollUp\nmap s scrollDown\nmap i scrollDown'); expect(result.error).toBe('Duplicate mapping for scrollDown'); }); it('should reject duplicate keys case-insensitively', () => { const result = parseNavMappings('map W scrollUp\nmap w scrollDown\nmap i focusInput'); expect(result.error).toBe('Navigation keys must be unique'); }); it('should reject missing mappings', () => { const result = parseNavMappings('map w scrollUp\nmap s scrollDown'); expect(result.error).toBe('Missing mapping for focusInput'); }); }); }); ================================================ FILE: tests/unit/i18n/constants.test.ts ================================================ import { DEFAULT_LOCALE, getLocaleDisplayString, getLocaleInfo, SUPPORTED_LOCALES } from '@/i18n/constants'; describe('i18n/constants', () => { it('DEFAULT_LOCALE is en', () => { expect(DEFAULT_LOCALE).toBe('en'); }); it('getLocaleInfo returns metadata for a supported locale', () => { const info = getLocaleInfo('en'); expect(info).toBeDefined(); expect(info?.code).toBe('en'); expect(info?.name).toBe('English'); expect(info?.englishName).toBe('English'); expect(info?.flag).toBe('🇺🇸'); }); it('getLocaleInfo returns undefined for unknown locale', () => { expect(getLocaleInfo('xx' as any)).toBeUndefined(); }); it('getLocaleDisplayString returns a string with a flag by default', () => { expect(getLocaleDisplayString('en')).toBe('🇺🇸 English (English)'); }); it('getLocaleDisplayString can omit the flag', () => { expect(getLocaleDisplayString('en', false)).toBe('English (English)'); }); it('getLocaleDisplayString returns code when locale is unknown', () => { expect(getLocaleDisplayString('xx' as any)).toBe('xx'); }); it('getLocaleDisplayString omits the flag when metadata has no flag', () => { const originalFlag = SUPPORTED_LOCALES[0]?.flag; SUPPORTED_LOCALES[0].flag = undefined; try { expect(getLocaleDisplayString('en')).toBe('English (English)'); } finally { SUPPORTED_LOCALES[0].flag = originalFlag; } }); }); ================================================ FILE: tests/unit/i18n/i18n.test.ts ================================================ import { getAvailableLocales, getLocale, getLocaleDisplayName, setLocale, t, } from '@/i18n/i18n'; import type { Locale, TranslationKey } from '@/i18n/types'; describe('i18n', () => { // Reset locale to default before each test beforeEach(() => { setLocale('en'); }); describe('t (translate)', () => { it('returns translated string for valid key', () => { const result = t('common.save' as TranslationKey); expect(result).toBe('Save'); }); it('returns string with parameter interpolation', () => { // Use a key that has placeholders const result = t('settings.blockedCommands.name' as TranslationKey, { platform: 'Unix' }); expect(result).toBe('Blocked commands (Unix)'); }); it('returns key for missing translation in English', () => { const result = t('nonexistent.key.here' as TranslationKey); expect(result).toBe('nonexistent.key.here'); }); it('falls back to English for missing translation in other locale', () => { setLocale('de'); // Use a key that exists in English but might not in German const result = t('common.save' as TranslationKey); // Should return the English translation or the German one expect(typeof result).toBe('string'); expect(result.length).toBeGreaterThan(0); }); it('handles nested keys correctly', () => { const result = t('settings.userName.name' as TranslationKey); expect(result).toBe('What should Claudian call you?'); }); it('handles deeply nested keys', () => { const result = t('settings.userName.desc' as TranslationKey); expect(result).toBe('Your name for personalized greetings (leave empty for generic greetings)'); }); it('returns key when value is not a string', () => { // Try to access a non-leaf key (object instead of string) const result = t('settings' as TranslationKey); expect(result).toBe('settings'); }); it('replaces placeholders with params', () => { // Use a key with {param} placeholders const result = t('settings.blockedCommands.desc' as TranslationKey, { platform: 'Windows' }); expect(result).toContain('Windows'); }); it('keeps placeholder if param not provided', () => { // Use a key with placeholders but don't provide the param const result = t('settings.blockedCommands.name' as TranslationKey, {}); expect(result).toBe('Blocked commands ({platform})'); }); }); describe('setLocale', () => { it('sets valid locale and returns true', () => { const result = setLocale('ja'); expect(result).toBe(true); expect(getLocale()).toBe('ja'); }); it('sets Chinese Simplified locale', () => { const result = setLocale('zh-CN'); expect(result).toBe(true); expect(getLocale()).toBe('zh-CN'); }); it('sets Chinese Traditional locale', () => { const result = setLocale('zh-TW'); expect(result).toBe(true); expect(getLocale()).toBe('zh-TW'); }); it('returns false for invalid locale and keeps current', () => { setLocale('de'); const result = setLocale('invalid' as Locale); expect(result).toBe(false); expect(getLocale()).toBe('de'); // Should remain unchanged }); }); describe('getLocale', () => { it('returns default locale initially', () => { expect(getLocale()).toBe('en'); }); it('returns current locale after change', () => { setLocale('fr'); expect(getLocale()).toBe('fr'); }); }); describe('getAvailableLocales', () => { it('returns all supported locales', () => { const locales = getAvailableLocales(); expect(locales).toContain('en'); expect(locales).toContain('zh-CN'); expect(locales).toContain('zh-TW'); expect(locales).toContain('ja'); expect(locales).toContain('ko'); expect(locales).toContain('de'); expect(locales).toContain('fr'); expect(locales).toContain('es'); expect(locales).toContain('ru'); expect(locales).toContain('pt'); }); it('returns exactly 10 locales', () => { const locales = getAvailableLocales(); expect(locales).toHaveLength(10); }); }); describe('getLocaleDisplayName', () => { it('returns English for en', () => { expect(getLocaleDisplayName('en')).toBe('English'); }); it('returns Simplified Chinese name for zh-CN', () => { expect(getLocaleDisplayName('zh-CN')).toBe('简体中文'); }); it('returns Traditional Chinese name for zh-TW', () => { expect(getLocaleDisplayName('zh-TW')).toBe('繁體中文'); }); it('returns Japanese name for ja', () => { expect(getLocaleDisplayName('ja')).toBe('日本語'); }); it('returns Korean name for ko', () => { expect(getLocaleDisplayName('ko')).toBe('한국어'); }); it('returns German name for de', () => { expect(getLocaleDisplayName('de')).toBe('Deutsch'); }); it('returns French name for fr', () => { expect(getLocaleDisplayName('fr')).toBe('Français'); }); it('returns Spanish name for es', () => { expect(getLocaleDisplayName('es')).toBe('Español'); }); it('returns Russian name for ru', () => { expect(getLocaleDisplayName('ru')).toBe('Русский'); }); it('returns Portuguese name for pt', () => { expect(getLocaleDisplayName('pt')).toBe('Português'); }); it('returns locale code for unknown locale', () => { expect(getLocaleDisplayName('xx' as Locale)).toBe('xx'); }); }); describe('translation in different locales', () => { it('translates correctly in German', () => { setLocale('de'); const result = t('common.save' as TranslationKey); // German should have a translation or fall back to English expect(typeof result).toBe('string'); expect(result.length).toBeGreaterThan(0); }); it('translates correctly in Japanese', () => { setLocale('ja'); const result = t('common.save' as TranslationKey); expect(typeof result).toBe('string'); expect(result.length).toBeGreaterThan(0); }); it('translates correctly in Korean', () => { setLocale('ko'); const result = t('common.save' as TranslationKey); expect(typeof result).toBe('string'); expect(result.length).toBeGreaterThan(0); }); it('translates correctly in Simplified Chinese', () => { setLocale('zh-CN'); const result = t('common.save' as TranslationKey); expect(typeof result).toBe('string'); expect(result.length).toBeGreaterThan(0); }); it('translates correctly in French', () => { setLocale('fr'); const result = t('common.save' as TranslationKey); expect(typeof result).toBe('string'); expect(result.length).toBeGreaterThan(0); }); it('translates correctly in Spanish', () => { setLocale('es'); const result = t('common.save' as TranslationKey); expect(typeof result).toBe('string'); expect(result.length).toBeGreaterThan(0); }); it('translates correctly in Russian', () => { setLocale('ru'); const result = t('common.save' as TranslationKey); expect(typeof result).toBe('string'); expect(result.length).toBeGreaterThan(0); }); it('translates correctly in Portuguese', () => { setLocale('pt'); const result = t('common.save' as TranslationKey); expect(typeof result).toBe('string'); expect(result.length).toBeGreaterThan(0); }); }); }); ================================================ FILE: tests/unit/i18n/locales.test.ts ================================================ import * as de from '@/i18n/locales/de.json'; import * as en from '@/i18n/locales/en.json'; import * as es from '@/i18n/locales/es.json'; import * as fr from '@/i18n/locales/fr.json'; import * as ja from '@/i18n/locales/ja.json'; import * as ko from '@/i18n/locales/ko.json'; import * as pt from '@/i18n/locales/pt.json'; import * as ru from '@/i18n/locales/ru.json'; import * as zhCN from '@/i18n/locales/zh-CN.json'; import * as zhTW from '@/i18n/locales/zh-TW.json'; interface TranslationTree { [key: string]: string | TranslationTree; } const locales = { de, es, fr, ja, ko, pt, ru, 'zh-CN': zhCN, 'zh-TW': zhTW, } as const; const localizedKeys = [ 'chat.fork.errorMessageNotFound', 'chat.fork.errorNoSession', 'chat.fork.errorNoActiveTab', 'chat.bangBash.placeholder', 'chat.bangBash.commandPanel', 'chat.bangBash.copyAriaLabel', 'chat.bangBash.clearAriaLabel', 'chat.bangBash.statusLabel', 'chat.bangBash.collapseOutput', 'chat.bangBash.expandOutput', 'chat.bangBash.running', 'chat.bangBash.copyFailed', 'settings.subagents.name', 'settings.subagents.desc', 'settings.subagents.noAgents', 'settings.subagents.deleteConfirm', 'settings.subagents.saveFailed', 'settings.subagents.deleteFailed', 'settings.subagents.renameCleanupFailed', 'settings.subagents.created', 'settings.subagents.updated', 'settings.subagents.deleted', 'settings.subagents.duplicateName', 'settings.subagents.descriptionRequired', 'settings.subagents.promptRequired', 'settings.subagents.modal.titleEdit', 'settings.subagents.modal.titleAdd', 'settings.subagents.modal.nameDesc', 'settings.subagents.modal.descriptionDesc', 'settings.subagents.modal.descriptionPlaceholder', 'settings.subagents.modal.advancedOptions', 'settings.subagents.modal.modelDesc', 'settings.subagents.modal.toolsDesc', 'settings.subagents.modal.disallowedTools', 'settings.subagents.modal.disallowedToolsDesc', 'settings.subagents.modal.skills', 'settings.subagents.modal.skillsDesc', 'settings.subagents.modal.prompt', 'settings.subagents.modal.promptDesc', 'settings.subagents.modal.promptPlaceholder', 'settings.enableBangBash.name', 'settings.enableBangBash.desc', 'settings.enableBangBash.validation.noNode', ] as const; const staleBangBashDesc = 'Type ! on empty input to enter bash mode. Runs commands directly via Node.js child_process.'; function flattenTranslations( translations: TranslationTree, prefix = '', out: Record<string, string> = {} ): Record<string, string> { for (const [key, value] of Object.entries(translations)) { const nextKey = prefix ? `${prefix}.${key}` : key; if (value && typeof value === 'object') { flattenTranslations(value as TranslationTree, nextKey, out); continue; } out[nextKey] = String(value); } return out; } describe('locale files', () => { const english = flattenTranslations(en as unknown as TranslationTree); it('keeps every locale structurally aligned with English', () => { const englishKeys = Object.keys(english).sort(); for (const [locale, translations] of Object.entries(locales)) { const localeKeys = Object.keys(flattenTranslations(translations as unknown as TranslationTree)).sort(); expect(localeKeys).toEqual(englishKeys); expect(locale).toBeTruthy(); } }); it('localizes the recent bang bash and subagent additions', () => { for (const translations of Object.values(locales)) { const locale = flattenTranslations(translations as unknown as TranslationTree); for (const key of localizedKeys) { expect(locale[key]).toBeDefined(); expect(locale[key]).not.toBe(english[key]); } expect(locale['settings.enableBangBash.desc']).not.toBe(staleBangBashDesc); } }); }); ================================================ FILE: tests/unit/shared/components/ResumeSessionDropdown.test.ts ================================================ import { createMockEl } from '@test/helpers/mockElement'; import type { ConversationMeta } from '@/core/types'; import { ResumeSessionDropdown, type ResumeSessionDropdownCallbacks, } from '@/shared/components/ResumeSessionDropdown'; function createMockInput(): any { return { value: '', selectionStart: 0, selectionEnd: 0, focus: jest.fn(), addEventListener: jest.fn(), removeEventListener: jest.fn(), }; } function createMockCallbacks( overrides: Partial<ResumeSessionDropdownCallbacks> = {} ): ResumeSessionDropdownCallbacks { return { onSelect: jest.fn(), onDismiss: jest.fn(), ...overrides, }; } function createConversation( id: string, title: string, opts: Partial<ConversationMeta> = {} ): ConversationMeta { return { id, title, createdAt: Date.now() - 10000, updatedAt: Date.now() - 5000, messageCount: 3, preview: 'Test preview', ...opts, }; } function getRenderedItems(containerEl: any): { title: string; isCurrent: boolean }[] { const dropdownEl = containerEl.children.find( (c: any) => c.hasClass('claudian-resume-dropdown') ); if (!dropdownEl) return []; const items = dropdownEl.querySelectorAll('.claudian-resume-item'); return items.map((item: any) => { // Check direct children for content div, then find title inside let title = ''; for (const child of item.children) { const found = child.querySelector?.('.claudian-resume-item-title'); if (found) { title = found.textContent ?? ''; break; } } return { title, isCurrent: item.hasClass('current'), }; }); } describe('ResumeSessionDropdown', () => { let containerEl: any; let inputEl: any; let callbacks: ResumeSessionDropdownCallbacks; const conversations: ConversationMeta[] = [ createConversation('conv-1', 'First Chat', { lastResponseAt: 1000 }), createConversation('conv-2', 'Second Chat', { lastResponseAt: 3000 }), createConversation('conv-3', 'Third Chat', { lastResponseAt: 2000 }), ]; beforeEach(() => { containerEl = createMockEl(); inputEl = createMockInput(); callbacks = createMockCallbacks(); }); describe('constructor', () => { it('creates dropdown with visible class', () => { const dropdown = new ResumeSessionDropdown( containerEl, inputEl, conversations, null, callbacks ); const dropdownEl = containerEl.children.find( (c: any) => c.hasClass('claudian-resume-dropdown') ); expect(dropdownEl).toBeDefined(); expect(dropdownEl.hasClass('visible')).toBe(true); dropdown.destroy(); }); it('sorts conversations by lastResponseAt descending', () => { const dropdown = new ResumeSessionDropdown( containerEl, inputEl, conversations, null, callbacks ); const items = getRenderedItems(containerEl); expect(items[0].title).toBe('Second Chat'); // lastResponseAt: 3000 expect(items[1].title).toBe('Third Chat'); // lastResponseAt: 2000 expect(items[2].title).toBe('First Chat'); // lastResponseAt: 1000 dropdown.destroy(); }); it('marks current conversation', () => { const dropdown = new ResumeSessionDropdown( containerEl, inputEl, conversations, 'conv-2', callbacks ); const items = getRenderedItems(containerEl); const currentItem = items.find(i => i.title === 'Second Chat'); expect(currentItem?.isCurrent).toBe(true); const otherItem = items.find(i => i.title === 'First Chat'); expect(otherItem?.isCurrent).toBe(false); dropdown.destroy(); }); it('renders empty state when no conversations', () => { const dropdown = new ResumeSessionDropdown( containerEl, inputEl, [], null, callbacks ); const dropdownEl = containerEl.children.find( (c: any) => c.hasClass('claudian-resume-dropdown') ); const emptyEl = dropdownEl?.querySelector('.claudian-resume-empty'); expect(emptyEl).toBeDefined(); expect(emptyEl?.textContent).toBe('No conversations'); dropdown.destroy(); }); it('adds input event listener for auto-dismiss', () => { const dropdown = new ResumeSessionDropdown( containerEl, inputEl, conversations, null, callbacks ); expect(inputEl.addEventListener).toHaveBeenCalledWith('input', expect.any(Function)); dropdown.destroy(); }); }); describe('handleKeydown', () => { it('returns false when dropdown is not visible', () => { const dropdown = new ResumeSessionDropdown( containerEl, inputEl, conversations, null, callbacks ); // Hide it first const dropdownEl = containerEl.children.find( (c: any) => c.hasClass('claudian-resume-dropdown') ); dropdownEl.removeClass('visible'); const event = { key: 'ArrowDown', preventDefault: jest.fn() } as any; expect(dropdown.handleKeydown(event)).toBe(false); expect(event.preventDefault).not.toHaveBeenCalled(); dropdown.destroy(); }); it('navigates down with ArrowDown', () => { const dropdown = new ResumeSessionDropdown( containerEl, inputEl, conversations, null, callbacks ); const event = { key: 'ArrowDown', preventDefault: jest.fn() } as any; const result = dropdown.handleKeydown(event); expect(result).toBe(true); expect(event.preventDefault).toHaveBeenCalled(); dropdown.destroy(); }); it('navigates up with ArrowUp', () => { const dropdown = new ResumeSessionDropdown( containerEl, inputEl, conversations, null, callbacks ); // Go down first, then up dropdown.handleKeydown({ key: 'ArrowDown', preventDefault: jest.fn() } as any); const event = { key: 'ArrowUp', preventDefault: jest.fn() } as any; const result = dropdown.handleKeydown(event); expect(result).toBe(true); expect(event.preventDefault).toHaveBeenCalled(); dropdown.destroy(); }); it('selects with Enter', () => { const dropdown = new ResumeSessionDropdown( containerEl, inputEl, conversations, null, callbacks ); const event = { key: 'Enter', preventDefault: jest.fn() } as any; const result = dropdown.handleKeydown(event); expect(result).toBe(true); expect(event.preventDefault).toHaveBeenCalled(); // First item after sorting is conv-2 (highest lastResponseAt) expect(callbacks.onSelect).toHaveBeenCalledWith('conv-2'); dropdown.destroy(); }); it('selects with Tab', () => { const dropdown = new ResumeSessionDropdown( containerEl, inputEl, conversations, null, callbacks ); const event = { key: 'Tab', preventDefault: jest.fn() } as any; const result = dropdown.handleKeydown(event); expect(result).toBe(true); expect(callbacks.onSelect).toHaveBeenCalledWith('conv-2'); dropdown.destroy(); }); it('dismisses with Escape', () => { const dropdown = new ResumeSessionDropdown( containerEl, inputEl, conversations, null, callbacks ); const event = { key: 'Escape', preventDefault: jest.fn() } as any; const result = dropdown.handleKeydown(event); expect(result).toBe(true); expect(event.preventDefault).toHaveBeenCalled(); expect(callbacks.onDismiss).toHaveBeenCalled(); dropdown.destroy(); }); it('returns false for unhandled keys', () => { const dropdown = new ResumeSessionDropdown( containerEl, inputEl, conversations, null, callbacks ); const event = { key: 'a', preventDefault: jest.fn() } as any; const result = dropdown.handleKeydown(event); expect(result).toBe(false); expect(event.preventDefault).not.toHaveBeenCalled(); dropdown.destroy(); }); it('dismisses when selecting current conversation', () => { const dropdown = new ResumeSessionDropdown( containerEl, inputEl, conversations, 'conv-2', callbacks ); // conv-2 is first after sorting (highest lastResponseAt), so Enter selects it const event = { key: 'Enter', preventDefault: jest.fn() } as any; dropdown.handleKeydown(event); // Should dismiss, not call onSelect expect(callbacks.onSelect).not.toHaveBeenCalled(); expect(callbacks.onDismiss).toHaveBeenCalled(); dropdown.destroy(); }); }); describe('isVisible', () => { it('returns true after construction', () => { const dropdown = new ResumeSessionDropdown( containerEl, inputEl, conversations, null, callbacks ); expect(dropdown.isVisible()).toBe(true); dropdown.destroy(); }); it('returns false after Escape', () => { const dropdown = new ResumeSessionDropdown( containerEl, inputEl, conversations, null, callbacks ); dropdown.handleKeydown({ key: 'Escape', preventDefault: jest.fn() } as any); expect(dropdown.isVisible()).toBe(false); dropdown.destroy(); }); }); describe('destroy', () => { it('removes input event listener', () => { const dropdown = new ResumeSessionDropdown( containerEl, inputEl, conversations, null, callbacks ); dropdown.destroy(); expect(inputEl.removeEventListener).toHaveBeenCalledWith('input', expect.any(Function)); }); }); describe('click selection', () => { it('calls onSelect when clicking a non-current item', () => { const dropdown = new ResumeSessionDropdown( containerEl, inputEl, conversations, 'conv-1', callbacks ); const dropdownEl = containerEl.children.find( (c: any) => c.hasClass('claudian-resume-dropdown') ); const items = dropdownEl.querySelectorAll('.claudian-resume-item'); // Find a non-current item (conv-2 is first, conv-1 is current) const nonCurrentItem = items.find((i: any) => !i.hasClass('current')); nonCurrentItem?.dispatchEvent('click'); expect(callbacks.onSelect).toHaveBeenCalled(); dropdown.destroy(); }); it('dismisses when clicking current item', () => { const dropdown = new ResumeSessionDropdown( containerEl, inputEl, conversations, 'conv-2', callbacks ); const dropdownEl = containerEl.children.find( (c: any) => c.hasClass('claudian-resume-dropdown') ); const items = dropdownEl.querySelectorAll('.claudian-resume-item'); // conv-2 is first after sorting and is current const currentItem = items.find((i: any) => i.hasClass('current')); currentItem?.dispatchEvent('click'); expect(callbacks.onSelect).not.toHaveBeenCalled(); expect(callbacks.onDismiss).toHaveBeenCalled(); dropdown.destroy(); }); }); }); ================================================ FILE: tests/unit/shared/components/SelectableDropdown.test.ts ================================================ import { createMockEl } from '@test/helpers/mockElement'; import { SelectableDropdown, type SelectableDropdownRenderOptions } from '@/shared/components/SelectableDropdown'; function createRenderOptions<T>(overrides: Partial<SelectableDropdownRenderOptions<T>> = {}): SelectableDropdownRenderOptions<T> { return { items: [] as T[], selectedIndex: 0, emptyText: 'No items', renderItem: jest.fn(), ...overrides, }; } describe('SelectableDropdown', () => { let containerEl: any; let dropdown: SelectableDropdown<string>; beforeEach(() => { containerEl = createMockEl(); dropdown = new SelectableDropdown(containerEl, { listClassName: 'dropdown-list', itemClassName: 'dropdown-item', emptyClassName: 'dropdown-empty', }); }); afterEach(() => { dropdown.destroy(); }); describe('initial state', () => { it('is not visible before render', () => { expect(dropdown.isVisible()).toBe(false); }); it('has no element before render', () => { expect(dropdown.getElement()).toBeNull(); }); it('returns 0 for selectedIndex', () => { expect(dropdown.getSelectedIndex()).toBe(0); }); it('returns null for selectedItem', () => { expect(dropdown.getSelectedItem()).toBeNull(); }); it('returns empty items array', () => { expect(dropdown.getItems()).toEqual([]); }); }); describe('render with items', () => { it('creates dropdown element and marks it visible', () => { dropdown.render(createRenderOptions({ items: ['alpha', 'beta'], selectedIndex: 0, renderItem: (item, el) => el.setText(item), })); const el = dropdown.getElement(); expect(el).not.toBeNull(); expect(el!.hasClass('visible')).toBe(true); }); it('stores items and exposes them via getItems', () => { dropdown.render(createRenderOptions({ items: ['a', 'b', 'c'], renderItem: jest.fn(), })); expect(dropdown.getItems()).toEqual(['a', 'b', 'c']); }); it('calls renderItem for each item', () => { const renderItem = jest.fn(); dropdown.render(createRenderOptions({ items: ['x', 'y'], renderItem, })); expect(renderItem).toHaveBeenCalledTimes(2); expect(renderItem).toHaveBeenCalledWith('x', expect.anything()); expect(renderItem).toHaveBeenCalledWith('y', expect.anything()); }); it('marks selected item with selected class', () => { dropdown.render(createRenderOptions({ items: ['a', 'b', 'c'], selectedIndex: 1, renderItem: jest.fn(), })); expect(dropdown.getSelectedIndex()).toBe(1); expect(dropdown.getSelectedItem()).toBe('b'); }); it('creates item elements with itemClassName', () => { dropdown.render(createRenderOptions({ items: ['one'], renderItem: jest.fn(), })); const el = dropdown.getElement()!; const items = el.querySelectorAll('dropdown-item'); expect(items.length).toBe(1); }); }); describe('render with empty items', () => { it('shows empty text when items array is empty', () => { dropdown.render(createRenderOptions({ items: [], emptyText: 'Nothing here', renderItem: jest.fn(), })); const el = dropdown.getElement()!; const emptyEl = el.querySelector('dropdown-empty'); expect(emptyEl).not.toBeNull(); expect(emptyEl!.textContent).toBe('Nothing here'); }); }); describe('getItemClass support', () => { it('applies string class to items', () => { dropdown.render(createRenderOptions({ items: ['a'], renderItem: jest.fn(), getItemClass: () => 'extra-class', })); const el = dropdown.getElement()!; const items = el.querySelectorAll('dropdown-item'); expect(items[0].hasClass('extra-class')).toBe(true); }); it('applies array of classes to items', () => { dropdown.render(createRenderOptions({ items: ['a'], renderItem: jest.fn(), getItemClass: () => ['cls-a', 'cls-b'], })); const el = dropdown.getElement()!; const items = el.querySelectorAll('dropdown-item'); expect(items[0].hasClass('cls-a')).toBe(true); expect(items[0].hasClass('cls-b')).toBe(true); }); it('handles undefined class gracefully', () => { dropdown.render(createRenderOptions({ items: ['a'], renderItem: jest.fn(), getItemClass: () => undefined, })); const el = dropdown.getElement()!; const items = el.querySelectorAll('dropdown-item'); expect(items.length).toBe(1); }); }); describe('click handler', () => { it('calls onItemClick when item is clicked', () => { const onItemClick = jest.fn(); dropdown.render(createRenderOptions({ items: ['a', 'b'], renderItem: jest.fn(), onItemClick, })); const el = dropdown.getElement()!; const items = el.querySelectorAll('dropdown-item'); items[1].dispatchEvent({ type: 'click', target: items[1] } as any); expect(onItemClick).toHaveBeenCalledWith('b', 1, expect.anything()); }); it('updates selectedIndex on click', () => { dropdown.render(createRenderOptions({ items: ['a', 'b', 'c'], selectedIndex: 0, renderItem: jest.fn(), })); const el = dropdown.getElement()!; const items = el.querySelectorAll('dropdown-item'); items[2].dispatchEvent({ type: 'click', target: items[2] } as any); expect(dropdown.getSelectedIndex()).toBe(2); }); }); describe('hover handler', () => { it('calls onItemHover when item is hovered', () => { const onItemHover = jest.fn(); dropdown.render(createRenderOptions({ items: ['a', 'b'], renderItem: jest.fn(), onItemHover, })); const el = dropdown.getElement()!; const items = el.querySelectorAll('dropdown-item'); items[0].dispatchEvent({ type: 'mouseenter' } as any); expect(onItemHover).toHaveBeenCalledWith('a', 0); }); it('updates selectedIndex on hover', () => { dropdown.render(createRenderOptions({ items: ['a', 'b', 'c'], selectedIndex: 0, renderItem: jest.fn(), })); const el = dropdown.getElement()!; const items = el.querySelectorAll('dropdown-item'); items[1].dispatchEvent({ type: 'mouseenter' } as any); expect(dropdown.getSelectedIndex()).toBe(1); }); }); describe('moveSelection', () => { it('moves selection down', () => { dropdown.render(createRenderOptions({ items: ['a', 'b', 'c'], selectedIndex: 0, renderItem: jest.fn(), })); dropdown.moveSelection(1); expect(dropdown.getSelectedIndex()).toBe(1); }); it('moves selection up', () => { dropdown.render(createRenderOptions({ items: ['a', 'b', 'c'], selectedIndex: 2, renderItem: jest.fn(), })); dropdown.moveSelection(-1); expect(dropdown.getSelectedIndex()).toBe(1); }); it('clamps at 0 when moving up past beginning', () => { dropdown.render(createRenderOptions({ items: ['a', 'b'], selectedIndex: 0, renderItem: jest.fn(), })); dropdown.moveSelection(-1); expect(dropdown.getSelectedIndex()).toBe(0); }); it('clamps at max index when moving down past end', () => { dropdown.render(createRenderOptions({ items: ['a', 'b'], selectedIndex: 1, renderItem: jest.fn(), })); dropdown.moveSelection(1); expect(dropdown.getSelectedIndex()).toBe(1); }); }); describe('updateSelection', () => { it('adds selected class to current item and removes from others', () => { dropdown.render(createRenderOptions({ items: ['a', 'b', 'c'], selectedIndex: 0, renderItem: jest.fn(), })); dropdown.moveSelection(2); const el = dropdown.getElement()!; const items = el.querySelectorAll('dropdown-item'); expect(items[0].hasClass('selected')).toBe(false); expect(items[1].hasClass('selected')).toBe(false); expect(items[2].hasClass('selected')).toBe(true); }); }); describe('hide', () => { it('removes visible class from dropdown', () => { dropdown.render(createRenderOptions({ items: ['a'], renderItem: jest.fn(), })); expect(dropdown.isVisible()).toBe(true); dropdown.hide(); expect(dropdown.isVisible()).toBe(false); }); it('is safe to call before render', () => { expect(() => dropdown.hide()).not.toThrow(); }); }); describe('destroy', () => { it('removes the dropdown element', () => { dropdown.render(createRenderOptions({ items: ['a'], renderItem: jest.fn(), })); expect(dropdown.getElement()).not.toBeNull(); dropdown.destroy(); expect(dropdown.getElement()).toBeNull(); }); it('is safe to call before render', () => { expect(() => dropdown.destroy()).not.toThrow(); }); it('is safe to call multiple times', () => { dropdown.render(createRenderOptions({ items: ['a'], renderItem: jest.fn() })); dropdown.destroy(); expect(() => dropdown.destroy()).not.toThrow(); }); }); describe('re-render', () => { it('reuses existing dropdown element on subsequent renders', () => { dropdown.render(createRenderOptions({ items: ['a'], renderItem: jest.fn(), })); const firstEl = dropdown.getElement(); dropdown.render(createRenderOptions({ items: ['b', 'c'], renderItem: jest.fn(), })); expect(dropdown.getElement()).toBe(firstEl); }); it('clears old items and renders new ones', () => { dropdown.render(createRenderOptions({ items: ['a', 'b'], renderItem: jest.fn(), })); dropdown.render(createRenderOptions({ items: ['x'], renderItem: jest.fn(), })); expect(dropdown.getItems()).toEqual(['x']); }); }); describe('fixed className', () => { it('applies fixedClassName when fixed option is true', () => { const fixedDropdown = new SelectableDropdown(containerEl, { listClassName: 'dropdown-list', itemClassName: 'dropdown-item', emptyClassName: 'dropdown-empty', fixed: true, fixedClassName: 'dropdown-fixed', }); fixedDropdown.render(createRenderOptions({ items: ['a'], renderItem: jest.fn(), })); const el = fixedDropdown.getElement()!; expect(el.hasClass('dropdown-list')).toBe(true); expect(el.hasClass('dropdown-fixed')).toBe(true); fixedDropdown.destroy(); }); it('does not apply fixedClassName when fixed is false', () => { const nonFixedDropdown = new SelectableDropdown(containerEl, { listClassName: 'dropdown-list', itemClassName: 'dropdown-item', emptyClassName: 'dropdown-empty', fixed: false, fixedClassName: 'dropdown-fixed', }); nonFixedDropdown.render(createRenderOptions({ items: ['a'], renderItem: jest.fn(), })); const el = nonFixedDropdown.getElement()!; expect(el.hasClass('dropdown-list')).toBe(true); expect(el.hasClass('dropdown-fixed')).toBe(false); nonFixedDropdown.destroy(); }); }); }); ================================================ FILE: tests/unit/shared/components/SlashCommandDropdown.test.ts ================================================ import { createMockEl } from '@test/helpers/mockElement'; import type { SlashCommand } from '@/core/types'; import { SlashCommandDropdown, type SlashCommandDropdownCallbacks, } from '@/shared/components/SlashCommandDropdown'; // Mock getBuiltInCommandsForDropdown jest.mock('@/core/commands', () => ({ getBuiltInCommandsForDropdown: jest.fn(() => [ { id: 'builtin:clear', name: 'clear', description: 'Start a new conversation', content: '' }, { id: 'builtin:add-dir', name: 'add-dir', description: 'Add external context directory', content: '', argumentHint: 'path/to/directory' }, ]), })); function createMockInput(): any { return { value: '', selectionStart: 0, selectionEnd: 0, focus: jest.fn(), addEventListener: jest.fn(), removeEventListener: jest.fn(), }; } function createMockCallbacks(overrides: Partial<SlashCommandDropdownCallbacks> = {}): SlashCommandDropdownCallbacks { return { onSelect: jest.fn(), onHide: jest.fn(), ...overrides, }; } /** * Query the rendered dropdown DOM to extract displayed command items. * Each rendered item has a `.claudian-slash-name` span (text: `/{name}`) * and an optional `.claudian-slash-desc` div. */ function getRenderedItems(containerEl: any): { name: string; description: string }[] { const dropdownEl = containerEl.children.find( (c: any) => c.hasClass('claudian-slash-dropdown') ); if (!dropdownEl) return []; const items = dropdownEl.querySelectorAll('.claudian-slash-item'); return items.map((item: any) => { const nameSpan = item.children.find((c: any) => c.hasClass('claudian-slash-name')); const descDiv = item.children.find((c: any) => c.hasClass('claudian-slash-desc')); return { name: nameSpan?.textContent?.replace(/^\//, '') ?? '', description: descDiv?.textContent ?? '', }; }); } function getRenderedCommandNames(containerEl: any): string[] { return getRenderedItems(containerEl).map(i => i.name); } // SDK commands for testing const SDK_COMMANDS: SlashCommand[] = [ { id: 'sdk:commit', name: 'commit', description: 'Create a git commit', content: '', source: 'sdk' }, { id: 'sdk:pr', name: 'pr', description: 'Create a pull request', content: '', source: 'sdk' }, { id: 'sdk:review', name: 'review', description: 'Review code', content: '', source: 'sdk' }, { id: 'sdk:my-custom', name: 'my-custom', description: 'Custom command', content: '', source: 'sdk' }, { id: 'sdk:compact', name: 'compact', description: 'Compact context', content: '', source: 'sdk' }, ]; // Commands that should be filtered out (not shown in Claudian) const FILTERED_SDK_COMMANDS_LIST: SlashCommand[] = [ { id: 'sdk:context', name: 'context', description: 'Show context', content: '', source: 'sdk' }, { id: 'sdk:cost', name: 'cost', description: 'Show cost', content: '', source: 'sdk' }, { id: 'sdk:init', name: 'init', description: 'Initialize project', content: '', source: 'sdk' }, { id: 'sdk:release-notes', name: 'release-notes', description: 'Release notes', content: '', source: 'sdk' }, { id: 'sdk:security-review', name: 'security-review', description: 'Security review', content: '', source: 'sdk' }, ]; describe('SlashCommandDropdown', () => { let containerEl: any; let inputEl: any; let callbacks: SlashCommandDropdownCallbacks; let dropdown: SlashCommandDropdown; beforeEach(() => { containerEl = createMockEl(); inputEl = createMockInput(); callbacks = createMockCallbacks(); dropdown = new SlashCommandDropdown(containerEl, inputEl, callbacks); }); afterEach(() => { dropdown.destroy(); }); describe('constructor', () => { it('creates dropdown with container and input elements', () => { expect(dropdown).toBeInstanceOf(SlashCommandDropdown); }); it('adds input event listener', () => { expect(inputEl.addEventListener).toHaveBeenCalledWith('input', expect.any(Function)); }); it('accepts optional hiddenCommands in options', () => { const hiddenCommands = new Set(['commit', 'pr']); const dropdownWithHidden = new SlashCommandDropdown( containerEl, inputEl, callbacks, { hiddenCommands } ); expect(dropdownWithHidden).toBeInstanceOf(SlashCommandDropdown); dropdownWithHidden.destroy(); }); }); describe('setEnabled', () => { it('should not show dropdown when disabled', async () => { dropdown.setEnabled(false); inputEl.value = '/'; inputEl.selectionStart = 1; dropdown.handleInputChange(); await new Promise(resolve => setTimeout(resolve, 0)); expect(dropdown.isVisible()).toBe(false); expect(getRenderedCommandNames(containerEl)).toEqual([]); }); it('should hide dropdown when disabling while visible', async () => { inputEl.value = '/'; inputEl.selectionStart = 1; dropdown.handleInputChange(); await new Promise(resolve => setTimeout(resolve, 0)); expect(dropdown.isVisible()).toBe(true); dropdown.setEnabled(false); expect(dropdown.isVisible()).toBe(false); }); }); describe('FILTERED_SDK_COMMANDS filtering', () => { it('should filter out context, cost, init, release-notes, security-review', async () => { const allSdkCommands = [...SDK_COMMANDS, ...FILTERED_SDK_COMMANDS_LIST]; const getSdkCommands = jest.fn().mockResolvedValue(allSdkCommands); const dropdownWithSdk = new SlashCommandDropdown( containerEl, inputEl, { ...callbacks, getSdkCommands } ); // Trigger dropdown inputEl.value = '/'; inputEl.selectionStart = 1; dropdownWithSdk.handleInputChange(); // Wait for async SDK fetch await new Promise(resolve => setTimeout(resolve, 10)); const commandNames = getRenderedCommandNames(containerEl); // Should NOT include filtered commands expect(commandNames).not.toContain('context'); expect(commandNames).not.toContain('cost'); expect(commandNames).not.toContain('init'); expect(commandNames).not.toContain('release-notes'); expect(commandNames).not.toContain('security-review'); // Should include other SDK commands expect(commandNames).toContain('commit'); expect(commandNames).toContain('compact'); expect(commandNames).toContain('pr'); expect(commandNames).toContain('review'); expect(commandNames).toContain('my-custom'); dropdownWithSdk.destroy(); }); }); describe('hidden commands filtering', () => { it('should filter out user-hidden commands from SDK commands', async () => { const getSdkCommands = jest.fn().mockResolvedValue(SDK_COMMANDS); const hiddenCommands = new Set(['commit', 'pr']); const dropdownWithHidden = new SlashCommandDropdown( containerEl, inputEl, { ...callbacks, getSdkCommands }, { hiddenCommands } ); inputEl.value = '/'; inputEl.selectionStart = 1; dropdownWithHidden.handleInputChange(); await new Promise(resolve => setTimeout(resolve, 10)); const commandNames = getRenderedCommandNames(containerEl); // Hidden SDK commands should not appear expect(commandNames).not.toContain('commit'); expect(commandNames).not.toContain('pr'); // Non-hidden SDK commands should appear expect(commandNames).toContain('review'); expect(commandNames).toContain('my-custom'); dropdownWithHidden.destroy(); }); it('should NOT filter out built-in commands even if in hiddenCommands', async () => { const getSdkCommands = jest.fn().mockResolvedValue(SDK_COMMANDS); // Try to hide built-in command 'clear' const hiddenCommands = new Set(['clear', 'add-dir']); const dropdownWithHidden = new SlashCommandDropdown( containerEl, inputEl, { ...callbacks, getSdkCommands }, { hiddenCommands } ); inputEl.value = '/'; inputEl.selectionStart = 1; dropdownWithHidden.handleInputChange(); await new Promise(resolve => setTimeout(resolve, 10)); const commandNames = getRenderedCommandNames(containerEl); // Built-in commands should STILL appear (not subject to hiding) expect(commandNames).toContain('clear'); expect(commandNames).toContain('add-dir'); dropdownWithHidden.destroy(); }); }); describe('deduplication', () => { it('should deduplicate commands by name (built-in takes priority)', async () => { // SDK has a command with same name as built-in const sdkWithDuplicate: SlashCommand[] = [ { id: 'sdk:clear', name: 'clear', description: 'SDK clear command', content: '', source: 'sdk' }, { id: 'sdk:commit', name: 'commit', description: 'Create commit', content: '', source: 'sdk' }, ]; const getSdkCommands = jest.fn().mockResolvedValue(sdkWithDuplicate); const dropdownWithSdk = new SlashCommandDropdown( containerEl, inputEl, { ...callbacks, getSdkCommands } ); inputEl.value = '/cle'; inputEl.selectionStart = 4; dropdownWithSdk.handleInputChange(); await new Promise(resolve => setTimeout(resolve, 10)); const items = getRenderedItems(containerEl); const clearItems = items.filter(i => i.name === 'clear'); // Should only have one 'clear' command expect(clearItems).toHaveLength(1); // And it should be the built-in one (verified by its description) expect(clearItems[0].description).toBe('Start a new conversation'); dropdownWithSdk.destroy(); }); }); describe('SDK command caching', () => { it('should cache SDK commands after first successful fetch', async () => { const getSdkCommands = jest.fn().mockResolvedValue(SDK_COMMANDS); const dropdownWithSdk = new SlashCommandDropdown( containerEl, inputEl, { ...callbacks, getSdkCommands } ); // First trigger inputEl.value = '/'; inputEl.selectionStart = 1; dropdownWithSdk.handleInputChange(); await new Promise(resolve => setTimeout(resolve, 10)); // Second trigger inputEl.value = '/c'; inputEl.selectionStart = 2; dropdownWithSdk.handleInputChange(); await new Promise(resolve => setTimeout(resolve, 10)); // Should only fetch once (cached) expect(getSdkCommands).toHaveBeenCalledTimes(1); dropdownWithSdk.destroy(); }); it('should retry fetch when previous result was empty', async () => { const getSdkCommands = jest.fn() .mockResolvedValueOnce([]) // First call returns empty .mockResolvedValueOnce(SDK_COMMANDS); // Second call returns commands const dropdownWithSdk = new SlashCommandDropdown( containerEl, inputEl, { ...callbacks, getSdkCommands } ); // First trigger - gets empty result inputEl.value = '/'; inputEl.selectionStart = 1; dropdownWithSdk.handleInputChange(); await new Promise(resolve => setTimeout(resolve, 10)); // Second trigger - should retry since previous was empty inputEl.value = '/c'; inputEl.selectionStart = 2; dropdownWithSdk.handleInputChange(); await new Promise(resolve => setTimeout(resolve, 10)); // Should fetch twice (retried because first was empty) expect(getSdkCommands).toHaveBeenCalledTimes(2); dropdownWithSdk.destroy(); }); it('should retry fetch when previous call threw error', async () => { const getSdkCommands = jest.fn() .mockRejectedValueOnce(new Error('SDK not ready')) .mockResolvedValueOnce(SDK_COMMANDS); const dropdownWithSdk = new SlashCommandDropdown( containerEl, inputEl, { ...callbacks, getSdkCommands } ); // First trigger - throws error inputEl.value = '/'; inputEl.selectionStart = 1; dropdownWithSdk.handleInputChange(); await new Promise(resolve => setTimeout(resolve, 10)); // Second trigger - should retry since previous threw inputEl.value = '/c'; inputEl.selectionStart = 2; dropdownWithSdk.handleInputChange(); await new Promise(resolve => setTimeout(resolve, 10)); // Should fetch twice (retried because first failed) expect(getSdkCommands).toHaveBeenCalledTimes(2); dropdownWithSdk.destroy(); }); }); describe('race condition handling', () => { it('should discard stale results when newer request is made', async () => { let resolveFirst: (value: SlashCommand[]) => void; const firstPromise = new Promise<SlashCommand[]>(resolve => { resolveFirst = resolve; }); const getSdkCommands = jest.fn() .mockReturnValueOnce(firstPromise) .mockResolvedValueOnce([ { id: 'sdk:new', name: 'new-command', description: 'New', content: '', source: 'sdk' }, ]); const dropdownWithSdk = new SlashCommandDropdown( containerEl, inputEl, { ...callbacks, getSdkCommands } ); // First trigger (will be slow) inputEl.value = '/'; inputEl.selectionStart = 1; dropdownWithSdk.handleInputChange(); // Second trigger (faster, should supersede first) inputEl.value = '/n'; inputEl.selectionStart = 2; dropdownWithSdk.handleInputChange(); await new Promise(resolve => setTimeout(resolve, 10)); // Now resolve the first (stale) request resolveFirst!(SDK_COMMANDS); await new Promise(resolve => setTimeout(resolve, 10)); // Render dropdown with cached commands to verify stale results were discarded inputEl.value = '/'; inputEl.selectionStart = 1; dropdownWithSdk.handleInputChange(); await new Promise(resolve => setTimeout(resolve, 10)); const names = getRenderedCommandNames(containerEl); // Should have the command from the second (newer) request expect(names).toContain('new-command'); // Should NOT have commands from stale first request expect(names).not.toContain('commit'); dropdownWithSdk.destroy(); }); }); describe('setHiddenCommands', () => { it('should update hidden commands set', async () => { const getSdkCommands = jest.fn().mockResolvedValue(SDK_COMMANDS); const dropdownWithSdk = new SlashCommandDropdown( containerEl, inputEl, { ...callbacks, getSdkCommands } ); // Initial fetch with no hidden commands inputEl.value = '/'; inputEl.selectionStart = 1; dropdownWithSdk.handleInputChange(); await new Promise(resolve => setTimeout(resolve, 10)); expect(getRenderedCommandNames(containerEl)).toContain('commit'); // Now hide commit dropdownWithSdk.setHiddenCommands(new Set(['commit'])); // Trigger again inputEl.value = '/c'; inputEl.selectionStart = 2; dropdownWithSdk.handleInputChange(); await new Promise(resolve => setTimeout(resolve, 10)); expect(getRenderedCommandNames(containerEl)).not.toContain('commit'); dropdownWithSdk.destroy(); }); }); describe('resetSdkSkillsCache', () => { it('should clear cached SDK skills and allow refetch', async () => { const getSdkCommands = jest.fn().mockResolvedValue(SDK_COMMANDS); const dropdownWithSdk = new SlashCommandDropdown( containerEl, inputEl, { ...callbacks, getSdkCommands } ); // First fetch inputEl.value = '/'; inputEl.selectionStart = 1; dropdownWithSdk.handleInputChange(); await new Promise(resolve => setTimeout(resolve, 10)); expect(getSdkCommands).toHaveBeenCalledTimes(1); // Reset cache dropdownWithSdk.resetSdkSkillsCache(); // Trigger again - should refetch inputEl.value = '/c'; inputEl.selectionStart = 2; dropdownWithSdk.handleInputChange(); await new Promise(resolve => setTimeout(resolve, 10)); expect(getSdkCommands).toHaveBeenCalledTimes(2); dropdownWithSdk.destroy(); }); }); describe('handleInputChange', () => { it('should hide dropdown when / is not at position 0', () => { inputEl.value = 'text /command'; inputEl.selectionStart = 13; dropdown.handleInputChange(); expect(callbacks.onHide).toHaveBeenCalled(); }); it('should hide dropdown when whitespace follows command', () => { inputEl.value = '/clear '; inputEl.selectionStart = 7; dropdown.handleInputChange(); expect(callbacks.onHide).toHaveBeenCalled(); }); it('should show dropdown when / is at position 0', async () => { inputEl.value = '/'; inputEl.selectionStart = 1; dropdown.handleInputChange(); await new Promise(resolve => setTimeout(resolve, 10)); // Should have created dropdown element expect(containerEl.children.length).toBeGreaterThan(0); }); }); describe('handleKeydown', () => { it('should return false when dropdown is not visible', () => { const event = { key: 'ArrowDown', preventDefault: jest.fn() } as any; const handled = dropdown.handleKeydown(event); expect(handled).toBe(false); expect(event.preventDefault).not.toHaveBeenCalled(); }); }); describe('isVisible', () => { it('should return false initially', () => { expect(dropdown.isVisible()).toBe(false); }); }); describe('hide', () => { it('should call onHide callback', () => { dropdown.hide(); expect(callbacks.onHide).toHaveBeenCalled(); }); }); describe('destroy', () => { it('should remove input event listener', () => { dropdown.destroy(); expect(inputEl.removeEventListener).toHaveBeenCalledWith('input', expect.any(Function)); }); }); describe('search filtering', () => { it('should filter commands by name', async () => { const getSdkCommands = jest.fn().mockResolvedValue(SDK_COMMANDS); const dropdownWithSdk = new SlashCommandDropdown( containerEl, inputEl, { ...callbacks, getSdkCommands } ); inputEl.value = '/com'; inputEl.selectionStart = 4; dropdownWithSdk.handleInputChange(); await new Promise(resolve => setTimeout(resolve, 10)); const commandNames = getRenderedCommandNames(containerEl); expect(commandNames).toContain('commit'); expect(commandNames).not.toContain('pr'); dropdownWithSdk.destroy(); }); it('should filter commands by description', async () => { const getSdkCommands = jest.fn().mockResolvedValue(SDK_COMMANDS); const dropdownWithSdk = new SlashCommandDropdown( containerEl, inputEl, { ...callbacks, getSdkCommands } ); inputEl.value = '/pull'; inputEl.selectionStart = 5; dropdownWithSdk.handleInputChange(); await new Promise(resolve => setTimeout(resolve, 10)); // 'pr' has description 'Create a pull request' expect(getRenderedCommandNames(containerEl)).toContain('pr'); dropdownWithSdk.destroy(); }); it('should hide dropdown when search has no matches', async () => { inputEl.value = '/xyz123nonexistent'; inputEl.selectionStart = 18; dropdown.handleInputChange(); await new Promise(resolve => setTimeout(resolve, 10)); expect(callbacks.onHide).toHaveBeenCalled(); }); it('should sort results alphabetically', async () => { const getSdkCommands = jest.fn().mockResolvedValue(SDK_COMMANDS); const dropdownWithSdk = new SlashCommandDropdown( containerEl, inputEl, { ...callbacks, getSdkCommands } ); inputEl.value = '/'; inputEl.selectionStart = 1; dropdownWithSdk.handleInputChange(); await new Promise(resolve => setTimeout(resolve, 10)); const names = getRenderedCommandNames(containerEl); const sortedNames = [...names].sort(); expect(names).toEqual(sortedNames); dropdownWithSdk.destroy(); }); }); }); ================================================ FILE: tests/unit/shared/index.test.ts ================================================ jest.mock('@/shared/components/SelectableDropdown', () => ({ SelectableDropdown: function SelectableDropdown() {}, })); jest.mock('@/shared/components/SelectionHighlight', () => ({ hideSelectionHighlight: jest.fn(), showSelectionHighlight: jest.fn(), })); jest.mock('@/shared/components/SlashCommandDropdown', () => ({ SlashCommandDropdown: function SlashCommandDropdown() {}, })); jest.mock('@/shared/icons', () => ({ CHECK_ICON_SVG: '<svg />', MCP_ICON_SVG: '<svg />', })); jest.mock('@/shared/mention/MentionDropdownController', () => ({ MentionDropdownController: function MentionDropdownController() {}, })); jest.mock('@/shared/modals/InstructionConfirmModal', () => ({ InstructionModal: function InstructionModal() {}, })); import { CHECK_ICON_SVG, hideSelectionHighlight, InstructionModal, MCP_ICON_SVG, MentionDropdownController, SelectableDropdown, showSelectionHighlight, SlashCommandDropdown, } from '@/shared'; describe('shared index', () => { it('re-exports runtime symbols', () => { expect(SelectableDropdown).toBeDefined(); expect(showSelectionHighlight).toBeDefined(); expect(hideSelectionHighlight).toBeDefined(); expect(SlashCommandDropdown).toBeDefined(); expect(MentionDropdownController).toBeDefined(); expect(InstructionModal).toBeDefined(); expect(CHECK_ICON_SVG).toBe('<svg />'); expect(MCP_ICON_SVG).toBe('<svg />'); }); }); ================================================ FILE: tests/unit/shared/mention/MentionDropdownController.test.ts ================================================ import { createMockEl } from '@test/helpers/mockElement'; import { type AgentMentionProvider, type McpMentionProvider, type MentionDropdownCallbacks, MentionDropdownController, } from '@/shared/mention/MentionDropdownController'; // Mock externalContextScanner jest.mock('@/utils/externalContextScanner', () => ({ externalContextScanner: { scanPaths: jest.fn().mockReturnValue([]), }, })); // Mock extractMcpMentions jest.mock('@/utils/mcp', () => ({ extractMcpMentions: jest.fn().mockReturnValue(new Set()), })); // Mock SelectableDropdown with controllable visibility let mockDropdownVisible = false; jest.mock('@/shared/components/SelectableDropdown', () => ({ SelectableDropdown: jest.fn().mockImplementation(() => ({ isVisible: jest.fn(() => mockDropdownVisible), hide: jest.fn(() => { mockDropdownVisible = false; }), destroy: jest.fn(), render: jest.fn(() => { mockDropdownVisible = true; }), moveSelection: jest.fn(), getSelectedIndex: jest.fn().mockReturnValue(0), getElement: jest.fn().mockReturnValue(null), })), })); function createMockInput() { return { value: '', selectionStart: 0, selectionEnd: 0, focus: jest.fn(), addEventListener: jest.fn(), removeEventListener: jest.fn(), } as any; } function createMockCallbacks(overrides: Partial<MentionDropdownCallbacks> = {}): MentionDropdownCallbacks { const mentionedServers = new Set<string>(); return { onAttachFile: jest.fn(), onMcpMentionChange: jest.fn(), onAgentMentionSelect: jest.fn(), getMentionedMcpServers: jest.fn().mockReturnValue(mentionedServers), setMentionedMcpServers: jest.fn().mockReturnValue(false), addMentionedMcpServer: jest.fn((name: string) => mentionedServers.add(name)), getExternalContexts: jest.fn().mockReturnValue([]), getCachedVaultFolders: jest.fn().mockReturnValue([]), getCachedVaultFiles: jest.fn().mockReturnValue([]), normalizePathForVault: jest.fn((path: string | undefined | null) => path ?? null), ...overrides, }; } function getLatestDropdownRenderOptions(): any { const { SelectableDropdown } = jest.requireMock('@/shared/components/SelectableDropdown'); const dropdownCtor = SelectableDropdown as jest.Mock; const dropdownInstance = dropdownCtor.mock.results[dropdownCtor.mock.results.length - 1]?.value; const renderMock = dropdownInstance?.render as jest.Mock | undefined; const renderCalls = renderMock?.mock.calls ?? []; return renderCalls[renderCalls.length - 1]?.[0]; } function createMockMcpService(servers: Array<{ name: string }> = []): McpMentionProvider { return { getContextSavingServers: jest.fn().mockReturnValue(servers), }; } function createMockAgentService(agents: Array<{ id: string; name: string; source: 'plugin' | 'vault' | 'global' | 'builtin'; }> = []): AgentMentionProvider { return { searchAgents: jest.fn((query: string) => { if (query === '') return agents; const q = query.toLowerCase(); return agents.filter(a => a.name.toLowerCase().includes(q) || a.id.toLowerCase().includes(q) ); }), }; } describe('MentionDropdownController', () => { let containerEl: any; let inputEl: any; let callbacks: MentionDropdownCallbacks; let controller: MentionDropdownController; beforeEach(() => { jest.useFakeTimers(); mockDropdownVisible = false; const { SelectableDropdown } = jest.requireMock('@/shared/components/SelectableDropdown'); (SelectableDropdown as jest.Mock).mockClear(); containerEl = createMockEl(); inputEl = createMockInput(); callbacks = createMockCallbacks(); controller = new MentionDropdownController(containerEl, inputEl, callbacks); }); afterEach(() => { controller.destroy(); jest.useRealTimers(); }); describe('constructor', () => { it('creates controller with container and input elements', () => { expect(controller).toBeInstanceOf(MentionDropdownController); }); }); describe('setAgentService', () => { it('sets the agent service', () => { const agentService = createMockAgentService([ { id: 'Explore', name: 'Explore', source: 'builtin' }, ]); controller.setAgentService(agentService); // Trigger dropdown to verify service is used inputEl.value = '@'; inputEl.selectionStart = 1; controller.handleInputChange(); jest.advanceTimersByTime(200); expect(agentService.searchAgents).toHaveBeenCalled(); }); it('can set agent service to null', () => { expect(() => { controller.setAgentService(null); inputEl.value = '@'; inputEl.selectionStart = 1; controller.handleInputChange(); jest.advanceTimersByTime(200); }).not.toThrow(); }); }); describe('agent folder entry', () => { it('checks for agents when @ is typed', () => { const agentService = createMockAgentService([ { id: 'Explore', name: 'Explore', source: 'builtin' }, ]); controller.setAgentService(agentService); inputEl.value = '@a'; inputEl.selectionStart = 2; controller.handleInputChange(); jest.advanceTimersByTime(200); // searchAgents should be called with empty string to check if any agents exist expect(agentService.searchAgents).toHaveBeenCalledWith(''); }); it('checks if any agents exist when search is empty', () => { const agentService = createMockAgentService([ { id: 'Explore', name: 'Explore', source: 'builtin' }, ]); controller.setAgentService(agentService); inputEl.value = '@'; inputEl.selectionStart = 1; controller.handleInputChange(); jest.advanceTimersByTime(200); expect(agentService.searchAgents).toHaveBeenCalled(); }); it('does not show agents folder when no agents exist', () => { const agentService = createMockAgentService([]); controller.setAgentService(agentService); inputEl.value = '@a'; inputEl.selectionStart = 2; controller.handleInputChange(); jest.advanceTimersByTime(200); // searchAgents returns empty array, so no Agents folder shown expect(agentService.searchAgents).toHaveBeenCalled(); }); }); describe('@Agents/ filter navigation', () => { it('filters to agents when @Agents/ is typed', () => { const agentService = createMockAgentService([ { id: 'Explore', name: 'Explore', source: 'builtin' }, { id: 'Plan', name: 'Plan', source: 'builtin' }, { id: 'Bash', name: 'Bash', source: 'builtin' }, ]); controller.setAgentService(agentService); inputEl.value = '@Agents/'; inputEl.selectionStart = 8; controller.handleInputChange(); jest.advanceTimersByTime(200); expect(agentService.searchAgents).toHaveBeenCalledWith(''); }); it('searches agents within @Agents/ filter', () => { const agentService = createMockAgentService([ { id: 'Explore', name: 'Explore', source: 'builtin' }, { id: 'Plan', name: 'Plan', source: 'builtin' }, { id: 'Bash', name: 'Bash', source: 'builtin' }, ]); controller.setAgentService(agentService); inputEl.value = '@Agents/exp'; inputEl.selectionStart = 11; controller.handleInputChange(); jest.advanceTimersByTime(200); expect(agentService.searchAgents).toHaveBeenCalledWith('exp'); }); it('is case-insensitive for agents/ prefix', () => { const agentService = createMockAgentService([ { id: 'Explore', name: 'Explore', source: 'builtin' }, ]); controller.setAgentService(agentService); inputEl.value = '@agents/'; inputEl.selectionStart = 8; controller.handleInputChange(); jest.advanceTimersByTime(200); expect(agentService.searchAgents).toHaveBeenCalledWith(''); }); it('handles mixed case agents prefix', () => { const agentService = createMockAgentService([ { id: 'Explore', name: 'Explore', source: 'builtin' }, ]); controller.setAgentService(agentService); inputEl.value = '@AGENTS/test'; inputEl.selectionStart = 12; controller.handleInputChange(); jest.advanceTimersByTime(200); expect(agentService.searchAgents).toHaveBeenCalledWith('test'); }); }); describe('setMcpManager', () => { it('sets the MCP manager', () => { const mcpManager = createMockMcpService([{ name: 'filesystem' }]); controller.setMcpManager(mcpManager); inputEl.value = '@'; inputEl.selectionStart = 1; controller.handleInputChange(); jest.advanceTimersByTime(200); expect(mcpManager.getContextSavingServers).toHaveBeenCalled(); }); }); describe('mixed providers', () => { it('queries both MCP servers and agents', () => { const mcpManager = createMockMcpService([{ name: 'filesystem' }]); const agentService = createMockAgentService([ { id: 'Explore', name: 'Explore', source: 'builtin' }, ]); controller.setMcpManager(mcpManager); controller.setAgentService(agentService); inputEl.value = '@'; inputEl.selectionStart = 1; controller.handleInputChange(); jest.advanceTimersByTime(200); expect(mcpManager.getContextSavingServers).toHaveBeenCalled(); expect(agentService.searchAgents).toHaveBeenCalled(); }); }); describe('hide', () => { it('can be called without error', () => { expect(() => controller.hide()).not.toThrow(); }); }); describe('destroy', () => { it('cleans up resources', () => { expect(() => controller.destroy()).not.toThrow(); }); }); describe('handleInputChange', () => { it('hides dropdown when no @ in text', () => { inputEl.value = 'no at sign'; inputEl.selectionStart = 10; controller.handleInputChange(); expect(() => jest.advanceTimersByTime(200)).not.toThrow(); }); it('hides dropdown when @ is not at word boundary', () => { inputEl.value = 'test@example'; inputEl.selectionStart = 12; controller.handleInputChange(); expect(() => jest.advanceTimersByTime(200)).not.toThrow(); }); it('hides dropdown when space follows @mention', () => { inputEl.value = '@test '; inputEl.selectionStart = 6; controller.handleInputChange(); expect(() => jest.advanceTimersByTime(200)).not.toThrow(); }); it('handles @ at start of line', () => { const agentService = createMockAgentService([ { id: 'Explore', name: 'Explore', source: 'builtin' }, ]); controller.setAgentService(agentService); inputEl.value = '@Explore'; inputEl.selectionStart = 8; controller.handleInputChange(); jest.advanceTimersByTime(200); expect(agentService.searchAgents).toHaveBeenCalled(); }); it('handles @ after whitespace', () => { const agentService = createMockAgentService([ { id: 'Explore', name: 'Explore', source: 'builtin' }, ]); controller.setAgentService(agentService); inputEl.value = 'hello @Explore'; inputEl.selectionStart = 14; controller.handleInputChange(); jest.advanceTimersByTime(200); expect(agentService.searchAgents).toHaveBeenCalled(); }); }); describe('handleKeydown', () => { it('returns false when dropdown not visible', () => { const event = { key: 'ArrowDown', preventDefault: jest.fn() } as any; const handled = controller.handleKeydown(event); expect(handled).toBe(false); }); }); describe('isVisible', () => { it('returns false initially', () => { expect(controller.isVisible()).toBe(false); }); }); describe('containsElement', () => { it('returns false when element not in dropdown', () => { const el = createMockEl(); expect(controller.containsElement(el)).toBe(false); }); }); describe('preScanExternalContexts', () => { it('can be called without error', () => { expect(() => controller.preScanExternalContexts()).not.toThrow(); }); }); describe('updateMcpMentionsFromText', () => { it('does nothing without MCP manager', () => { expect(() => controller.updateMcpMentionsFromText('@test')).not.toThrow(); }); it('updates mentions when MCP manager is set', () => { const mcpManager = createMockMcpService([{ name: 'test' }]); controller.setMcpManager(mcpManager); controller.updateMcpMentionsFromText('@test'); expect(mcpManager.getContextSavingServers).toHaveBeenCalled(); }); }); describe('agent selection callback', () => { it('calls onAgentMentionSelect when agent is selected via dropdown', () => { const onAgentMentionSelect = jest.fn(); const testCallbacks = createMockCallbacks({ onAgentMentionSelect }); const testInput = createMockInput(); const testController = new MentionDropdownController( createMockEl(), testInput, testCallbacks ); const agentService = createMockAgentService([ { id: 'custom-agent', name: 'Custom Agent', source: 'vault' }, ]); testController.setAgentService(agentService); // Type @Agents/ to navigate into the agent submenu and populate items testInput.value = '@Agents/'; testInput.selectionStart = 8; testController.handleInputChange(); jest.advanceTimersByTime(200); // handleInputChange populates filteredMentionItems and calls dropdown.render(), // which sets mockDropdownVisible = true. Press Enter to select the first item. const enterEvent = { key: 'Enter', preventDefault: jest.fn(), isComposing: false } as any; testController.handleKeydown(enterEvent); expect(onAgentMentionSelect).toHaveBeenCalledWith('custom-agent'); testController.destroy(); }); }); describe('input debouncing', () => { it('debounces rapid input changes', () => { const agentService = createMockAgentService([ { id: 'Explore', name: 'Explore', source: 'builtin' }, ]); controller.setAgentService(agentService); inputEl.value = '@'; inputEl.selectionStart = 1; controller.handleInputChange(); inputEl.value = '@E'; inputEl.selectionStart = 2; controller.handleInputChange(); inputEl.value = '@Ex'; inputEl.selectionStart = 3; controller.handleInputChange(); expect(agentService.searchAgents).not.toHaveBeenCalled(); jest.advanceTimersByTime(200); expect(agentService.searchAgents).toHaveBeenCalledTimes(1); }); it('clears pending timer on destroy', () => { inputEl.value = '@test'; inputEl.selectionStart = 5; controller.handleInputChange(); expect(() => { controller.destroy(); jest.runAllTimers(); }).not.toThrow(); }); it('processes input after debounce delay', () => { const agentService = createMockAgentService([ { id: 'Explore', name: 'Explore', source: 'builtin' }, ]); controller.setAgentService(agentService); inputEl.value = '@Explore'; inputEl.selectionStart = 8; controller.handleInputChange(); jest.advanceTimersByTime(199); expect(agentService.searchAgents).not.toHaveBeenCalled(); jest.advanceTimersByTime(1); expect(agentService.searchAgents).toHaveBeenCalled(); }); }); describe('result limiting', () => { it('limits vault file results to 100 items', () => { const largeFileSet = Array.from({ length: 200 }, (_, i) => ({ path: `note${i}.md`, name: `note${i}.md`, stat: { mtime: Date.now() - i }, })) as any[]; const limitedCallbacks = createMockCallbacks({ getCachedVaultFiles: jest.fn().mockReturnValue(largeFileSet), }); const testController = new MentionDropdownController( createMockEl(), createMockInput(), limitedCallbacks ); expect(testController).toBeDefined(); testController.destroy(); }); }); describe('vault folder mentions', () => { it('limits vault folder results to 50 items', () => { const largeFolderSet = Array.from({ length: 80 }, (_, i) => ({ name: `folder${i}`, path: `src/folder${i}`, })); const localCallbacks = createMockCallbacks({ getCachedVaultFolders: jest.fn().mockReturnValue(largeFolderSet), }); const localInput = createMockInput(); const localController = new MentionDropdownController(createMockEl(), localInput, localCallbacks); localInput.value = '@folder'; localInput.selectionStart = 7; localController.handleInputChange(); jest.advanceTimersByTime(200); const renderOptions = getLatestDropdownRenderOptions(); const folderItems = renderOptions.items.filter((item: any) => item.type === 'folder'); expect(folderItems).toHaveLength(50); localController.destroy(); }); it('prioritizes name starts-with matches then sorts by mtime', () => { const localCallbacks = createMockCallbacks({ getCachedVaultFolders: jest.fn().mockReturnValue([ { name: 'helpers', path: 'lib/src-utils' }, { name: 'src-core', path: 'src-core' }, { name: 'src-app', path: 'src-app' }, ]), getCachedVaultFiles: jest.fn().mockReturnValue([ { path: 'src-core/main.ts', name: 'main.ts', stat: { mtime: 3000 } }, { path: 'src-app/index.ts', name: 'index.ts', stat: { mtime: 1000 } }, { path: 'lib/src-utils/helper.ts', name: 'helper.ts', stat: { mtime: 2000 } }, ] as any[]), }); const localInput = createMockInput(); const localController = new MentionDropdownController(createMockEl(), localInput, localCallbacks); localInput.value = '@src'; localInput.selectionStart = 4; localController.handleInputChange(); jest.advanceTimersByTime(200); const renderOptions = getLatestDropdownRenderOptions(); const folderItems = renderOptions.items.filter((item: any) => item.type === 'folder'); // starts-with matches first (src-core, src-app), sorted by derived mtime desc // src-core has file mtime 3000, src-app has 1000, lib/src-utils has 2000 expect(folderItems.map((item: any) => item.path)).toEqual([ 'src-core', 'src-app', 'lib/src-utils', ]); localController.destroy(); }); it('defaults selection to first vault item when special items exist', () => { const localCallbacks = createMockCallbacks({ getCachedVaultFolders: jest.fn().mockReturnValue([ { name: 'src', path: 'src' }, ]), getCachedVaultFiles: jest.fn().mockReturnValue([ { path: 'note.md', name: 'note.md', stat: { mtime: Date.now() }, } as any, ]), }); const localInput = createMockInput(); const localController = new MentionDropdownController(createMockEl(), localInput, localCallbacks); localController.setMcpManager(createMockMcpService([{ name: 'filesystem' }])); localInput.value = '@'; localInput.selectionStart = 1; localController.handleInputChange(); jest.advanceTimersByTime(200); const renderOptions = getLatestDropdownRenderOptions(); expect(renderOptions.selectedIndex).toBe(1); localController.destroy(); }); it('inserts folder mention as plain text and does not attach file context', () => { const onAttachFile = jest.fn(); const localCallbacks = createMockCallbacks({ onAttachFile, getCachedVaultFolders: jest.fn().mockReturnValue([ { name: 'src', path: 'src' }, ]), }); const localInput = createMockInput(); const localController = new MentionDropdownController(createMockEl(), localInput, localCallbacks); localInput.value = '@src'; localInput.selectionStart = 4; localInput.selectionEnd = 4; localController.handleInputChange(); jest.advanceTimersByTime(200); const enterEvent = { key: 'Enter', preventDefault: jest.fn(), isComposing: false } as any; localController.handleKeydown(enterEvent); expect(localInput.value).toBe('@src/ '); expect(onAttachFile).not.toHaveBeenCalled(); localController.destroy(); }); it('renders vault folder text in @path/ format', () => { const localCallbacks = createMockCallbacks({ getCachedVaultFolders: jest.fn().mockReturnValue([ { name: 'src', path: 'src' }, ]), }); const localInput = createMockInput(); const localController = new MentionDropdownController(createMockEl(), localInput, localCallbacks); localInput.value = '@src'; localInput.selectionStart = 4; localController.handleInputChange(); jest.advanceTimersByTime(200); const renderOptions = getLatestDropdownRenderOptions(); const folderItem = renderOptions.items.find((item: any) => item.type === 'folder'); expect(folderItem).toBeDefined(); const itemEl = createMockEl(); renderOptions.renderItem(folderItem, itemEl); const nameEl = itemEl.querySelector('.claudian-mention-name-folder'); expect(nameEl?.textContent).toBe('@src/'); localController.destroy(); }); it('still shows vault folder matches when slash search overlaps external context', () => { const { externalContextScanner } = jest.requireMock('@/utils/externalContextScanner'); (externalContextScanner.scanPaths as jest.Mock).mockReturnValue([]); const localCallbacks = createMockCallbacks({ getExternalContexts: jest.fn().mockReturnValue(['/external/src']), getCachedVaultFolders: jest.fn().mockReturnValue([ { name: 'components', path: 'src/components' }, ]), }); const localInput = createMockInput(); const localController = new MentionDropdownController(createMockEl(), localInput, localCallbacks); localInput.value = '@src/'; localInput.selectionStart = 5; localController.handleInputChange(); jest.advanceTimersByTime(200); const renderOptions = getLatestDropdownRenderOptions(); const folderItems = renderOptions.items.filter((item: any) => item.type === 'folder'); expect(folderItems.map((item: any) => item.path)).toContain('src/components'); localController.destroy(); }); it('filters out slash-only root folder paths', () => { const localCallbacks = createMockCallbacks({ getCachedVaultFolders: jest.fn().mockReturnValue([ { name: '/', path: '/' }, { name: 'src', path: 'src' }, ]), }); const localInput = createMockInput(); const localController = new MentionDropdownController(createMockEl(), localInput, localCallbacks); localInput.value = '@'; localInput.selectionStart = 1; localController.handleInputChange(); jest.advanceTimersByTime(200); const renderOptions = getLatestDropdownRenderOptions(); const folderItems = renderOptions.items.filter((item: any) => item.type === 'folder'); expect(folderItems.map((item: any) => item.path)).toEqual(['src']); localController.destroy(); }); it('sorts files and folders by mtime with alphabetical tiebreaker', () => { const now = Date.now(); const localCallbacks = createMockCallbacks({ getCachedVaultFolders: jest.fn().mockReturnValue([ { name: 'recent-folder', path: 'recent-folder' }, { name: 'old-folder', path: 'old-folder' }, ]), getCachedVaultFiles: jest.fn().mockReturnValue([ { path: 'recent-folder/new.md', name: 'new.md', stat: { mtime: now } }, { path: 'old-folder/old.md', name: 'old.md', stat: { mtime: now - 5000 } }, { path: 'root-file.md', name: 'root-file.md', stat: { mtime: now - 2000 } }, ] as any[]), }); const localInput = createMockInput(); const localController = new MentionDropdownController(createMockEl(), localInput, localCallbacks); localInput.value = '@'; localInput.selectionStart = 1; localController.handleInputChange(); jest.advanceTimersByTime(200); const renderOptions = getLatestDropdownRenderOptions(); const vaultItems = renderOptions.items.filter( (item: any) => item.type === 'file' || item.type === 'folder' ); // mtime: recent-folder=now (from new.md), old-folder=now-5000 (from old.md) // Files: new.md=now, root-file.md=now-2000, old.md=now-5000 // When mtime ties, files sort above folders expect(vaultItems.map((item: any) => ({ type: item.type, path: item.path }))).toEqual([ { type: 'file', path: 'recent-folder/new.md' }, { type: 'folder', path: 'recent-folder' }, { type: 'file', path: 'root-file.md' }, { type: 'file', path: 'old-folder/old.md' }, { type: 'folder', path: 'old-folder' }, ]); localController.destroy(); }); }); }); ================================================ FILE: tests/unit/shared/mention/VaultFileCache.test.ts ================================================ import type { App, TFile } from 'obsidian'; import { VaultFileCache } from '@/shared/mention/VaultMentionCache'; describe('VaultFileCache', () => { let mockApp: App; let mockFiles: TFile[]; beforeEach(() => { mockFiles = [ { path: 'note1.md', name: 'note1.md' } as TFile, { path: 'note2.md', name: 'note2.md' } as TFile, ]; mockApp = { vault: { getFiles: jest.fn().mockReturnValue(mockFiles), }, } as any; }); describe('getFiles', () => { it('should return cached files on first call', () => { const cache = new VaultFileCache(mockApp); const files = cache.getFiles(); expect(files).toEqual(mockFiles); expect(mockApp.vault.getFiles).toHaveBeenCalledTimes(1); }); it('should return cached files on subsequent calls without re-fetching', () => { const cache = new VaultFileCache(mockApp); cache.getFiles(); cache.getFiles(); expect(mockApp.vault.getFiles).toHaveBeenCalledTimes(1); }); it('should re-fetch when marked dirty', () => { const cache = new VaultFileCache(mockApp); cache.getFiles(); cache.markDirty(); cache.getFiles(); expect(mockApp.vault.getFiles).toHaveBeenCalledTimes(2); }); it('should return the same array reference (no defensive copy)', () => { const cache = new VaultFileCache(mockApp); const files1 = cache.getFiles(); const files2 = cache.getFiles(); expect(files1).toBe(files2); }); it('should return stale files if reload fails', () => { const getFiles = jest .fn() .mockReturnValueOnce(mockFiles) .mockImplementation(() => { throw new Error('Vault error'); }); mockApp.vault.getFiles = getFiles as any; const cache = new VaultFileCache(mockApp); expect(cache.getFiles()).toEqual(mockFiles); cache.markDirty(); expect(cache.getFiles()).toEqual(mockFiles); expect(getFiles).toHaveBeenCalledTimes(2); }); it('should invoke onLoadError callback when getFiles fails', () => { const error = new Error('Vault error'); mockApp.vault.getFiles = jest.fn(() => { throw error; }); const onLoadError = jest.fn(); const cache = new VaultFileCache(mockApp, { onLoadError }); cache.getFiles(); expect(onLoadError).toHaveBeenCalledWith(error); }); it('should not reload repeatedly when vault has no files', () => { mockApp.vault.getFiles = jest.fn().mockReturnValue([]); const cache = new VaultFileCache(mockApp); expect(cache.getFiles()).toEqual([]); expect(cache.getFiles()).toEqual([]); expect(mockApp.vault.getFiles).toHaveBeenCalledTimes(1); }); }); describe('initializeInBackground', () => { beforeEach(() => { jest.useFakeTimers(); }); afterEach(() => { jest.useRealTimers(); }); it('should populate cache in background', () => { const cache = new VaultFileCache(mockApp); cache.initializeInBackground(); expect(mockApp.vault.getFiles).not.toHaveBeenCalled(); jest.runAllTimers(); expect(mockApp.vault.getFiles).toHaveBeenCalledTimes(1); }); it('should not re-initialize if already initialized', () => { const cache = new VaultFileCache(mockApp); cache.initializeInBackground(); jest.runAllTimers(); cache.initializeInBackground(); jest.runAllTimers(); expect(mockApp.vault.getFiles).toHaveBeenCalledTimes(1); }); it('should handle errors gracefully', () => { mockApp.vault.getFiles = jest.fn(() => { throw new Error('Vault error'); }); const cache = new VaultFileCache(mockApp); cache.initializeInBackground(); expect(() => jest.runAllTimers()).not.toThrow(); }); it('should invoke onLoadError callback when initialization fails', () => { const error = new Error('Vault error'); mockApp.vault.getFiles = jest.fn(() => { throw error; }); const onLoadError = jest.fn(); const cache = new VaultFileCache(mockApp, { onLoadError }); cache.initializeInBackground(); jest.runAllTimers(); expect(onLoadError).toHaveBeenCalledWith(error); }); it('should mark initialization attempted even if loading fails', () => { mockApp.vault.getFiles = jest.fn(() => { throw new Error('Vault error'); }); const cache = new VaultFileCache(mockApp); cache.initializeInBackground(); jest.runOnlyPendingTimers(); cache.initializeInBackground(); jest.runOnlyPendingTimers(); expect(mockApp.vault.getFiles).toHaveBeenCalledTimes(1); }); it('should make cache available after initialization', () => { const cache = new VaultFileCache(mockApp); cache.initializeInBackground(); jest.runAllTimers(); const files = cache.getFiles(); expect(files).toEqual(mockFiles); expect(mockApp.vault.getFiles).toHaveBeenCalledTimes(1); }); }); describe('markDirty', () => { it('should force re-fetch on next getFiles call', () => { const cache = new VaultFileCache(mockApp); cache.getFiles(); const newFiles = [{ path: 'note3.md', name: 'note3.md' } as TFile]; mockApp.vault.getFiles = jest.fn().mockReturnValue(newFiles); cache.markDirty(); const files = cache.getFiles(); expect(files).toEqual(newFiles); expect(mockApp.vault.getFiles).toHaveBeenCalledTimes(1); }); }); }); ================================================ FILE: tests/unit/shared/mention/VaultFolderCache.test.ts ================================================ import { TFile, TFolder } from 'obsidian'; import { VaultFolderCache } from '@/shared/mention/VaultMentionCache'; function createFolder(path: string): TFolder { return new (TFolder as any)(path) as TFolder; } function createFile(path: string): TFile { return new (TFile as any)(path) as TFile; } describe('VaultFolderCache', () => { afterEach(() => { jest.useRealTimers(); }); it('excludes root and hidden folders', () => { const loadedFiles = [ createFolder(''), createFolder('/'), createFolder('//'), createFolder('.obsidian'), createFolder('src/.private'), createFolder('src'), createFolder('src/components'), createFile('notes/example.md'), ]; const app = { vault: { getAllLoadedFiles: jest.fn(() => loadedFiles), }, } as any; const cache = new VaultFolderCache(app); const folders = cache.getFolders().map(folder => folder.path); expect(folders).toEqual(['src', 'src/components']); }); it('returns cached folders until marked dirty', () => { let loadedFiles = [createFolder('src')]; const getAllLoadedFiles = jest.fn(() => loadedFiles); const app = { vault: { getAllLoadedFiles }, } as any; const cache = new VaultFolderCache(app); const initial = cache.getFolders().map(folder => folder.path); loadedFiles = [createFolder('docs')]; const second = cache.getFolders().map(folder => folder.path); expect(initial).toEqual(['src']); expect(second).toEqual(['src']); expect(getAllLoadedFiles).toHaveBeenCalledTimes(1); }); it('refreshes folder list after markDirty', () => { let loadedFiles = [createFolder('src')]; const getAllLoadedFiles = jest.fn(() => loadedFiles); const app = { vault: { getAllLoadedFiles }, } as any; const cache = new VaultFolderCache(app); cache.getFolders(); loadedFiles = [createFolder('docs')]; cache.markDirty(); const refreshed = cache.getFolders().map(folder => folder.path); expect(refreshed).toEqual(['docs']); expect(getAllLoadedFiles).toHaveBeenCalledTimes(2); }); it('supports lazy background initialization', () => { jest.useFakeTimers(); const getAllLoadedFiles = jest.fn(() => [createFolder('src')]); const app = { vault: { getAllLoadedFiles }, } as any; const cache = new VaultFolderCache(app); cache.initializeInBackground(); expect(getAllLoadedFiles).not.toHaveBeenCalled(); jest.runOnlyPendingTimers(); expect(getAllLoadedFiles).toHaveBeenCalledTimes(1); const folders = cache.getFolders().map(folder => folder.path); expect(folders).toEqual(['src']); expect(getAllLoadedFiles).toHaveBeenCalledTimes(1); }); it('returns stale folders if reload fails', () => { const getAllLoadedFiles = jest .fn() .mockReturnValueOnce([createFolder('src')]) .mockImplementation(() => { throw new Error('Vault error'); }); const app = { vault: { getAllLoadedFiles }, } as any; const cache = new VaultFolderCache(app); expect(cache.getFolders().map(folder => folder.path)).toEqual(['src']); cache.markDirty(); expect(cache.getFolders().map(folder => folder.path)).toEqual(['src']); expect(getAllLoadedFiles).toHaveBeenCalledTimes(2); }); it('does not reload repeatedly when vault has no visible folders', () => { const getAllLoadedFiles = jest.fn(() => []); const app = { vault: { getAllLoadedFiles }, } as any; const cache = new VaultFolderCache(app); expect(cache.getFolders()).toEqual([]); expect(cache.getFolders()).toEqual([]); expect(getAllLoadedFiles).toHaveBeenCalledTimes(1); }); it('marks background initialization as attempted after failure', () => { jest.useFakeTimers(); const getAllLoadedFiles = jest.fn(() => { throw new Error('Vault error'); }); const app = { vault: { getAllLoadedFiles }, } as any; const cache = new VaultFolderCache(app); cache.initializeInBackground(); jest.runOnlyPendingTimers(); cache.initializeInBackground(); jest.runOnlyPendingTimers(); expect(getAllLoadedFiles).toHaveBeenCalledTimes(1); }); }); ================================================ FILE: tests/unit/shared/mention/VaultMentionDataProvider.test.ts ================================================ import { TFile, TFolder } from 'obsidian'; import { VaultMentionDataProvider } from '@/shared/mention/VaultMentionDataProvider'; function createFile(path: string): TFile { const file = new (TFile as any)(path) as TFile; (file as any).stat = { mtime: Date.now(), ctime: Date.now(), size: 0 }; return file; } function createFolder(path: string): TFolder { return new (TFolder as any)(path) as TFolder; } describe('VaultMentionDataProvider', () => { afterEach(() => { jest.useRealTimers(); }); it('returns cached vault files and folders without reloading until dirty', () => { const files = [createFile('notes/a.md')]; const folders = [createFolder('notes')]; const app = { vault: { getFiles: jest.fn(() => files), getAllLoadedFiles: jest.fn(() => folders), }, } as any; const provider = new VaultMentionDataProvider(app); expect(provider.getCachedVaultFiles()).toEqual(files); expect(provider.getCachedVaultFiles()).toEqual(files); expect(provider.getCachedVaultFolders()).toEqual([{ name: 'notes', path: 'notes' }]); expect(provider.getCachedVaultFolders()).toEqual([{ name: 'notes', path: 'notes' }]); expect(app.vault.getFiles).toHaveBeenCalledTimes(1); expect(app.vault.getAllLoadedFiles).toHaveBeenCalledTimes(1); provider.markFilesDirty(); provider.markFoldersDirty(); provider.getCachedVaultFiles(); provider.getCachedVaultFolders(); expect(app.vault.getFiles).toHaveBeenCalledTimes(2); expect(app.vault.getAllLoadedFiles).toHaveBeenCalledTimes(2); }); it('initializes file and folder caches in background', () => { jest.useFakeTimers(); const app = { vault: { getFiles: jest.fn(() => [createFile('notes/a.md')]), getAllLoadedFiles: jest.fn(() => [createFolder('notes')]), }, } as any; const provider = new VaultMentionDataProvider(app); provider.initializeInBackground(); expect(app.vault.getFiles).not.toHaveBeenCalled(); expect(app.vault.getAllLoadedFiles).not.toHaveBeenCalled(); jest.runOnlyPendingTimers(); expect(app.vault.getFiles).toHaveBeenCalledTimes(1); expect(app.vault.getAllLoadedFiles).toHaveBeenCalledTimes(1); }); it('reports file load errors only once while continuing to return an empty result', () => { const onFileLoadError = jest.fn(); const app = { vault: { getFiles: jest.fn(() => { throw new Error('Vault unavailable'); }), getAllLoadedFiles: jest.fn(() => []), }, } as any; const provider = new VaultMentionDataProvider(app, { onFileLoadError }); expect(provider.getCachedVaultFiles()).toEqual([]); expect(provider.getCachedVaultFiles()).toEqual([]); expect(app.vault.getFiles).toHaveBeenCalledTimes(2); expect(onFileLoadError).toHaveBeenCalledTimes(1); }); }); ================================================ FILE: tests/unit/shared/modals/ConfirmModal.test.ts ================================================ import { createMockEl } from '@test/helpers/mockElement'; let lastModalInstance: any; let createdButtons: any[] = []; jest.mock('obsidian', () => { const actual = jest.requireActual('obsidian'); class MockModal { app: any; modalEl: any; contentEl: any; constructor(app: any) { this.app = app; this.modalEl = createMockEl(); this.contentEl = createMockEl(); // eslint-disable-next-line @typescript-eslint/no-this-alias lastModalInstance = this; } setTitle = jest.fn(); open() { this.onOpen(); } close() { this.onClose(); } onOpen() { // Overridden by subclass } onClose() { // Overridden by subclass } } class MockSetting { constructor(_containerEl: any) {} addButton(cb: (btn: any) => void) { const btn: any = { _onClick: null as null | (() => void), setButtonText: jest.fn().mockReturnThis(), setWarning: jest.fn().mockReturnThis(), onClick: jest.fn((handler: () => void) => { btn._onClick = handler; return btn; }), }; createdButtons.push(btn); cb(btn); return this; } } return { ...actual, Modal: MockModal, Setting: MockSetting, }; }); import { confirm, confirmDelete } from '@/shared/modals/ConfirmModal'; beforeEach(() => { lastModalInstance = null; createdButtons = []; }); describe('ConfirmModal', () => { const mockApp = {} as any; it('confirmDelete resolves true when confirm button is clicked', async () => { const p = confirmDelete(mockApp, 'Are you sure?'); expect(lastModalInstance).toBeTruthy(); expect(createdButtons).toHaveLength(2); const confirmBtn = createdButtons[1]; confirmBtn._onClick(); await expect(p).resolves.toBe(true); expect(lastModalInstance.contentEl.children).toHaveLength(0); }); it('confirmDelete resolves false when closed without confirming', async () => { const p = confirmDelete(mockApp, 'Are you sure?'); lastModalInstance.close(); await expect(p).resolves.toBe(false); expect(lastModalInstance.contentEl.children).toHaveLength(0); }); it('confirm resolves true when confirm button is clicked', async () => { const p = confirm(mockApp, 'Proceed?', 'Confirm'); expect(createdButtons).toHaveLength(2); const confirmBtn = createdButtons[1]; expect(confirmBtn.setButtonText).toHaveBeenLastCalledWith('Confirm'); confirmBtn._onClick(); await expect(p).resolves.toBe(true); }); }); ================================================ FILE: tests/unit/shared/modals/ForkTargetModal.test.ts ================================================ import { createMockEl } from '@test/helpers/mockElement'; import { chooseForkTarget } from '@/shared/modals/ForkTargetModal'; let lastModalInstance: any; jest.mock('obsidian', () => { const actual = jest.requireActual('obsidian'); class MockModal { app: any; modalEl: any = { addClass: jest.fn() }; contentEl: any; constructor(app: any) { this.app = app; this.contentEl = createMockEl(); // eslint-disable-next-line @typescript-eslint/no-this-alias lastModalInstance = this; } setTitle = jest.fn(); open() { this.onOpen(); } close() { this.onClose(); } onOpen() { // Overridden by subclass } onClose() { // Overridden by subclass } } return { ...actual, Modal: MockModal, }; }); function getOptionItems(): Array<{ text: string; click: () => void }> { const listEl = lastModalInstance.contentEl.children?.find( (c: any) => c.hasClass?.('claudian-fork-target-list'), ); if (!listEl) return []; return (listEl.children || []) .filter((c: any) => c.hasClass?.('claudian-fork-target-option')) .map((c: any) => ({ text: c.textContent, click: () => { const handler = c._eventListeners?.get('click')?.[0]; handler?.(); }, })); } beforeEach(() => { lastModalInstance = null; }); describe('ForkTargetModal', () => { const mockApp = {} as any; describe('chooseForkTarget', () => { it('should resolve "current-tab" when current tab option is clicked', async () => { const result = chooseForkTarget(mockApp); const items = getOptionItems(); const item = items.find(i => i.text === 'Current tab'); item!.click(); expect(await result).toBe('current-tab'); }); it('should resolve "new-tab" when new tab option is clicked', async () => { const result = chooseForkTarget(mockApp); const items = getOptionItems(); const item = items.find(i => i.text === 'New tab'); item!.click(); expect(await result).toBe('new-tab'); }); it('should resolve null when modal is closed without selection', async () => { const result = chooseForkTarget(mockApp); lastModalInstance.close(); expect(await result).toBeNull(); }); it('should create two list options with correct labels', () => { chooseForkTarget(mockApp); const items = getOptionItems(); expect(items).toHaveLength(2); expect(items[0].text).toBe('Current tab'); expect(items[1].text).toBe('New tab'); }); }); }); ================================================ FILE: tests/unit/shared/modals/InstructionConfirmModal.test.ts ================================================ import { createMockEl } from '@test/helpers/mockElement'; import { InstructionModal, type InstructionModalCallbacks, } from '@/shared/modals/InstructionConfirmModal'; function createMockCallbacks( overrides: Partial<InstructionModalCallbacks> = {} ): InstructionModalCallbacks { return { onAccept: jest.fn(), onReject: jest.fn(), onClarificationSubmit: jest.fn().mockResolvedValue(undefined), ...overrides, }; } function openModal( rawInstruction: string, callbacks: InstructionModalCallbacks ): InstructionModal { const modal = new InstructionModal({} as any, rawInstruction, callbacks); (modal as any).setTitle = jest.fn(); (modal as any).contentEl = createMockEl(); (modal as any).close = jest.fn(); InstructionModal.prototype.onOpen.call(modal); return modal; } function findByClass(root: any, cls: string): any { if (root.hasClass?.(cls)) return root; for (const child of root.children || []) { const found = findByClass(child, cls); if (found) return found; } return null; } function findAllByClass(root: any, cls: string): any[] { const results: any[] = []; const collect = (el: any) => { if (el.hasClass?.(cls)) results.push(el); for (const child of el.children || []) collect(child); }; collect(root); return results; } function clickButton(root: any, text: string): void { const buttons = findAllByClass(root, 'claudian-instruction-btn'); const btn = buttons.find((b: any) => b.textContent === text); if (!btn) throw new Error(`Button "${text}" not found`); btn.click(); } describe('InstructionModal', () => { describe('onOpen', () => { it('renders the raw instruction text', () => { const callbacks = createMockCallbacks(); const modal = openModal('Make it better', callbacks); const contentEl = (modal as any).contentEl; const originalEl = findByClass(contentEl, 'claudian-instruction-original'); expect(originalEl).not.toBeNull(); expect(originalEl.textContent).toBe('Make it better'); }); it('starts in loading state', () => { const callbacks = createMockCallbacks(); const modal = openModal('test', callbacks); const contentEl = (modal as any).contentEl; const loadingEl = findByClass(contentEl, 'claudian-instruction-loading'); expect(loadingEl).not.toBeNull(); expect(loadingEl.style.display).not.toBe('none'); const clarificationEl = findByClass(contentEl, 'claudian-instruction-clarification-section'); expect(clarificationEl.style.display).toBe('none'); const confirmationEl = findByClass(contentEl, 'claudian-instruction-confirmation-section'); expect(confirmationEl.style.display).toBe('none'); }); it('renders Cancel button in loading state', () => { const callbacks = createMockCallbacks(); const modal = openModal('test', callbacks); const contentEl = (modal as any).contentEl; const buttons = findAllByClass(contentEl, 'claudian-instruction-btn'); expect(buttons.length).toBe(1); expect(buttons[0].textContent).toBe('Cancel'); }); }); describe('showClarification', () => { it('transitions to clarification state', () => { const callbacks = createMockCallbacks(); const modal = openModal('test', callbacks); const contentEl = (modal as any).contentEl; modal.showClarification('What style do you want?'); const loadingEl = findByClass(contentEl, 'claudian-instruction-loading'); expect(loadingEl.style.display).toBe('none'); const clarificationEl = findByClass(contentEl, 'claudian-instruction-clarification-section'); expect(clarificationEl.style.display).toBe('block'); }); it('displays the clarification text', () => { const callbacks = createMockCallbacks(); const modal = openModal('test', callbacks); const contentEl = (modal as any).contentEl; modal.showClarification('What format?'); const clarificationTextEl = findByClass(contentEl, 'claudian-instruction-clarification'); expect(clarificationTextEl.textContent).toBe('What format?'); }); it('renders Cancel and Submit buttons', () => { const callbacks = createMockCallbacks(); const modal = openModal('test', callbacks); const contentEl = (modal as any).contentEl; modal.showClarification('Question?'); const buttons = findAllByClass(contentEl, 'claudian-instruction-btn'); const buttonTexts = buttons.map((b: any) => b.textContent); expect(buttonTexts).toContain('Cancel'); expect(buttonTexts).toContain('Submit'); }); }); describe('showConfirmation', () => { it('transitions to confirmation state', () => { const callbacks = createMockCallbacks(); const modal = openModal('test', callbacks); const contentEl = (modal as any).contentEl; modal.showConfirmation('Refined instruction text'); const loadingEl = findByClass(contentEl, 'claudian-instruction-loading'); expect(loadingEl.style.display).toBe('none'); const confirmationEl = findByClass(contentEl, 'claudian-instruction-confirmation-section'); expect(confirmationEl.style.display).toBe('block'); }); it('displays the refined instruction', () => { const callbacks = createMockCallbacks(); const modal = openModal('test', callbacks); const contentEl = (modal as any).contentEl; modal.showConfirmation('The refined snippet'); const refinedEl = findByClass(contentEl, 'claudian-instruction-refined'); expect(refinedEl.textContent).toBe('The refined snippet'); }); it('renders Cancel, Edit, and Accept buttons', () => { const callbacks = createMockCallbacks(); const modal = openModal('test', callbacks); const contentEl = (modal as any).contentEl; modal.showConfirmation('instruction'); const buttons = findAllByClass(contentEl, 'claudian-instruction-btn'); const buttonTexts = buttons.map((b: any) => b.textContent); expect(buttonTexts).toContain('Cancel'); expect(buttonTexts).toContain('Edit'); expect(buttonTexts).toContain('Accept'); }); }); describe('accept callback', () => { it('calls onAccept with the refined instruction', () => { const callbacks = createMockCallbacks(); const modal = openModal('raw', callbacks); const contentEl = (modal as any).contentEl; modal.showConfirmation('refined text'); clickButton(contentEl, 'Accept'); expect(callbacks.onAccept).toHaveBeenCalledWith('refined text'); }); it('calls close when accepted', () => { const callbacks = createMockCallbacks(); const modal = openModal('raw', callbacks); const contentEl = (modal as any).contentEl; modal.showConfirmation('refined'); clickButton(contentEl, 'Accept'); expect((modal as any).close).toHaveBeenCalled(); }); it('prevents double-accept', () => { const callbacks = createMockCallbacks(); const modal = openModal('raw', callbacks); const contentEl = (modal as any).contentEl; modal.showConfirmation('refined'); clickButton(contentEl, 'Accept'); // Simulate second click - re-render buttons and try again modal.showConfirmation('refined'); clickButton(contentEl, 'Accept'); expect(callbacks.onAccept).toHaveBeenCalledTimes(1); }); }); describe('reject callback', () => { it('calls onReject when Cancel is clicked', () => { const callbacks = createMockCallbacks(); const modal = openModal('test', callbacks); const contentEl = (modal as any).contentEl; clickButton(contentEl, 'Cancel'); expect(callbacks.onReject).toHaveBeenCalled(); }); it('calls close when rejected', () => { const callbacks = createMockCallbacks(); const modal = openModal('test', callbacks); const contentEl = (modal as any).contentEl; clickButton(contentEl, 'Cancel'); expect((modal as any).close).toHaveBeenCalled(); }); it('prevents double-reject', () => { const callbacks = createMockCallbacks(); const modal = openModal('test', callbacks); const contentEl = (modal as any).contentEl; clickButton(contentEl, 'Cancel'); // Reset buttons and try again modal.showClarification('q'); clickButton(contentEl, 'Cancel'); expect(callbacks.onReject).toHaveBeenCalledTimes(1); }); }); describe('onClose', () => { it('calls onReject if not already resolved', () => { const callbacks = createMockCallbacks(); const modal = openModal('test', callbacks); InstructionModal.prototype.onClose.call(modal); expect(callbacks.onReject).toHaveBeenCalled(); }); it('does not call onReject if already resolved', () => { const callbacks = createMockCallbacks(); const modal = openModal('test', callbacks); const contentEl = (modal as any).contentEl; modal.showConfirmation('refined'); clickButton(contentEl, 'Accept'); InstructionModal.prototype.onClose.call(modal); expect(callbacks.onReject).not.toHaveBeenCalled(); }); }); describe('showError', () => { it('closes the modal and marks as resolved', () => { const callbacks = createMockCallbacks(); const modal = openModal('test', callbacks); modal.showError('Something went wrong'); expect((modal as any).close).toHaveBeenCalled(); // onClose should not call onReject since resolved=true InstructionModal.prototype.onClose.call(modal); expect(callbacks.onReject).not.toHaveBeenCalled(); }); }); describe('showClarificationLoading', () => { it('transitions back to loading state', () => { const callbacks = createMockCallbacks(); const modal = openModal('test', callbacks); const contentEl = (modal as any).contentEl; modal.showClarification('question?'); modal.showClarificationLoading(); const loadingEl = findByClass(contentEl, 'claudian-instruction-loading'); expect(loadingEl.style.display).not.toBe('none'); const clarificationEl = findByClass(contentEl, 'claudian-instruction-clarification-section'); expect(clarificationEl.style.display).toBe('none'); }); }); }); ================================================ FILE: tests/unit/utils/agent.test.ts ================================================ import { buildAgentFromFrontmatter, parseAgentFile } from '@/core/agents/AgentStorage'; import type { AgentDefinition } from '@/core/types'; import { serializeAgent, validateAgentName } from '@/utils/agent'; describe('validateAgentName', () => { it('returns null for valid name', () => { expect(validateAgentName('code-reviewer')).toBeNull(); }); it('returns null for single character', () => { expect(validateAgentName('a')).toBeNull(); }); it('returns null for numbers and hyphens', () => { expect(validateAgentName('agent-v2')).toBeNull(); }); it('returns error for empty name', () => { expect(validateAgentName('')).toBe('Agent name is required'); }); it('returns error for name exceeding max length', () => { const longName = 'a'.repeat(65); expect(validateAgentName(longName)).toBe('Agent name must be 64 characters or fewer'); }); it('returns null for exactly max length', () => { const maxName = 'a'.repeat(64); expect(validateAgentName(maxName)).toBeNull(); }); it('returns error for uppercase letters', () => { expect(validateAgentName('CodeReviewer')).toBe( 'Agent name can only contain lowercase letters, numbers, and hyphens' ); }); it('returns error for spaces', () => { expect(validateAgentName('code reviewer')).toBe( 'Agent name can only contain lowercase letters, numbers, and hyphens' ); }); it('returns error for underscores', () => { expect(validateAgentName('code_reviewer')).toBe( 'Agent name can only contain lowercase letters, numbers, and hyphens' ); }); it('returns error for special characters', () => { expect(validateAgentName('code@reviewer')).toBe( 'Agent name can only contain lowercase letters, numbers, and hyphens' ); }); it.each(['true', 'false', 'null', 'yes', 'no', 'on', 'off'])( 'returns error for YAML reserved word "%s"', (word) => { expect(validateAgentName(word)).toBe( 'Agent name cannot be a YAML reserved word (true, false, null, yes, no, on, off)' ); } ); }); describe('serializeAgent', () => { const baseAgent: AgentDefinition = { id: 'test-agent', name: 'test-agent', description: 'A test agent', prompt: 'You are a test agent.', source: 'vault', }; it('serializes minimal agent', () => { const result = serializeAgent(baseAgent); expect(result).toBe( '---\nname: test-agent\ndescription: A test agent\n---\nYou are a test agent.' ); }); it('serializes agent with tools', () => { const agent: AgentDefinition = { ...baseAgent, tools: ['Read', 'Grep'], }; const result = serializeAgent(agent); expect(result).toContain('tools:\n - Read\n - Grep'); }); it('serializes agent with disallowedTools', () => { const agent: AgentDefinition = { ...baseAgent, disallowedTools: ['Write', 'Bash'], }; const result = serializeAgent(agent); expect(result).toContain('disallowedTools:\n - Write\n - Bash'); }); it('serializes agent with model (non-inherit)', () => { const agent: AgentDefinition = { ...baseAgent, model: 'sonnet', }; const result = serializeAgent(agent); expect(result).toContain('model: sonnet'); }); it('omits model when inherit', () => { const agent: AgentDefinition = { ...baseAgent, model: 'inherit', }; const result = serializeAgent(agent); expect(result).not.toContain('model:'); }); it('serializes agent with permissionMode', () => { const agent: AgentDefinition = { ...baseAgent, permissionMode: 'dontAsk', }; const result = serializeAgent(agent); expect(result).toContain('permissionMode: dontAsk'); }); it('omits permissionMode when undefined', () => { const result = serializeAgent(baseAgent); expect(result).not.toContain('permissionMode'); }); it('serializes agent with skills', () => { const agent: AgentDefinition = { ...baseAgent, skills: ['my-skill', 'another'], }; const result = serializeAgent(agent); expect(result).toContain('skills:\n - my-skill\n - another'); }); it('quotes description with special YAML characters', () => { const agent: AgentDefinition = { ...baseAgent, description: 'Test: agent with #special chars', }; const result = serializeAgent(agent); expect(result).toContain('description: "Test: agent with #special chars"'); }); it('includes prompt as body after frontmatter', () => { const agent: AgentDefinition = { ...baseAgent, prompt: 'Multi\nline\nprompt', }; const result = serializeAgent(agent); expect(result).toMatch(/---\nMulti\nline\nprompt$/); }); it('serializes hooks as JSON', () => { const agent: AgentDefinition = { ...baseAgent, hooks: { preToolUse: { command: 'echo test' } }, }; const result = serializeAgent(agent); expect(result).toContain('hooks: {"preToolUse":{"command":"echo test"}}'); }); it('omits hooks when undefined', () => { const result = serializeAgent(baseAgent); expect(result).not.toContain('hooks'); }); it('serializes all fields together', () => { const agent: AgentDefinition = { ...baseAgent, tools: ['Read'], disallowedTools: ['Bash'], model: 'opus', permissionMode: 'acceptEdits', skills: ['review'], }; const result = serializeAgent(agent); expect(result).toContain('name: test-agent'); expect(result).toContain('description: A test agent'); expect(result).toContain('tools:\n - Read'); expect(result).toContain('disallowedTools:\n - Bash'); expect(result).toContain('model: opus'); expect(result).toContain('permissionMode: acceptEdits'); expect(result).toContain('skills:\n - review'); }); it('quotes list items containing colons', () => { const agent: AgentDefinition = { ...baseAgent, tools: ['mcp__server:tool', 'Read'], }; const result = serializeAgent(agent); expect(result).toContain(' - "mcp__server:tool"'); expect(result).toContain(' - Read'); }); it('quotes list items containing hash', () => { const agent: AgentDefinition = { ...baseAgent, skills: ['skill#1'], }; const result = serializeAgent(agent); expect(result).toContain(' - "skill#1"'); }); it('quotes list items with leading spaces', () => { const agent: AgentDefinition = { ...baseAgent, tools: [' leading-space'], }; const result = serializeAgent(agent); expect(result).toContain(' - " leading-space"'); }); it('serializes extraFrontmatter keys', () => { const agent: AgentDefinition = { ...baseAgent, extraFrontmatter: { maxTurns: 5, customFlag: true }, }; const result = serializeAgent(agent); expect(result).toContain('maxTurns: 5'); expect(result).toContain('customFlag: true'); }); it('omits extraFrontmatter when undefined', () => { const result = serializeAgent(baseAgent); expect(result).not.toContain('extraFrontmatter'); }); }); describe('serializeAgent / parseAgentFile round-trip', () => { it('round-trips a minimal agent', () => { const agent: AgentDefinition = { id: 'my-agent', name: 'my-agent', description: 'A test agent', prompt: 'You are a test agent.', source: 'vault', }; const serialized = serializeAgent(agent); const parsed = parseAgentFile(serialized); expect(parsed).not.toBeNull(); const rebuilt = buildAgentFromFrontmatter(parsed!.frontmatter, parsed!.body, { id: parsed!.frontmatter.name, source: 'vault', }); expect(rebuilt.name).toBe(agent.name); expect(rebuilt.description).toBe(agent.description); expect(rebuilt.prompt).toBe(agent.prompt); }); it('round-trips a fully populated agent', () => { const agent: AgentDefinition = { id: 'full-agent', name: 'full-agent', description: 'Full agent', prompt: 'Do everything.', tools: ['Read', 'Grep'], disallowedTools: ['Bash'], model: 'opus', permissionMode: 'acceptEdits', skills: ['review', 'deploy'], hooks: { preToolUse: { command: 'echo test' } }, source: 'vault', }; const serialized = serializeAgent(agent); const parsed = parseAgentFile(serialized); expect(parsed).not.toBeNull(); const rebuilt = buildAgentFromFrontmatter(parsed!.frontmatter, parsed!.body, { id: parsed!.frontmatter.name, source: 'vault', }); expect(rebuilt.name).toBe(agent.name); expect(rebuilt.description).toBe(agent.description); expect(rebuilt.prompt).toBe(agent.prompt); expect(rebuilt.tools).toEqual(agent.tools); expect(rebuilt.disallowedTools).toEqual(agent.disallowedTools); expect(rebuilt.model).toBe(agent.model); expect(rebuilt.permissionMode).toBe(agent.permissionMode); expect(rebuilt.skills).toEqual(agent.skills); expect(rebuilt.hooks).toEqual(agent.hooks); }); it('round-trips tools with special YAML characters', () => { const agent: AgentDefinition = { id: 'special-tools', name: 'special-tools', description: 'Agent with special tool names', prompt: 'Use special tools.', tools: ['mcp__server:tool', 'Read'], source: 'vault', }; const serialized = serializeAgent(agent); const parsed = parseAgentFile(serialized); expect(parsed).not.toBeNull(); const rebuilt = buildAgentFromFrontmatter(parsed!.frontmatter, parsed!.body, { id: parsed!.frontmatter.name, source: 'vault', }); expect(rebuilt.tools).toEqual(['mcp__server:tool', 'Read']); }); it('round-trips unknown frontmatter keys', () => { const content = `--- name: custom-agent description: An agent with extra keys maxTurns: 5 mcpServers: ["server1"] --- Do stuff.`; const parsed = parseAgentFile(content); expect(parsed).not.toBeNull(); const rebuilt = buildAgentFromFrontmatter(parsed!.frontmatter, parsed!.body, { id: parsed!.frontmatter.name, source: 'vault', }); expect(rebuilt.extraFrontmatter).toEqual({ maxTurns: 5, mcpServers: ['server1'], }); const reserialized = serializeAgent(rebuilt); expect(reserialized).toContain('maxTurns: 5'); expect(reserialized).toContain('mcpServers:'); const reparsed = parseAgentFile(reserialized); expect(reparsed).not.toBeNull(); const rebuilt2 = buildAgentFromFrontmatter(reparsed!.frontmatter, reparsed!.body, { id: reparsed!.frontmatter.name, source: 'vault', }); expect(rebuilt2.extraFrontmatter?.maxTurns).toBe(5); }); it('does not set extraFrontmatter when no unknown keys exist', () => { const agent: AgentDefinition = { id: 'no-extra', name: 'no-extra', description: 'Standard agent', prompt: 'Do stuff.', source: 'vault', }; const serialized = serializeAgent(agent); const parsed = parseAgentFile(serialized); expect(parsed).not.toBeNull(); const rebuilt = buildAgentFromFrontmatter(parsed!.frontmatter, parsed!.body, { id: parsed!.frontmatter.name, source: 'vault', }); expect(rebuilt.extraFrontmatter).toBeUndefined(); }); it('round-trips description with special YAML characters', () => { const agent: AgentDefinition = { id: 'special-desc', name: 'special-desc', description: 'Test: agent with #special chars', prompt: 'Handle edge cases.', source: 'vault', }; const serialized = serializeAgent(agent); const parsed = parseAgentFile(serialized); expect(parsed).not.toBeNull(); const rebuilt = buildAgentFromFrontmatter(parsed!.frontmatter, parsed!.body, { id: parsed!.frontmatter.name, source: 'vault', }); expect(rebuilt.description).toBe(agent.description); }); }); ================================================ FILE: tests/unit/utils/browser.test.ts ================================================ import { appendBrowserContext, type BrowserSelectionContext, formatBrowserContext, } from '../../../src/utils/browser'; describe('formatBrowserContext', () => { it('formats browser selection as XML', () => { const context: BrowserSelectionContext = { source: 'surfing-view', selectedText: 'selected web content', title: 'LeetCode', url: 'https://leetcode.com/problems/two-sum', }; expect(formatBrowserContext(context)).toBe( '<browser_selection source="surfing-view" title="LeetCode" url="https://leetcode.com/problems/two-sum">\nselected web content\n</browser_selection>' ); }); it('escapes XML attribute quotes', () => { const context: BrowserSelectionContext = { source: 'webview', selectedText: 'content', title: 'title "with quote"', }; expect(formatBrowserContext(context)).toContain('title="title "with quote""'); }); it('escapes closing tag in selected text body', () => { const context: BrowserSelectionContext = { source: 'surfing-view', selectedText: 'before</browser_selection>injected', }; const result = formatBrowserContext(context); expect(result).not.toContain('</browser_selection>injected'); expect(result).toContain('before</browser_selection>injected'); expect(result).toMatch(/<browser_selection[^>]*>\n[\s\S]*\n<\/browser_selection>$/); }); it('returns empty string for blank selection text', () => { const context: BrowserSelectionContext = { source: 'surfing-view', selectedText: ' ', }; expect(formatBrowserContext(context)).toBe(''); }); }); describe('appendBrowserContext', () => { it('appends browser selection context to prompt', () => { const context: BrowserSelectionContext = { source: 'surfing-view', selectedText: 'selected text', }; expect(appendBrowserContext('Summarize this', context)).toBe( 'Summarize this\n\n<browser_selection source="surfing-view">\nselected text\n</browser_selection>' ); }); it('returns original prompt when context is empty', () => { const context: BrowserSelectionContext = { source: 'surfing-view', selectedText: '', }; expect(appendBrowserContext('Prompt', context)).toBe('Prompt'); }); }); ================================================ FILE: tests/unit/utils/canvas.test.ts ================================================ import { appendCanvasContext, type CanvasSelectionContext,formatCanvasContext } from '../../../src/utils/canvas'; describe('canvas utilities', () => { describe('formatCanvasContext', () => { it('formats single node selection', () => { const context: CanvasSelectionContext = { canvasPath: 'my-canvas.canvas', nodeIds: ['abc123'], }; expect(formatCanvasContext(context)).toBe( '<canvas_selection path="my-canvas.canvas">\nabc123\n</canvas_selection>' ); }); it('formats multiple node selection as comma-separated list', () => { const context: CanvasSelectionContext = { canvasPath: 'folder/design.canvas', nodeIds: ['node1', 'node2', 'node3'], }; expect(formatCanvasContext(context)).toBe( '<canvas_selection path="folder/design.canvas">\nnode1, node2, node3\n</canvas_selection>' ); }); it('returns empty string for empty node list', () => { const context: CanvasSelectionContext = { canvasPath: 'test.canvas', nodeIds: [], }; expect(formatCanvasContext(context)).toBe(''); }); }); describe('appendCanvasContext', () => { it('appends canvas context after prompt with double newline', () => { const context: CanvasSelectionContext = { canvasPath: 'my-canvas.canvas', nodeIds: ['abc123'], }; const result = appendCanvasContext('hello world', context); expect(result).toBe( 'hello world\n\n<canvas_selection path="my-canvas.canvas">\nabc123\n</canvas_selection>' ); }); it('returns original prompt when no nodes selected', () => { const context: CanvasSelectionContext = { canvasPath: 'my-canvas.canvas', nodeIds: [], }; expect(appendCanvasContext('hello world', context)).toBe('hello world'); }); }); }); ================================================ FILE: tests/unit/utils/claudeCli.test.ts ================================================ import * as fs from 'fs'; import * as os from 'os'; import { ClaudeCliResolver, resolveClaudeCliPath } from '@/utils/claudeCli'; import { findClaudeCLIPath } from '@/utils/path'; jest.mock('fs'); jest.mock('os'); jest.mock('@/utils/path', () => { const actual = jest.requireActual('@/utils/path'); return { ...actual, findClaudeCLIPath: jest.fn(), }; }); const mockedExists = fs.existsSync as jest.Mock; const mockedStat = fs.statSync as jest.Mock; const mockedFind = findClaudeCLIPath as jest.Mock; const mockedHostname = os.hostname as jest.Mock; describe('ClaudeCliResolver', () => { beforeEach(() => { jest.clearAllMocks(); mockedHostname.mockReturnValue('test-host'); }); describe('hostname-based resolution', () => { it('should use hostname path when available', () => { mockedExists.mockImplementation((p: string) => p === '/hostname/claude'); mockedStat.mockReturnValue({ isFile: () => true }); const resolver = new ClaudeCliResolver(); const resolved = resolver.resolve( { 'test-host': '/hostname/claude' }, '/legacy/claude', '' ); expect(resolved).toBe('/hostname/claude'); }); it('should fall back to legacy path when hostname not found', () => { mockedExists.mockImplementation((p: string) => p === '/legacy/claude'); mockedStat.mockReturnValue({ isFile: () => true }); const resolver = new ClaudeCliResolver(); const resolved = resolver.resolve( { 'other-host': '/other/claude' }, '/legacy/claude', '' ); expect(resolved).toBe('/legacy/claude'); }); it('should fall back to legacy path when hostname paths empty', () => { mockedExists.mockImplementation((p: string) => p === '/legacy/claude'); mockedStat.mockReturnValue({ isFile: () => true }); const resolver = new ClaudeCliResolver(); const resolved = resolver.resolve( {}, '/legacy/claude', '' ); expect(resolved).toBe('/legacy/claude'); }); it('should auto-detect when no paths configured', () => { mockedExists.mockReturnValue(false); mockedFind.mockReturnValue('/auto/claude'); const resolver = new ClaudeCliResolver(); const resolved = resolver.resolve({}, '', ''); expect(resolved).toBe('/auto/claude'); expect(mockedFind).toHaveBeenCalled(); }); }); describe('caching', () => { it('should cache resolved path and return same result', () => { mockedExists.mockImplementation((p: string) => p === '/hostname/claude'); mockedStat.mockReturnValue({ isFile: () => true }); const resolver = new ClaudeCliResolver(); const first = resolver.resolve( { 'test-host': '/hostname/claude' }, '', '' ); const second = resolver.resolve( { 'test-host': '/hostname/claude' }, '', '' ); expect(first).toBe('/hostname/claude'); expect(second).toBe('/hostname/claude'); // existsSync should be called only once due to caching expect(mockedExists).toHaveBeenCalledTimes(1); }); it('should invalidate cache when hostname path changes', () => { mockedExists.mockReturnValue(true); mockedStat.mockReturnValue({ isFile: () => true }); const resolver = new ClaudeCliResolver(); const first = resolver.resolve( { 'test-host': '/hostname/claude1' }, '', '' ); const second = resolver.resolve( { 'test-host': '/hostname/claude2' }, '', '' ); expect(first).toBe('/hostname/claude1'); expect(second).toBe('/hostname/claude2'); }); it('should clear cache on reset()', () => { mockedExists.mockReturnValue(true); mockedStat.mockReturnValue({ isFile: () => true }); const resolver = new ClaudeCliResolver(); resolver.resolve( { 'test-host': '/hostname/claude' }, '', '' ); resolver.reset(); resolver.resolve( { 'test-host': '/hostname/claude' }, '', '' ); // Should be called twice because cache was cleared expect(mockedExists).toHaveBeenCalledTimes(2); }); }); describe('legacy compatibility', () => { it('should use legacy path as fallback when hostname paths are empty', () => { mockedExists.mockImplementation((p: string) => p === '/legacy/claude'); mockedStat.mockReturnValue({ isFile: () => true }); mockedFind.mockReturnValue('/auto/claude'); const resolver = new ClaudeCliResolver(); const resolved = resolver.resolve({}, '/legacy/claude', ''); expect(resolved).toBe('/legacy/claude'); expect(mockedFind).not.toHaveBeenCalled(); }); it('should use legacy path when hostname paths are undefined', () => { mockedExists.mockImplementation((p: string) => p === '/legacy/claude'); mockedStat.mockReturnValue({ isFile: () => true }); mockedFind.mockReturnValue('/auto/claude'); const resolver = new ClaudeCliResolver(); const resolved = resolver.resolve(undefined, '/legacy/claude', ''); expect(resolved).toBe('/legacy/claude'); expect(mockedFind).not.toHaveBeenCalled(); }); }); }); describe('resolveClaudeCliPath', () => { beforeEach(() => { jest.clearAllMocks(); }); it('should return hostname path when valid file exists', () => { mockedExists.mockImplementation((p: string) => p === '/hostname/claude'); mockedStat.mockReturnValue({ isFile: () => true }); const result = resolveClaudeCliPath('/hostname/claude', '/legacy/claude', ''); expect(result).toBe('/hostname/claude'); }); it('should skip hostname path if it is a directory', () => { mockedExists.mockReturnValue(true); mockedStat.mockImplementation((p: string) => ({ isFile: () => p !== '/hostname/claude', })); const result = resolveClaudeCliPath('/hostname/claude', '/legacy/claude', ''); expect(result).toBe('/legacy/claude'); }); it('should handle empty hostname path gracefully', () => { mockedExists.mockImplementation((p: string) => p === '/legacy/claude'); mockedStat.mockReturnValue({ isFile: () => true }); const result = resolveClaudeCliPath('', '/legacy/claude', ''); expect(result).toBe('/legacy/claude'); }); it('should trim whitespace from paths', () => { mockedExists.mockImplementation((p: string) => p === '/hostname/claude'); mockedStat.mockReturnValue({ isFile: () => true }); const result = resolveClaudeCliPath(' /hostname/claude ', '', ''); expect(result).toBe('/hostname/claude'); }); it('should handle null/undefined hostname path', () => { mockedExists.mockImplementation((p: string) => p === '/legacy/claude'); mockedStat.mockReturnValue({ isFile: () => true }); const result = resolveClaudeCliPath(undefined, '/legacy/claude', ''); expect(result).toBe('/legacy/claude'); }); it('should handle null/undefined legacy path', () => { mockedExists.mockReturnValue(false); mockedFind.mockReturnValue('/auto/claude'); const result = resolveClaudeCliPath('', undefined, ''); expect(result).toBe('/auto/claude'); }); it('should fall through hostname path when existsSync returns false', () => { mockedExists.mockImplementation((p: string) => p === '/legacy/claude'); mockedStat.mockReturnValue({ isFile: () => true }); const result = resolveClaudeCliPath('/nonexistent/claude', '/legacy/claude', ''); expect(result).toBe('/legacy/claude'); }); it('should fall through hostname path when existsSync throws', () => { mockedExists.mockImplementation((p: string) => { if (p.includes('nonexistent')) throw new Error('Access denied'); return p === '/legacy/claude'; }); mockedStat.mockReturnValue({ isFile: () => true }); const result = resolveClaudeCliPath('/nonexistent/claude', '/legacy/claude', ''); expect(result).toBe('/legacy/claude'); }); it('should fall through legacy path when existsSync throws', () => { mockedExists.mockImplementation(() => { throw new Error('Access denied'); }); mockedFind.mockReturnValue('/auto/claude'); const result = resolveClaudeCliPath('', '/bad/path', ''); expect(result).toBe('/auto/claude'); }); it('should skip legacy path if it is a directory', () => { mockedExists.mockReturnValue(true); mockedStat.mockReturnValue({ isFile: () => false }); mockedFind.mockReturnValue('/auto/claude'); const result = resolveClaudeCliPath('', '/legacy/dir', ''); expect(result).toBe('/auto/claude'); }); it('should pass env PATH to findClaudeCLIPath', () => { mockedExists.mockReturnValue(false); mockedFind.mockReturnValue(null); resolveClaudeCliPath('', '', 'PATH=/custom/bin'); expect(mockedFind).toHaveBeenCalledWith('/custom/bin'); }); }); ================================================ FILE: tests/unit/utils/context.test.ts ================================================ import { appendContextFiles, appendCurrentNote, extractContentBeforeXmlContext, extractUserQuery, formatCurrentNote, stripCurrentNoteContext, XML_CONTEXT_PATTERN, } from '../../../src/utils/context'; describe('formatCurrentNote', () => { it('formats note path in XML tags', () => { expect(formatCurrentNote('notes/test.md')).toBe( '<current_note>\nnotes/test.md\n</current_note>' ); }); it('handles paths with special characters', () => { expect(formatCurrentNote('notes/my file (1).md')).toBe( '<current_note>\nnotes/my file (1).md\n</current_note>' ); }); }); describe('appendCurrentNote', () => { it('appends current note to prompt with double newline separator', () => { const result = appendCurrentNote('Hello', 'notes/test.md'); expect(result).toBe( 'Hello\n\n<current_note>\nnotes/test.md\n</current_note>' ); }); it('preserves original prompt content', () => { const result = appendCurrentNote('Multi\nline\nprompt', 'test.md'); expect(result.startsWith('Multi\nline\nprompt\n\n')).toBe(true); }); }); describe('stripCurrentNoteContext', () => { describe('legacy prefix format', () => { it('strips current_note from start of prompt', () => { const prompt = '<current_note>\nnotes/test.md\n</current_note>\n\nUser query here'; expect(stripCurrentNoteContext(prompt)).toBe('User query here'); }); it('handles multiline note content in prefix', () => { const prompt = '<current_note>\npath/to/note.md\nwith extra info\n</current_note>\n\nQuery'; expect(stripCurrentNoteContext(prompt)).toBe('Query'); }); }); describe('current suffix format', () => { it('strips current_note from end of prompt', () => { const prompt = 'User query here\n\n<current_note>\nnotes/test.md\n</current_note>'; expect(stripCurrentNoteContext(prompt)).toBe('User query here'); }); it('handles multiline note content in suffix', () => { const prompt = 'Query\n\n<current_note>\npath/to/note.md\n</current_note>'; expect(stripCurrentNoteContext(prompt)).toBe('Query'); }); }); it('returns unchanged prompt when no current_note present', () => { const prompt = 'Just a regular prompt'; expect(stripCurrentNoteContext(prompt)).toBe('Just a regular prompt'); }); it('prefers prefix format when both could match', () => { // This tests the function order: it tries prefix first const prefixPrompt = '<current_note>\ntest.md\n</current_note>\n\nQuery'; expect(stripCurrentNoteContext(prefixPrompt)).toBe('Query'); }); }); describe('XML_CONTEXT_PATTERN', () => { it('matches current_note tag', () => { const text = 'Query\n\n<current_note>\ntest.md\n</current_note>'; expect(XML_CONTEXT_PATTERN.test(text)).toBe(true); }); it('matches editor_selection tag with attributes', () => { const text = 'Query\n\n<editor_selection path="test.md">\nselected text\n</editor_selection>'; expect(XML_CONTEXT_PATTERN.test(text)).toBe(true); }); it('matches editor_cursor tag', () => { const text = 'Query\n\n<editor_cursor path="test.md">\n</editor_cursor>'; expect(XML_CONTEXT_PATTERN.test(text)).toBe(true); }); it('matches context_files tag', () => { const text = 'Query\n\n<context_files>\nfile1.md, file2.md\n</context_files>'; expect(XML_CONTEXT_PATTERN.test(text)).toBe(true); }); it('matches canvas_selection tag', () => { const text = 'Query\n\n<canvas_selection path="my.canvas">\nnode1, node2\n</canvas_selection>'; expect(XML_CONTEXT_PATTERN.test(text)).toBe(true); }); it('matches browser_selection tag', () => { const text = 'Query\n\n<browser_selection source="surfing-view">\nselected web content\n</browser_selection>'; expect(XML_CONTEXT_PATTERN.test(text)).toBe(true); }); it('does not match without double newline separator', () => { const text = 'Query\n<current_note>\ntest.md\n</current_note>'; expect(XML_CONTEXT_PATTERN.test(text)).toBe(false); }); it('does not match other XML tags', () => { const text = 'Query\n\n<other_tag>\ncontent\n</other_tag>'; expect(XML_CONTEXT_PATTERN.test(text)).toBe(false); }); }); describe('extractContentBeforeXmlContext', () => { describe('legacy format with <query> tags', () => { it('extracts content from query tags', () => { const prompt = '<current_note>\ntest.md\n</current_note>\n\n<query>\nUser question\n</query>'; expect(extractContentBeforeXmlContext(prompt)).toBe('User question'); }); it('trims whitespace from extracted content', () => { const prompt = '<query>\n spaced content \n</query>'; expect(extractContentBeforeXmlContext(prompt)).toBe('spaced content'); }); it('handles multiline content in query tags', () => { const prompt = '<query>\nLine 1\nLine 2\nLine 3\n</query>'; expect(extractContentBeforeXmlContext(prompt)).toBe('Line 1\nLine 2\nLine 3'); }); }); describe('current format with user content first', () => { it('extracts content before current_note tag', () => { const prompt = 'User query\n\n<current_note>\ntest.md\n</current_note>'; expect(extractContentBeforeXmlContext(prompt)).toBe('User query'); }); it('extracts content before editor_selection tag', () => { const prompt = 'Edit this\n\n<editor_selection path="test.md">\nselected\n</editor_selection>'; expect(extractContentBeforeXmlContext(prompt)).toBe('Edit this'); }); it('extracts content before editor_cursor tag', () => { const prompt = 'Insert here\n\n<editor_cursor path="test.md">\n</editor_cursor>'; expect(extractContentBeforeXmlContext(prompt)).toBe('Insert here'); }); it('extracts content before context_files tag', () => { const prompt = 'Use these files\n\n<context_files>\nfile1.md\n</context_files>'; expect(extractContentBeforeXmlContext(prompt)).toBe('Use these files'); }); it('handles multiple context tags - extracts before first one', () => { const prompt = 'Query\n\n<current_note>\ntest.md\n</current_note>\n\n<editor_selection path="x">\ny\n</editor_selection>'; expect(extractContentBeforeXmlContext(prompt)).toBe('Query'); }); it('extracts content before browser_selection tag', () => { const prompt = 'Summarize this\n\n<browser_selection source="surfing-view">\nselected web content\n</browser_selection>'; expect(extractContentBeforeXmlContext(prompt)).toBe('Summarize this'); }); it('trims whitespace from extracted content', () => { const prompt = ' spaced query \n\n<current_note>\ntest.md\n</current_note>'; expect(extractContentBeforeXmlContext(prompt)).toBe('spaced query'); }); }); describe('edge cases', () => { it('returns undefined for empty string', () => { expect(extractContentBeforeXmlContext('')).toBeUndefined(); }); it('returns undefined for plain text without XML context', () => { expect(extractContentBeforeXmlContext('Just a plain prompt')).toBeUndefined(); }); it('returns undefined for null-ish input', () => { expect(extractContentBeforeXmlContext(null as unknown as string)).toBeUndefined(); expect(extractContentBeforeXmlContext(undefined as unknown as string)).toBeUndefined(); }); }); }); describe('extractUserQuery', () => { describe('with XML context (delegates to extractContentBeforeXmlContext)', () => { it('extracts content from legacy query tags', () => { const prompt = '<current_note>\ntest.md\n</current_note>\n\n<query>\nUser question\n</query>'; expect(extractUserQuery(prompt)).toBe('User question'); }); it('extracts content before XML context tags', () => { const prompt = 'User query\n\n<current_note>\ntest.md\n</current_note>'; expect(extractUserQuery(prompt)).toBe('User query'); }); }); describe('fallback tag stripping', () => { it('strips current_note tags without structured format', () => { // Tag and trailing whitespace are replaced, leaving single space const prompt = 'Query <current_note>test.md</current_note> continues'; expect(extractUserQuery(prompt)).toBe('Query continues'); }); it('strips editor_selection tags', () => { const prompt = 'Query <editor_selection path="x">text</editor_selection> end'; expect(extractUserQuery(prompt)).toBe('Query end'); }); it('strips editor_cursor tags', () => { const prompt = 'Query <editor_cursor path="x"></editor_cursor> end'; expect(extractUserQuery(prompt)).toBe('Query end'); }); it('strips context_files tags', () => { const prompt = 'Query <context_files>file.md</context_files> end'; expect(extractUserQuery(prompt)).toBe('Query end'); }); it('strips canvas_selection tags', () => { const prompt = 'Query <canvas_selection path="x.canvas">node1</canvas_selection> end'; expect(extractUserQuery(prompt)).toBe('Query end'); }); it('strips browser_selection tags', () => { const prompt = 'Query <browser_selection source="surfing-view">selection</browser_selection> end'; expect(extractUserQuery(prompt)).toBe('Query end'); }); it('strips multiple tag types', () => { const prompt = '<current_note>a.md</current_note>Query<context_files>b.md</context_files>'; expect(extractUserQuery(prompt)).toBe('Query'); }); }); describe('edge cases', () => { it('returns empty string for empty input', () => { expect(extractUserQuery('')).toBe(''); }); it('returns empty string for null-ish input', () => { expect(extractUserQuery(null as unknown as string)).toBe(''); expect(extractUserQuery(undefined as unknown as string)).toBe(''); }); it('returns trimmed plain text when no tags present', () => { expect(extractUserQuery(' plain query ')).toBe('plain query'); }); }); }); describe('appendContextFiles', () => { it('appends context files in XML format', () => { const result = appendContextFiles('Query', ['file1.md', 'file2.md']); expect(result).toBe('Query\n\n<context_files>\nfile1.md, file2.md\n</context_files>'); }); it('handles single file', () => { const result = appendContextFiles('Query', ['single.md']); expect(result).toBe('Query\n\n<context_files>\nsingle.md\n</context_files>'); }); it('handles empty file array', () => { const result = appendContextFiles('Query', []); expect(result).toBe('Query\n\n<context_files>\n\n</context_files>'); }); }); ================================================ FILE: tests/unit/utils/contextMentionResolver.test.ts ================================================ import { buildExternalContextLookup, createExternalContextLookupGetter, findBestMentionLookupMatch, isMentionStart, normalizeForPlatformLookup, normalizeMentionPath, resolveExternalMentionAtIndex, } from '@/utils/contextMentionResolver'; import type { ExternalContextDisplayEntry } from '@/utils/externalContext'; import type { ExternalContextFile } from '@/utils/externalContextScanner'; describe('contextMentionResolver', () => { describe('isMentionStart', () => { it('returns true when @ is at the beginning of text', () => { expect(isMentionStart('@note.md', 0)).toBe(true); }); it('returns true when @ is preceded by whitespace', () => { expect(isMentionStart('check @note.md', 6)).toBe(true); expect(isMentionStart('check\n@note.md', 6)).toBe(true); }); it('returns false when @ is not preceded by whitespace', () => { expect(isMentionStart('email@test.com', 5)).toBe(false); }); it('returns false when the index is not @', () => { expect(isMentionStart('hello', 0)).toBe(false); }); }); describe('normalizeMentionPath', () => { it('normalizes separators and trims leading/trailing slashes', () => { expect(normalizeMentionPath('./src\\folder//file.md/')).toBe('src/folder/file.md'); }); it('returns empty string for root-like input', () => { expect(normalizeMentionPath('./')).toBe(''); }); }); describe('normalizeForPlatformLookup', () => { const originalPlatform = process.platform; afterEach(() => { Object.defineProperty(process, 'platform', { value: originalPlatform }); }); it('lowercases lookup keys on Windows', () => { Object.defineProperty(process, 'platform', { value: 'win32' }); expect(normalizeForPlatformLookup('SRC/FILE.MD')).toBe('src/file.md'); }); it('keeps lookup keys unchanged on non-Windows platforms', () => { Object.defineProperty(process, 'platform', { value: 'darwin' }); expect(normalizeForPlatformLookup('SRC/FILE.MD')).toBe('SRC/FILE.MD'); }); }); describe('buildExternalContextLookup', () => { it('normalizes keys and keeps the first file for duplicate paths', () => { const files: ExternalContextFile[] = [ { path: '/external/src/file.md', name: 'file.md', relativePath: 'src/file.md', contextRoot: '/external', mtime: 1, }, { path: '/external/src/file-duplicate.md', name: 'file-duplicate.md', relativePath: './src\\file.md', contextRoot: '/external', mtime: 2, }, { path: '/external/ignored', name: 'ignored', relativePath: './', contextRoot: '/external', mtime: 3, }, ]; const lookup = buildExternalContextLookup(files); expect(lookup.size).toBe(1); expect(lookup.get(normalizeForPlatformLookup('src/file.md'))).toBe('/external/src/file.md'); }); }); describe('createExternalContextLookupGetter', () => { it('calls getContextFiles on first access for a context root', () => { const filesByRoot: Record<string, ExternalContextFile[]> = { '/external-a': [ { path: '/external-a/src/a.md', name: 'a.md', relativePath: 'src/a.md', contextRoot: '/external-a', mtime: 1, }, ], }; const getContextFiles = jest.fn((contextRoot: string) => filesByRoot[contextRoot] ?? []); const getLookup = createExternalContextLookupGetter(getContextFiles); const lookup = getLookup('/external-a'); expect(getContextFiles).toHaveBeenCalledTimes(1); expect(getContextFiles).toHaveBeenCalledWith('/external-a'); expect(lookup.get(normalizeForPlatformLookup('src/a.md'))).toBe('/external-a/src/a.md'); }); it('reuses cached lookup and skips rescanning for the same root', () => { const filesByRoot: Record<string, ExternalContextFile[]> = { '/external-a': [ { path: '/external-a/src/a.md', name: 'a.md', relativePath: 'src/a.md', contextRoot: '/external-a', mtime: 1, }, ], }; const getContextFiles = jest.fn((contextRoot: string) => filesByRoot[contextRoot] ?? []); const getLookup = createExternalContextLookupGetter(getContextFiles); const firstLookup = getLookup('/external-a'); const secondLookup = getLookup('/external-a'); expect(getContextFiles).toHaveBeenCalledTimes(1); expect(secondLookup).toBe(firstLookup); }); it('creates independent cached lookups for distinct roots', () => { const filesByRoot: Record<string, ExternalContextFile[]> = { '/external-a': [ { path: '/external-a/src/a.md', name: 'a.md', relativePath: 'src/a.md', contextRoot: '/external-a', mtime: 1, }, ], '/external-b': [ { path: '/external-b/src/b.md', name: 'b.md', relativePath: 'src/b.md', contextRoot: '/external-b', mtime: 2, }, ], }; const getContextFiles = jest.fn((contextRoot: string) => filesByRoot[contextRoot] ?? []); const getLookup = createExternalContextLookupGetter(getContextFiles); const firstLookup = getLookup('/external-a'); const secondLookup = getLookup('/external-b'); expect(getContextFiles).toHaveBeenCalledTimes(2); expect(getContextFiles).toHaveBeenNthCalledWith(1, '/external-a'); expect(getContextFiles).toHaveBeenNthCalledWith(2, '/external-b'); expect(firstLookup).not.toBe(secondLookup); expect(firstLookup.get(normalizeForPlatformLookup('src/a.md'))).toBe('/external-a/src/a.md'); expect(secondLookup.get(normalizeForPlatformLookup('src/b.md'))).toBe('/external-b/src/b.md'); }); }); describe('findBestMentionLookupMatch', () => { it('matches the longest path and preserves trailing punctuation', () => { const text = 'Check @src/my file.md, then continue'; const pathStart = text.indexOf('@') + 1; const lookup = new Map<string, string>([ ['src/my', '/vault/src/my'], ['src/my file.md', '/vault/src/my file.md'], ]); const match = findBestMentionLookupMatch( text, pathStart, lookup, normalizeMentionPath, normalizeForPlatformLookup ); expect(match).toEqual({ resolvedPath: '/vault/src/my file.md', endIndex: text.indexOf(',') + 1, trailingPunctuation: ',', }); }); it('returns null when no lookup key matches', () => { const text = 'Check @missing/path'; const pathStart = text.indexOf('@') + 1; const lookup = new Map<string, string>([['src/file.md', '/vault/src/file.md']]); const match = findBestMentionLookupMatch( text, pathStart, lookup, normalizeMentionPath, normalizeForPlatformLookup ); expect(match).toBeNull(); }); }); describe('resolveExternalMentionAtIndex', () => { it('resolves external mention with trailing punctuation', () => { const text = 'Use @external/src/app.md.'; const mentionStart = text.indexOf('@'); const contextEntries: ExternalContextDisplayEntry[] = [ { contextRoot: '/external', displayName: 'external', displayNameLower: 'external', }, ]; const getContextLookup = jest.fn().mockReturnValue( new Map<string, string>([['src/app.md', '/external/src/app.md']]) ); const match = resolveExternalMentionAtIndex( text, mentionStart, contextEntries, getContextLookup ); expect(match).toEqual({ resolvedPath: '/external/src/app.md', trailingPunctuation: '.', endIndex: text.length, }); expect(getContextLookup).toHaveBeenCalledWith('/external'); }); it('returns null when mention does not include a path separator after display name', () => { const text = 'Use @external and continue'; const mentionStart = text.indexOf('@'); const contextEntries: ExternalContextDisplayEntry[] = [ { contextRoot: '/external', displayName: 'external', displayNameLower: 'external', }, ]; const getContextLookup = jest.fn().mockReturnValue(new Map<string, string>()); const match = resolveExternalMentionAtIndex( text, mentionStart, contextEntries, getContextLookup ); expect(match).toBeNull(); expect(getContextLookup).not.toHaveBeenCalled(); }); }); }); ================================================ FILE: tests/unit/utils/date.test.ts ================================================ import { formatDurationMmSs, getTodayDate } from '../../../src/utils/date'; describe('getTodayDate', () => { it('returns readable date with ISO suffix', () => { const result = getTodayDate(); // Should end with ISO date in parentheses, e.g. "(2024-01-15)" expect(result).toMatch(/\(\d{4}-\d{2}-\d{2}\)$/); }); it('includes day of week and full date', () => { const result = getTodayDate(); const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; const months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']; const now = new Date(); expect(result).toContain(days[now.getDay()]); expect(result).toContain(months[now.getMonth()]); expect(result).toContain(String(now.getFullYear())); }); it('ISO portion matches current date', () => { const result = getTodayDate(); const isoMatch = result.match(/\((\d{4}-\d{2}-\d{2})\)/); expect(isoMatch).not.toBeNull(); const today = new Date().toISOString().split('T')[0]; expect(isoMatch![1]).toBe(today); }); }); describe('formatDurationMmSs', () => { it('formats 0 seconds as 0s', () => { expect(formatDurationMmSs(0)).toBe('0s'); }); it('formats 59 seconds as 59s', () => { expect(formatDurationMmSs(59)).toBe('59s'); }); it('formats 60 seconds as 1m 0s', () => { expect(formatDurationMmSs(60)).toBe('1m 0s'); }); it('formats 61 seconds as 1m 1s', () => { expect(formatDurationMmSs(61)).toBe('1m 1s'); }); it('formats single digit seconds without leading zero', () => { expect(formatDurationMmSs(5)).toBe('5s'); }); it('formats minutes and seconds correctly', () => { expect(formatDurationMmSs(125)).toBe('2m 5s'); }); it('formats large durations correctly', () => { // 10 minutes 30 seconds = 630 seconds expect(formatDurationMmSs(630)).toBe('10m 30s'); }); it('formats hour+ durations correctly', () => { // 1 hour 5 minutes = 65 minutes = 3900 seconds expect(formatDurationMmSs(3900)).toBe('65m 0s'); }); describe('input validation', () => { it('returns 0s for negative values', () => { expect(formatDurationMmSs(-1)).toBe('0s'); expect(formatDurationMmSs(-60)).toBe('0s'); expect(formatDurationMmSs(-100)).toBe('0s'); }); it('returns 0s for NaN', () => { expect(formatDurationMmSs(NaN)).toBe('0s'); }); it('returns 0s for Infinity', () => { expect(formatDurationMmSs(Infinity)).toBe('0s'); expect(formatDurationMmSs(-Infinity)).toBe('0s'); }); }); }); ================================================ FILE: tests/unit/utils/diff.test.ts ================================================ import type { ToolCallInfo } from '../../../src/core/types/tools'; import { diffFromToolInput,extractDiffData } from '../../../src/utils/diff'; /** Helper to create a ToolCallInfo for testing. */ function makeToolCall(name: string, input: Record<string, unknown>): ToolCallInfo { return { id: 'test-id', name, input, status: 'completed', isExpanded: false }; } describe('extractDiffData', () => { it('returns ToolDiffData from valid toolUseResult with structuredPatch', () => { const toolCall = makeToolCall('Edit', { file_path: 'src/foo.ts' }); const toolUseResult = { structuredPatch: [ { oldStart: 1, oldLines: 1, newStart: 1, newLines: 1, lines: ['-old', '+new'] }, ], }; const result = extractDiffData(toolUseResult, toolCall); expect(result).toBeDefined(); expect(result!.filePath).toBe('src/foo.ts'); expect(result!.diffLines).toHaveLength(2); expect(result!.diffLines[0]).toEqual({ type: 'delete', text: 'old', oldLineNum: 1 }); expect(result!.diffLines[1]).toEqual({ type: 'insert', text: 'new', newLineNum: 1 }); expect(result!.stats).toEqual({ added: 1, removed: 1 }); }); it('uses SDK filePath when present in toolUseResult', () => { const toolCall = makeToolCall('Write', { file_path: 'input/path.ts' }); const toolUseResult = { filePath: 'sdk/override.ts', structuredPatch: [ { oldStart: 1, oldLines: 0, newStart: 1, newLines: 1, lines: ['+hello'] }, ], }; const result = extractDiffData(toolUseResult, toolCall); expect(result).toBeDefined(); expect(result!.filePath).toBe('sdk/override.ts'); }); it('falls back to diffFromToolInput when toolUseResult is undefined', () => { const toolCall = makeToolCall('Edit', { file_path: 'src/bar.ts', old_string: 'a', new_string: 'b', }); const result = extractDiffData(undefined, toolCall); expect(result).toBeDefined(); expect(result!.filePath).toBe('src/bar.ts'); expect(result!.diffLines).toHaveLength(2); }); it('falls back to diffFromToolInput when toolUseResult is empty object', () => { const toolCall = makeToolCall('Write', { file_path: 'src/new.ts', content: 'line1\nline2', }); const result = extractDiffData({}, toolCall); // {} has no structuredPatch → falls back to diffFromToolInput for Write expect(result).toBeDefined(); expect(result!.diffLines).toHaveLength(2); expect(result!.stats).toEqual({ added: 2, removed: 0 }); }); it('falls back to diffFromToolInput when structuredPatch is empty array', () => { const toolCall = makeToolCall('Edit', { file_path: 'src/x.ts', old_string: 'old', new_string: 'new', }); const result = extractDiffData({ structuredPatch: [] }, toolCall); expect(result).toBeDefined(); expect(result!.filePath).toBe('src/x.ts'); expect(result!.diffLines).toHaveLength(2); }); it('falls back to diffFromToolInput when toolUseResult is a string', () => { const toolCall = makeToolCall('Edit', { file_path: 'src/y.ts', old_string: 'foo', new_string: 'bar', }); const result = extractDiffData('some string result', toolCall); expect(result).toBeDefined(); expect(result!.filePath).toBe('src/y.ts'); }); it('returns undefined for unknown tool with no structuredPatch', () => { const toolCall = makeToolCall('Bash', { command: 'echo hi' }); const result = extractDiffData(undefined, toolCall); expect(result).toBeUndefined(); }); }); describe('diffFromToolInput', () => { it('returns delete + insert lines for Edit with valid old_string/new_string', () => { const toolCall = makeToolCall('Edit', { file_path: 'src/a.ts', old_string: 'line1\nline2', new_string: 'newline1\nnewline2\nnewline3', }); const result = diffFromToolInput(toolCall, 'src/a.ts'); expect(result).toBeDefined(); expect(result!.filePath).toBe('src/a.ts'); // 2 delete lines + 3 insert lines expect(result!.diffLines).toHaveLength(5); expect(result!.diffLines.filter(l => l.type === 'delete')).toHaveLength(2); expect(result!.diffLines.filter(l => l.type === 'insert')).toHaveLength(3); expect(result!.stats).toEqual({ added: 3, removed: 2 }); }); it('returns all insert lines for Write with valid content', () => { const toolCall = makeToolCall('Write', { file_path: 'src/b.ts', content: 'a\nb\nc', }); const result = diffFromToolInput(toolCall, 'src/b.ts'); expect(result).toBeDefined(); expect(result!.diffLines).toHaveLength(3); expect(result!.diffLines.every(l => l.type === 'insert')).toBe(true); expect(result!.stats).toEqual({ added: 3, removed: 0 }); }); it('returns undefined for Edit with non-string inputs', () => { const toolCall = makeToolCall('Edit', { file_path: 'src/c.ts', old_string: 123, new_string: null, }); const result = diffFromToolInput(toolCall, 'src/c.ts'); expect(result).toBeUndefined(); }); it('returns undefined for Write with non-string content', () => { const toolCall = makeToolCall('Write', { file_path: 'src/d.ts', content: { data: 'not a string' }, }); const result = diffFromToolInput(toolCall, 'src/d.ts'); expect(result).toBeUndefined(); }); it('returns undefined for unknown tool name', () => { const toolCall = makeToolCall('Bash', { command: 'ls' }); const result = diffFromToolInput(toolCall, 'some/path'); expect(result).toBeUndefined(); }); }); ================================================ FILE: tests/unit/utils/editor.test.ts ================================================ import { appendEditorContext, buildCursorContext, type EditorSelectionContext, findNearestNonEmptyLine, formatEditorContext, } from '@/utils/editor'; function makeGetLine(lines: string[]): (line: number) => string { return (line: number) => lines[line] ?? ''; } describe('findNearestNonEmptyLine', () => { const lines = ['first', '', 'third', '', 'fifth']; const getLine = makeGetLine(lines); it('finds nearest non-empty line before', () => { expect(findNearestNonEmptyLine(getLine, lines.length, 1, 'before')).toBe('first'); }); it('finds nearest non-empty line after', () => { expect(findNearestNonEmptyLine(getLine, lines.length, 1, 'after')).toBe('third'); }); it('skips multiple empty lines before', () => { expect(findNearestNonEmptyLine(getLine, lines.length, 3, 'before')).toBe('third'); }); it('skips multiple empty lines after', () => { expect(findNearestNonEmptyLine(getLine, lines.length, 3, 'after')).toBe('fifth'); }); it('returns empty string when no non-empty line exists before', () => { const emptyLines = ['', '', 'content']; expect(findNearestNonEmptyLine(makeGetLine(emptyLines), emptyLines.length, 0, 'before')).toBe(''); }); it('returns empty string when no non-empty line exists after', () => { const emptyLines = ['content', '', '']; expect(findNearestNonEmptyLine(makeGetLine(emptyLines), emptyLines.length, 2, 'after')).toBe(''); }); it('skips whitespace-only lines', () => { const lines = ['content', ' ', ' \t ', 'found']; expect(findNearestNonEmptyLine(makeGetLine(lines), lines.length, 0, 'after')).toBe('found'); }); }); describe('buildCursorContext', () => { it('splits line at cursor position', () => { const lines = ['hello world']; const result = buildCursorContext(makeGetLine(lines), lines.length, 0, 5); expect(result.beforeCursor).toBe('hello'); expect(result.afterCursor).toBe(' world'); expect(result.isInbetween).toBe(false); expect(result.line).toBe(0); expect(result.column).toBe(5); }); it('cursor at start of line', () => { const lines = ['', 'next line']; const result = buildCursorContext(makeGetLine(lines), lines.length, 0, 0); expect(result.isInbetween).toBe(true); expect(result.beforeCursor).toBe(''); expect(result.afterCursor).toBe('next line'); }); it('cursor on empty line between content', () => { const lines = ['above', '', 'below']; const result = buildCursorContext(makeGetLine(lines), lines.length, 1, 0); expect(result.isInbetween).toBe(true); expect(result.beforeCursor).toBe('above'); expect(result.afterCursor).toBe('below'); }); it('cursor on whitespace-only line', () => { const lines = ['above', ' ', 'below']; const result = buildCursorContext(makeGetLine(lines), lines.length, 1, 1); expect(result.isInbetween).toBe(true); expect(result.beforeCursor).toBe('above'); expect(result.afterCursor).toBe('below'); }); it('cursor at end of non-empty line is not inbetween', () => { const lines = ['hello']; const result = buildCursorContext(makeGetLine(lines), lines.length, 0, 5); expect(result.isInbetween).toBe(false); expect(result.beforeCursor).toBe('hello'); expect(result.afterCursor).toBe(''); }); it('cursor in middle of word', () => { const lines = ['function test() {}']; const result = buildCursorContext(makeGetLine(lines), lines.length, 0, 8); expect(result.beforeCursor).toBe('function'); expect(result.afterCursor).toBe(' test() {}'); expect(result.isInbetween).toBe(false); }); }); describe('formatEditorContext', () => { it('formats selection context', () => { const context: EditorSelectionContext = { notePath: 'test.md', mode: 'selection', selectedText: 'selected content', startLine: 5, lineCount: 3, }; const result = formatEditorContext(context); expect(result).toBe('<editor_selection path="test.md" lines="5-7">\nselected content\n</editor_selection>'); }); it('formats selection without line info', () => { const context: EditorSelectionContext = { notePath: 'test.md', mode: 'selection', selectedText: 'selected', }; const result = formatEditorContext(context); expect(result).toBe('<editor_selection path="test.md">\nselected\n</editor_selection>'); }); it('formats inline cursor context', () => { const context: EditorSelectionContext = { notePath: 'test.md', mode: 'cursor', cursorContext: { beforeCursor: 'hello', afterCursor: ' world', isInbetween: false, line: 0, column: 5, }, }; const result = formatEditorContext(context); expect(result).toBe('<editor_cursor path="test.md">\nhello| world #inline\n</editor_cursor>'); }); it('formats inbetween cursor context', () => { const context: EditorSelectionContext = { notePath: 'test.md', mode: 'cursor', cursorContext: { beforeCursor: 'above', afterCursor: 'below', isInbetween: true, line: 1, column: 0, }, }; const result = formatEditorContext(context); expect(result).toBe('<editor_cursor path="test.md">\nabove\n| #inbetween\nbelow\n</editor_cursor>'); }); it('formats inbetween cursor with no before content', () => { const context: EditorSelectionContext = { notePath: 'test.md', mode: 'cursor', cursorContext: { beforeCursor: '', afterCursor: 'below', isInbetween: true, line: 0, column: 0, }, }; const result = formatEditorContext(context); expect(result).toBe('<editor_cursor path="test.md">\n| #inbetween\nbelow\n</editor_cursor>'); }); it('formats inbetween cursor with no after content', () => { const context: EditorSelectionContext = { notePath: 'test.md', mode: 'cursor', cursorContext: { beforeCursor: 'above', afterCursor: '', isInbetween: true, line: 5, column: 0, }, }; const result = formatEditorContext(context); expect(result).toBe('<editor_cursor path="test.md">\nabove\n| #inbetween\n</editor_cursor>'); }); it('formats inbetween cursor with no before and no after content', () => { const context: EditorSelectionContext = { notePath: 'test.md', mode: 'cursor', cursorContext: { beforeCursor: '', afterCursor: '', isInbetween: true, line: 0, column: 0, }, }; const result = formatEditorContext(context); expect(result).toBe('<editor_cursor path="test.md">\n| #inbetween\n</editor_cursor>'); }); it('returns empty string for none mode', () => { const context: EditorSelectionContext = { notePath: 'test.md', mode: 'none', }; expect(formatEditorContext(context)).toBe(''); }); it('returns empty string for selection mode without selectedText', () => { const context: EditorSelectionContext = { notePath: 'test.md', mode: 'selection', }; expect(formatEditorContext(context)).toBe(''); }); it('returns empty string for cursor mode without cursorContext', () => { const context: EditorSelectionContext = { notePath: 'test.md', mode: 'cursor', }; expect(formatEditorContext(context)).toBe(''); }); }); describe('appendEditorContext', () => { it('appends formatted context to prompt', () => { const context: EditorSelectionContext = { notePath: 'test.md', mode: 'selection', selectedText: 'text', startLine: 1, lineCount: 1, }; const result = appendEditorContext('Fix this', context); expect(result).toBe('Fix this\n\n<editor_selection path="test.md" lines="1-1">\ntext\n</editor_selection>'); }); it('returns prompt unchanged when context is none', () => { const context: EditorSelectionContext = { notePath: 'test.md', mode: 'none', }; expect(appendEditorContext('Fix this', context)).toBe('Fix this'); }); }); ================================================ FILE: tests/unit/utils/env.test.ts ================================================ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import * as env from '../../../src/utils/env'; const { cliPathRequiresNode, findNodeDirectory, findNodeExecutable, formatContextLimit, getCurrentModelFromEnvironment, getCustomModelIds, getEnhancedPath, getMissingNodeError, getModelsFromEnvironment, getHostnameKey, parseContextLimit, parseEnvironmentVariables, } = env; const isWindows = process.platform === 'win32'; const SEP = isWindows ? ';' : ':'; describe('parseEnvironmentVariables', () => { it('parses simple KEY=VALUE pairs', () => { const input = 'FOO=bar\nBAZ=qux'; expect(parseEnvironmentVariables(input)).toEqual({ FOO: 'bar', BAZ: 'qux' }); }); it('handles quoted values', () => { const input = 'FOO="bar baz"\nQUX=\'hello world\''; expect(parseEnvironmentVariables(input)).toEqual({ FOO: 'bar baz', QUX: 'hello world' }); }); it('ignores comments and empty lines', () => { const input = '# comment\nFOO=bar\n\n# another\nBAZ=qux'; expect(parseEnvironmentVariables(input)).toEqual({ FOO: 'bar', BAZ: 'qux' }); }); it('handles Windows line endings', () => { const input = 'FOO=bar\r\nBAZ=qux\r\n'; expect(parseEnvironmentVariables(input)).toEqual({ FOO: 'bar', BAZ: 'qux' }); }); it('handles equals sign in value', () => { const input = 'FOO=bar=baz'; expect(parseEnvironmentVariables(input)).toEqual({ FOO: 'bar=baz' }); }); it('trims whitespace around keys and values', () => { const input = ' FOO = bar '; expect(parseEnvironmentVariables(input)).toEqual({ FOO: 'bar' }); }); it('strips export prefix from shell snippets', () => { const input = 'export FOO=bar\nexport BAZ="hello world"'; expect(parseEnvironmentVariables(input)).toEqual({ FOO: 'bar', BAZ: 'hello world' }); }); it('handles mixed export and non-export lines', () => { const input = 'FOO=bar\nexport BAZ=qux\nQUX=123'; expect(parseEnvironmentVariables(input)).toEqual({ FOO: 'bar', BAZ: 'qux', QUX: '123' }); }); }); describe('getEnhancedPath', () => { const originalEnv = { ...process.env }; afterEach(() => { // Restore environment Object.keys(process.env).forEach(key => delete process.env[key]); Object.assign(process.env, originalEnv); }); describe('basic functionality', () => { it('returns a non-empty string', () => { const result = getEnhancedPath(); expect(typeof result).toBe('string'); expect(result.length).toBeGreaterThan(0); }); it('includes current PATH from process.env', () => { process.env.PATH = `/existing/path${SEP}/another/path`; const result = getEnhancedPath(); expect(result).toContain('/existing/path'); expect(result).toContain('/another/path'); }); it('works when process.env.PATH is empty', () => { process.env.PATH = ''; const result = getEnhancedPath(); expect(typeof result).toBe('string'); // Should still have extra paths expect(result.length).toBeGreaterThan(0); }); it('works when process.env.PATH is undefined', () => { delete process.env.PATH; const result = getEnhancedPath(); expect(typeof result).toBe('string'); expect(result.length).toBeGreaterThan(0); }); }); describe('platform-specific separator', () => { it('uses correct separator for current platform', () => { const result = getEnhancedPath(); // Result should contain the platform-specific separator expect(result).toContain(SEP); }); it('splits and joins with platform separator', () => { const result = getEnhancedPath(); const segments = result.split(SEP); // Should have multiple segments expect(segments.length).toBeGreaterThan(1); // Rejoining should give same result expect(segments.join(SEP)).toBe(result); }); it('handles input with platform separator', () => { const customPath = `/custom/bin1${SEP}/custom/bin2`; const result = getEnhancedPath(customPath); expect(result).toContain('/custom/bin1'); expect(result).toContain('/custom/bin2'); }); }); describe('custom PATH merging and priority', () => { it('prepends additional paths (highest priority)', () => { process.env.PATH = '/existing/path'; const result = getEnhancedPath('/custom/bin'); const segments = result.split(SEP); // Custom path should be first expect(segments[0]).toBe('/custom/bin'); // Existing should come after extra paths expect(segments.indexOf('/custom/bin')).toBeLessThan(segments.indexOf('/existing/path')); }); it('merges multiple additional paths in order', () => { const customPath = `/first/bin${SEP}/second/bin${SEP}/third/bin`; const result = getEnhancedPath(customPath); const segments = result.split(SEP); expect(segments[0]).toBe('/first/bin'); expect(segments[1]).toBe('/second/bin'); expect(segments[2]).toBe('/third/bin'); }); it('preserves priority: additional > extra > current', () => { process.env.PATH = '/usr/bin'; const result = getEnhancedPath('/user/custom'); const segments = result.split(SEP); const customIndex = segments.indexOf('/user/custom'); const usrBinIndex = segments.indexOf('/usr/bin'); // Custom should come before current PATH expect(customIndex).toBeLessThan(usrBinIndex); }); it('handles undefined additional paths', () => { process.env.PATH = '/existing/path'; const result = getEnhancedPath(undefined); expect(result).toContain('/existing/path'); }); it('handles empty string additional paths', () => { process.env.PATH = '/existing/path'; const result = getEnhancedPath(''); expect(result).toContain('/existing/path'); // Should not have empty segments const segments = result.split(SEP); expect(segments.every(s => s.length > 0)).toBe(true); }); }); describe('deduplication logic', () => { it('removes duplicate paths', () => { process.env.PATH = `/usr/local/bin${SEP}/usr/bin`; const result = getEnhancedPath('/usr/local/bin'); const segments = result.split(SEP); const count = segments.filter(s => s === '/usr/local/bin').length; expect(count).toBe(1); }); it('preserves first occurrence when deduplicating', () => { // Additional path should win over current PATH process.env.PATH = `/duplicate/path${SEP}/other/path`; const result = getEnhancedPath('/duplicate/path'); const segments = result.split(SEP); // First occurrence should be from additional paths expect(segments[0]).toBe('/duplicate/path'); }); it('deduplicates across all sources', () => { // Path appears in additional, might be in extra paths, and in current process.env.PATH = `/usr/local/bin${SEP}/usr/bin${SEP}/usr/local/bin`; const result = getEnhancedPath(`/usr/local/bin${SEP}/usr/bin`); const segments = result.split(SEP); // Each unique path should appear only once const localBinCount = segments.filter(s => s === '/usr/local/bin').length; const usrBinCount = segments.filter(s => s === '/usr/bin').length; expect(localBinCount).toBe(1); expect(usrBinCount).toBe(1); }); // Note: Case-insensitive deduplication on Windows is tested implicitly // since the module uses lowercase comparison on win32 }); describe('empty segment filtering', () => { it('filters out empty segments from current PATH', () => { process.env.PATH = `/usr/bin${SEP}${SEP}/bin${SEP}`; const result = getEnhancedPath(); const segments = result.split(SEP); expect(segments.every(s => s.length > 0)).toBe(true); }); it('filters out empty segments from additional paths', () => { const result = getEnhancedPath(`${SEP}/custom/bin${SEP}${SEP}`); const segments = result.split(SEP); expect(segments.every(s => s.length > 0)).toBe(true); }); it('handles path with only empty segments', () => { process.env.PATH = `${SEP}${SEP}${SEP}`; const result = getEnhancedPath(`${SEP}${SEP}`); const segments = result.split(SEP); expect(segments.every(s => s.length > 0)).toBe(true); }); }); describe('extra binary paths', () => { it('returns non-empty result with extra paths', () => { const result = getEnhancedPath(); // On both platforms, result should be non-empty expect(result.length).toBeGreaterThan(0); }); it('includes platform-appropriate paths', () => { const result = getEnhancedPath(); const segments = result.split(SEP); // Should have added some extra paths beyond just process.env.PATH expect(segments.length).toBeGreaterThan(1); }); }); describe('Unix environment variable paths', () => { if (isWindows) return; it('includes VOLTA_HOME/bin when set', () => { process.env.VOLTA_HOME = '/custom/volta'; const result = getEnhancedPath(); expect(result).toContain('/custom/volta/bin'); delete process.env.VOLTA_HOME; }); it('includes ASDF_DATA_DIR shims and bin when set', () => { process.env.ASDF_DATA_DIR = '/custom/asdf'; const result = getEnhancedPath(); expect(result).toContain('/custom/asdf/shims'); expect(result).toContain('/custom/asdf/bin'); delete process.env.ASDF_DATA_DIR; }); it('includes ASDF_DIR shims and bin when set', () => { delete process.env.ASDF_DATA_DIR; process.env.ASDF_DIR = '/alt/asdf'; const result = getEnhancedPath(); expect(result).toContain('/alt/asdf/shims'); expect(result).toContain('/alt/asdf/bin'); delete process.env.ASDF_DIR; }); it('includes FNM_MULTISHELL_PATH when set', () => { process.env.FNM_MULTISHELL_PATH = '/tmp/fnm_multishell'; const result = getEnhancedPath(); expect(result).toContain('/tmp/fnm_multishell'); delete process.env.FNM_MULTISHELL_PATH; }); it('includes FNM_DIR when set', () => { process.env.FNM_DIR = '/custom/fnm'; const result = getEnhancedPath(); expect(result).toContain('/custom/fnm'); delete process.env.FNM_DIR; }); it('includes NVM_BIN when set', () => { process.env.NVM_BIN = '/home/user/.nvm/versions/node/v20/bin'; const result = getEnhancedPath(); expect(result).toContain('/home/user/.nvm/versions/node/v20/bin'); delete process.env.NVM_BIN; }); describe('nvm fallback when NVM_BIN is not set (Unix)', () => { if (isWindows) return; let savedNvmBin: string | undefined; let savedNvmDir: string | undefined; let savedHome: string | undefined; beforeEach(() => { savedNvmBin = process.env.NVM_BIN; savedNvmDir = process.env.NVM_DIR; savedHome = process.env.HOME; delete process.env.NVM_BIN; delete process.env.NVM_DIR; process.env.HOME = '/fake/home'; }); afterEach(() => { jest.restoreAllMocks(); if (savedNvmBin !== undefined) process.env.NVM_BIN = savedNvmBin; else delete process.env.NVM_BIN; if (savedNvmDir !== undefined) process.env.NVM_DIR = savedNvmDir; else delete process.env.NVM_DIR; if (savedHome !== undefined) process.env.HOME = savedHome; else delete process.env.HOME; }); function mockNvm(opts: { nvmDir: string; aliasFiles: Record<string, string>; versions: string[]; existingBinDirs?: string[]; }) { const { nvmDir, aliasFiles, versions, existingBinDirs } = opts; const binDirs = existingBinDirs ?? versions.map(v => path.join(nvmDir, 'versions', 'node', v, 'bin')); jest.spyOn(fs, 'existsSync').mockImplementation(p => binDirs.includes(String(p))); jest.spyOn(fs, 'readFileSync').mockImplementation(((p: string) => { const s = String(p); for (const [aliasPath, value] of Object.entries(aliasFiles)) { if (s === aliasPath) return value; } throw new Error('not found'); }) as typeof fs.readFileSync); jest.spyOn(fs, 'readdirSync').mockImplementation(((p: string) => { if (String(p) === path.join(nvmDir, 'versions', 'node')) return versions; return []; }) as typeof fs.readdirSync); jest.spyOn(fs, 'statSync').mockImplementation( () => ({ isFile: () => true, isDirectory: () => true }) as fs.Stats ); } it('resolves default version from alias file', () => { const nvmDir = '/fake/home/.nvm'; const versionBin = path.join(nvmDir, 'versions', 'node', 'v22.18.0', 'bin'); mockNvm({ nvmDir, aliasFiles: { [path.join(nvmDir, 'alias', 'default')]: '22' }, versions: ['v22.18.0'], }); expect(getEnhancedPath()).toContain(versionBin); }); it('respects NVM_DIR env var', () => { process.env.NVM_DIR = '/custom/nvm'; const versionBin = '/custom/nvm/versions/node/v20.10.0/bin'; mockNvm({ nvmDir: '/custom/nvm', aliasFiles: { '/custom/nvm/alias/default': '20' }, versions: ['v20.10.0'], }); expect(getEnhancedPath()).toContain(versionBin); }); it('picks highest matching version when multiple match', () => { const nvmDir = '/fake/home/.nvm'; const expectedBin = path.join(nvmDir, 'versions', 'node', 'v22.18.0', 'bin'); mockNvm({ nvmDir, aliasFiles: { [path.join(nvmDir, 'alias', 'default')]: '22' }, versions: ['v22.5.0', 'v22.18.0', 'v20.10.0'], }); const result = getEnhancedPath(); expect(result).toContain(expectedBin); expect(result).not.toContain('v22.5.0'); }); it('follows alias chains (lts/* → lts/jod → version)', () => { const nvmDir = '/fake/home/.nvm'; const versionBin = path.join(nvmDir, 'versions', 'node', 'v22.18.0', 'bin'); mockNvm({ nvmDir, aliasFiles: { [path.join(nvmDir, 'alias', 'default')]: 'lts/*', [path.join(nvmDir, 'alias', 'lts', '*')]: 'lts/jod', [path.join(nvmDir, 'alias', 'lts', 'jod')]: 'v22.18.0', }, versions: ['v22.18.0', 'v20.10.0'], }); expect(getEnhancedPath()).toContain(versionBin); }); it.each(['node', 'stable'])( 'resolves built-in %s alias to the highest installed version', builtInAlias => { const nvmDir = '/fake/home/.nvm'; const expectedBin = path.join(nvmDir, 'versions', 'node', 'v22.18.0', 'bin'); mockNvm({ nvmDir, aliasFiles: { [path.join(nvmDir, 'alias', 'default')]: builtInAlias, }, versions: ['v20.10.0', 'v22.18.0'], }); const result = getEnhancedPath(); expect(result).toContain(expectedBin); expect(result).not.toContain('v20.10.0'); } ); }); }); describe('CLI path parameter for Node.js detection', () => { afterEach(() => { jest.restoreAllMocks(); }); function mockNodeExecutable(fakeDir: string) { const nodePath = path.join(fakeDir, isWindows ? 'node.exe' : 'node'); jest.spyOn(fs, 'existsSync').mockImplementation(p => String(p) === nodePath); jest.spyOn(fs, 'statSync').mockImplementation( p => ({ isFile: () => String(p) === nodePath }) as fs.Stats ); return nodePath; } it('prepends detected node directory before extra paths when cliPath is .js', () => { const fakeDir = isWindows ? 'C:\\fake\\node' : '/tmp/fake-node'; mockNodeExecutable(fakeDir); const otherPath = isWindows ? 'C:\\other' : '/other'; process.env.PATH = `${fakeDir}${SEP}${otherPath}`; if (isWindows) { process.env.ProgramFiles = 'C:\\Program Files'; } const result = getEnhancedPath(undefined, '/path/to/cli.js'); const segments = result.split(SEP); const extraPath = isWindows ? 'C:\\Program Files\\nodejs' : '/usr/local/bin'; const nodeIndex = segments.indexOf(fakeDir); const extraIndex = segments.indexOf(extraPath); expect(nodeIndex).toBeGreaterThanOrEqual(0); expect(extraIndex).toBeGreaterThanOrEqual(0); expect(nodeIndex).toBeLessThan(extraIndex); }); it('does not prepend node directory when cliPath is native binary', () => { const fakeDir = isWindows ? 'C:\\fake\\node' : '/tmp/fake-node'; mockNodeExecutable(fakeDir); const otherPath = isWindows ? 'C:\\other' : '/other'; process.env.PATH = `${fakeDir}${SEP}${otherPath}`; if (isWindows) { process.env.ProgramFiles = 'C:\\Program Files'; } const result = getEnhancedPath(undefined, '/path/to/claude.exe'); const segments = result.split(SEP); const extraPath = isWindows ? 'C:\\Program Files\\nodejs' : '/usr/local/bin'; const nodeIndex = segments.indexOf(fakeDir); const extraIndex = segments.indexOf(extraPath); expect(nodeIndex).toBeGreaterThanOrEqual(0); expect(extraIndex).toBeGreaterThanOrEqual(0); expect(nodeIndex).toBeGreaterThan(extraIndex); }); it('accepts cliPath parameter without error', () => { const result = getEnhancedPath(undefined, '/path/to/cli.js'); expect(typeof result).toBe('string'); expect(result.length).toBeGreaterThan(0); }); it('works with both additionalPaths and cliPath', () => { const result = getEnhancedPath('/custom/path', '/path/to/cli.js'); expect(result).toContain('/custom/path'); }); it('works with native binary path (no Node.js detection needed)', () => { const result = getEnhancedPath(undefined, '/path/to/claude.exe'); expect(typeof result).toBe('string'); }); }); describe('CLI directory with node executable (nvm/fnm/volta/asdf support)', () => { afterEach(() => { jest.restoreAllMocks(); }); function mockCliDirWithNode(cliDir: string) { const nodePath = path.join(cliDir, isWindows ? 'node.exe' : 'node'); jest.spyOn(fs, 'existsSync').mockImplementation(p => String(p) === nodePath); jest.spyOn(fs, 'statSync').mockImplementation( p => ({ isFile: () => String(p) === nodePath }) as fs.Stats ); } it('adds CLI directory to PATH when it contains node (Unix nvm)', () => { if (isWindows) return; const nvmBinDir = '/Users/test/.nvm/versions/node/v20.10.0/bin'; const cliPath = path.join(nvmBinDir, 'claude'); mockCliDirWithNode(nvmBinDir); process.env.PATH = '/usr/bin'; const result = getEnhancedPath(undefined, cliPath); const segments = result.split(SEP); // CLI directory should be added and come before /usr/bin expect(segments).toContain(nvmBinDir); expect(segments.indexOf(nvmBinDir)).toBeLessThan(segments.indexOf('/usr/bin')); }); it('adds CLI directory to PATH when it contains node (Windows nvm)', () => { if (!isWindows) return; const nvmBinDir = 'C:\\Users\\test\\AppData\\Roaming\\nvm\\v20.10.0'; const cliPath = path.join(nvmBinDir, 'claude.cmd'); mockCliDirWithNode(nvmBinDir); process.env.PATH = 'C:\\Windows\\System32'; const result = getEnhancedPath(undefined, cliPath); const segments = result.split(SEP); // CLI directory should be added (case-insensitive check for Windows) const hasNvmDir = segments.some(s => s.toLowerCase() === nvmBinDir.toLowerCase()); expect(hasNvmDir).toBe(true); }); it('adds CLI directory to PATH for fnm installation', () => { if (isWindows) return; const fnmBinDir = '/Users/test/.fnm/node-versions/v20.10.0/installation/bin'; const cliPath = path.join(fnmBinDir, 'claude'); mockCliDirWithNode(fnmBinDir); process.env.PATH = '/usr/bin'; const result = getEnhancedPath(undefined, cliPath); expect(result).toContain(fnmBinDir); }); it('adds CLI directory to PATH for volta installation', () => { if (isWindows) return; const voltaBinDir = '/Users/test/.volta/bin'; const cliPath = path.join(voltaBinDir, 'claude'); mockCliDirWithNode(voltaBinDir); process.env.PATH = '/usr/bin'; const result = getEnhancedPath(undefined, cliPath); expect(result).toContain(voltaBinDir); }); it('adds CLI directory to PATH for asdf installation', () => { if (isWindows) return; const asdfBinDir = '/Users/test/.asdf/installs/nodejs/20.10.0/bin'; const cliPath = path.join(asdfBinDir, 'claude'); mockCliDirWithNode(asdfBinDir); process.env.PATH = '/usr/bin'; const result = getEnhancedPath(undefined, cliPath); expect(result).toContain(asdfBinDir); }); it('does not add CLI directory when node is not present', () => { const cliDir = isWindows ? 'C:\\custom\\bin' : '/custom/bin'; const cliPath = path.join(cliDir, isWindows ? 'claude.exe' : 'claude'); // Mock: node does not exist in CLI directory jest.spyOn(fs, 'existsSync').mockReturnValue(false); process.env.PATH = isWindows ? 'C:\\Windows\\System32' : '/usr/bin'; const result = getEnhancedPath(undefined, cliPath); expect(result).not.toContain(cliDir); }); it('CLI directory has higher priority than fallback node search', () => { if (isWindows) return; const nvmBinDir = '/Users/test/.nvm/versions/node/v20.10.0/bin'; const cliPath = path.join(nvmBinDir, 'cli.js'); // JS file // Mock: node exists in CLI directory const nodePath = path.join(nvmBinDir, 'node'); jest.spyOn(fs, 'existsSync').mockImplementation(p => String(p) === nodePath); jest.spyOn(fs, 'statSync').mockImplementation( p => ({ isFile: () => String(p) === nodePath }) as fs.Stats ); process.env.PATH = '/usr/bin'; const result = getEnhancedPath(undefined, cliPath); const segments = result.split(SEP); // CLI directory should be first (after any additional paths) expect(segments[0]).toBe(nvmBinDir); }); it('user additional paths have highest priority over CLI directory', () => { if (isWindows) return; const nvmBinDir = '/Users/test/.nvm/versions/node/v20.10.0/bin'; const cliPath = path.join(nvmBinDir, 'claude'); mockCliDirWithNode(nvmBinDir); const userPath = '/user/custom/bin'; process.env.PATH = '/usr/bin'; const result = getEnhancedPath(userPath, cliPath); const segments = result.split(SEP); // User path should be first, then CLI directory expect(segments[0]).toBe(userPath); expect(segments[1]).toBe(nvmBinDir); }); }); }); describe('cliPathRequiresNode', () => { afterEach(() => { jest.restoreAllMocks(); }); it('returns true for .js files', () => { expect(cliPathRequiresNode('/path/to/cli.js')).toBe(true); expect(cliPathRequiresNode('C:\\path\\to\\cli.js')).toBe(true); }); it('returns true for other JS extensions', () => { expect(cliPathRequiresNode('/path/to/cli.mjs')).toBe(true); expect(cliPathRequiresNode('/path/to/cli.cjs')).toBe(true); expect(cliPathRequiresNode('/path/to/cli.ts')).toBe(true); expect(cliPathRequiresNode('/path/to/cli.tsx')).toBe(true); expect(cliPathRequiresNode('/path/to/cli.jsx')).toBe(true); }); it('returns false for native binaries', () => { expect(cliPathRequiresNode('/path/to/claude')).toBe(false); expect(cliPathRequiresNode('/path/to/claude.exe')).toBe(false); expect(cliPathRequiresNode('C:\\path\\to\\claude.exe')).toBe(false); }); it('returns true for scripts with node shebang', () => { const scriptPath = isWindows ? 'C:\\temp\\claude' : '/tmp/claude'; const shebang = '#!/usr/bin/env node\nconsole.log("hi");\n'; jest.spyOn(fs, 'existsSync').mockImplementation(p => String(p) === scriptPath); jest.spyOn(fs, 'statSync').mockImplementation( p => ({ isFile: () => String(p) === scriptPath }) as fs.Stats ); jest.spyOn(fs, 'openSync').mockImplementation(() => 1 as any); jest.spyOn(fs, 'readSync').mockImplementation((_, buffer: Buffer) => { buffer.write(shebang); return shebang.length; }); jest.spyOn(fs, 'closeSync').mockImplementation(() => {}); expect(cliPathRequiresNode(scriptPath)).toBe(true); }); it('returns false when path exists but is a directory', () => { const dirPath = isWindows ? 'C:\\temp\\claude' : '/tmp/claude'; jest.spyOn(fs, 'existsSync').mockImplementation(p => String(p) === dirPath); jest.spyOn(fs, 'statSync').mockImplementation( () => ({ isFile: () => false }) as fs.Stats ); expect(cliPathRequiresNode(dirPath)).toBe(false); }); it('returns false for scripts without node shebang', () => { const scriptPath = isWindows ? 'C:\\temp\\script' : '/tmp/script'; const shebang = '#!/usr/bin/env python\nprint("hi")\n'; jest.spyOn(fs, 'existsSync').mockImplementation(p => String(p) === scriptPath); jest.spyOn(fs, 'statSync').mockImplementation( p => ({ isFile: () => String(p) === scriptPath }) as fs.Stats ); jest.spyOn(fs, 'openSync').mockImplementation(() => 1 as any); jest.spyOn(fs, 'readSync').mockImplementation((_, buffer: Buffer) => { buffer.write(shebang); return shebang.length; }); jest.spyOn(fs, 'closeSync').mockImplementation(() => {}); expect(cliPathRequiresNode(scriptPath)).toBe(false); }); it('returns false for .cmd files', () => { expect(cliPathRequiresNode('/path/to/claude.cmd')).toBe(false); }); it('is case-insensitive', () => { expect(cliPathRequiresNode('/path/to/CLI.JS')).toBe(true); expect(cliPathRequiresNode('/path/to/cli.MJS')).toBe(true); }); }); describe('getMissingNodeError', () => { afterEach(() => { jest.restoreAllMocks(); }); it('returns null when CLI does not require Node.js', () => { jest.spyOn(fs, 'existsSync').mockReturnValue(false); const error = getMissingNodeError('/path/to/claude'); expect(error).toBeNull(); }); it('returns error when Node.js is missing and CLI requires Node.js', () => { jest.spyOn(fs, 'existsSync').mockReturnValue(false); const error = getMissingNodeError('/path/to/cli.js', '/missing'); expect(error).toContain('Node.js'); }); it('returns null when Node.js is found on PATH', () => { const nodeDir = isWindows ? 'C:\\custom\\bin' : '/custom/bin'; const nodePath = path.join(nodeDir, isWindows ? 'node.exe' : 'node'); jest.spyOn(fs, 'existsSync').mockImplementation(p => String(p) === nodePath); jest.spyOn(fs, 'statSync').mockImplementation( p => ({ isFile: () => String(p) === nodePath }) as fs.Stats ); const error = getMissingNodeError('/path/to/cli.js', nodeDir); expect(error).toBeNull(); }); }); describe('findNodeDirectory', () => { const originalEnv = { ...process.env }; afterEach(() => { jest.restoreAllMocks(); Object.keys(process.env).forEach(key => delete process.env[key]); Object.assign(process.env, originalEnv); }); it('returns string or null', () => { const result = findNodeDirectory(); expect(result === null || typeof result === 'string').toBe(true); }); it('returns a non-empty string when node is found', () => { const result = findNodeDirectory(); // On most dev machines, node should be findable // Result is either null (not found) or a non-empty directory path const isValidResult = result === null || (typeof result === 'string' && result.length > 0); expect(isValidResult).toBe(true); }); it('uses NVM_SYMLINK when set on Windows', () => { if (!isWindows) { return; } const nvmSymlink = 'C:\\nvm\\symlink'; const nodePath = path.join(nvmSymlink, 'node.exe'); jest.spyOn(fs, 'existsSync').mockImplementation(p => String(p) === nodePath); jest.spyOn(fs, 'statSync').mockImplementation( p => ({ isFile: () => String(p) === nodePath }) as fs.Stats ); process.env.NVM_SYMLINK = nvmSymlink; process.env.PATH = ''; const result = findNodeDirectory(); expect(result).toBe(nvmSymlink); }); it('prefers additionalPaths over process.env.PATH', () => { const nodeExecutable = isWindows ? 'node.exe' : 'node'; const preferredDir = isWindows ? 'C:\\custom\\bin' : '/custom/bin'; const fallbackDir = isWindows ? 'C:\\fallback\\bin' : '/fallback/bin'; const preferredNode = path.join(preferredDir, nodeExecutable); const fallbackNode = path.join(fallbackDir, nodeExecutable); jest.spyOn(fs, 'existsSync').mockImplementation(p => { const candidate = String(p); return candidate === preferredNode || candidate === fallbackNode; }); jest.spyOn(fs, 'statSync').mockImplementation( () => ({ isFile: () => true }) as fs.Stats ); process.env.PATH = fallbackDir; const result = findNodeDirectory(preferredDir); expect(result).toBe(preferredDir); }); it('returns full path for findNodeExecutable when available', () => { const nodeExecutable = isWindows ? 'node.exe' : 'node'; const preferredDir = isWindows ? 'C:\\custom\\bin' : '/custom/bin'; const preferredNode = path.join(preferredDir, nodeExecutable); jest.spyOn(fs, 'existsSync').mockImplementation(p => String(p) === preferredNode); jest.spyOn(fs, 'statSync').mockImplementation( () => ({ isFile: () => true }) as fs.Stats ); const result = findNodeExecutable(preferredDir); expect(result).toBe(preferredNode); }); }); describe('getHostnameKey', () => { it('returns a non-empty string', () => { const hostname = getHostnameKey(); expect(typeof hostname).toBe('string'); expect(hostname.length).toBeGreaterThan(0); }); it('returns the system hostname', () => { const hostname = getHostnameKey(); expect(hostname).toBe(os.hostname()); }); it('returns consistent value on repeated calls', () => { const first = getHostnameKey(); const second = getHostnameKey(); expect(first).toBe(second); }); }); describe('parseContextLimit', () => { it('should parse "256k" to 256000', () => { expect(parseContextLimit('256k')).toBe(256000); }); it('should parse "256K" to 256000 (case insensitive)', () => { expect(parseContextLimit('256K')).toBe(256000); }); it('should parse "1m" to 1000000', () => { expect(parseContextLimit('1m')).toBe(1000000); }); it('should parse "1M" to 1000000 (case insensitive)', () => { expect(parseContextLimit('1M')).toBe(1000000); }); it('should parse "1000000" to 1000000', () => { expect(parseContextLimit('1000000')).toBe(1000000); }); it('should parse "1.5m" to 1500000', () => { expect(parseContextLimit('1.5m')).toBe(1500000); }); it('should parse "200k" to 200000', () => { expect(parseContextLimit('200k')).toBe(200000); }); it('should handle whitespace', () => { expect(parseContextLimit(' 256k ')).toBe(256000); }); it('should handle space before suffix', () => { expect(parseContextLimit('256 k')).toBe(256000); expect(parseContextLimit('1 m')).toBe(1000000); expect(parseContextLimit('1.5 m')).toBe(1500000); }); it('should return null for empty string', () => { expect(parseContextLimit('')).toBeNull(); }); it('should return null for whitespace only', () => { expect(parseContextLimit(' ')).toBeNull(); }); it('should return null for invalid input', () => { expect(parseContextLimit('abc')).toBeNull(); expect(parseContextLimit('k256')).toBeNull(); expect(parseContextLimit('256x')).toBeNull(); }); it('should return null for negative values', () => { expect(parseContextLimit('-100k')).toBeNull(); }); it('should return null for zero', () => { expect(parseContextLimit('0k')).toBeNull(); }); it('should return null for values below 1k', () => { expect(parseContextLimit('100')).toBeNull(); expect(parseContextLimit('999')).toBeNull(); }); it('should return null for values above 10m', () => { expect(parseContextLimit('20m')).toBeNull(); expect(parseContextLimit('11000000')).toBeNull(); }); it('should accept boundary values', () => { expect(parseContextLimit('1k')).toBe(1000); expect(parseContextLimit('10m')).toBe(10000000); }); }); describe('formatContextLimit', () => { it('should format 256000 as "256k"', () => { expect(formatContextLimit(256000)).toBe('256k'); }); it('should format 1000000 as "1m"', () => { expect(formatContextLimit(1000000)).toBe('1m'); }); it('should format 200000 as "200k"', () => { expect(formatContextLimit(200000)).toBe('200k'); }); it('should format 2000000 as "2m"', () => { expect(formatContextLimit(2000000)).toBe('2m'); }); it('should format non-round numbers with toLocaleString', () => { expect(formatContextLimit(256500)).toBe('256,500'); }); it('should format small numbers with toLocaleString', () => { expect(formatContextLimit(500)).toBe('500'); }); it('should round-trip through parseContextLimit for all formats', () => { // Round numbers (k/m suffix) expect(parseContextLimit(formatContextLimit(256000))).toBe(256000); expect(parseContextLimit(formatContextLimit(1000000))).toBe(1000000); expect(parseContextLimit(formatContextLimit(2000000))).toBe(2000000); // Non-round numbers (locale-formatted with commas) expect(parseContextLimit(formatContextLimit(256500))).toBe(256500); expect(parseContextLimit(formatContextLimit(1234567))).toBe(1234567); }); }); describe('parseContextLimit with comma-formatted input', () => { it('should parse "256,500" to 256500', () => { expect(parseContextLimit('256,500')).toBe(256500); }); it('should parse "1,000,000" to 1000000', () => { expect(parseContextLimit('1,000,000')).toBe(1000000); }); it('should parse "1,234,567" to 1234567', () => { expect(parseContextLimit('1,234,567')).toBe(1234567); }); }); describe('getCustomModelIds', () => { it('should return empty set when no custom models configured', () => { const result = getCustomModelIds({}); expect(result.size).toBe(0); }); it('should extract ANTHROPIC_MODEL', () => { const result = getCustomModelIds({ ANTHROPIC_MODEL: 'custom-model' }); expect(result.size).toBe(1); expect(result.has('custom-model')).toBe(true); }); it('should extract model from default tier env vars', () => { const result = getCustomModelIds({ ANTHROPIC_DEFAULT_OPUS_MODEL: 'my-opus', ANTHROPIC_DEFAULT_SONNET_MODEL: 'my-sonnet', ANTHROPIC_DEFAULT_HAIKU_MODEL: 'my-haiku', }); expect(result.size).toBe(3); expect(result.has('my-opus')).toBe(true); expect(result.has('my-sonnet')).toBe(true); expect(result.has('my-haiku')).toBe(true); }); it('should deduplicate when multiple env vars point to same model', () => { const result = getCustomModelIds({ ANTHROPIC_MODEL: 'shared-model', ANTHROPIC_DEFAULT_SONNET_MODEL: 'shared-model', }); expect(result.size).toBe(1); expect(result.has('shared-model')).toBe(true); }); it('should ignore unrelated env vars', () => { const result = getCustomModelIds({ ANTHROPIC_API_KEY: 'secret-key', ANTHROPIC_BASE_URL: 'https://api.example.com', OTHER_VAR: 'value', }); expect(result.size).toBe(0); }); it('should handle mixed relevant and irrelevant env vars', () => { const result = getCustomModelIds({ ANTHROPIC_API_KEY: 'secret-key', ANTHROPIC_MODEL: 'custom-model', ANTHROPIC_BASE_URL: 'https://api.example.com', }); expect(result.size).toBe(1); expect(result.has('custom-model')).toBe(true); }); it('should ignore empty string model values', () => { const result = getCustomModelIds({ ANTHROPIC_MODEL: '', ANTHROPIC_DEFAULT_SONNET_MODEL: 'valid-model', }); expect(result.size).toBe(1); expect(result.has('')).toBe(false); expect(result.has('valid-model')).toBe(true); }); it('should ignore whitespace-only model values', () => { const result = getCustomModelIds({ ANTHROPIC_MODEL: ' ', ANTHROPIC_DEFAULT_HAIKU_MODEL: 'my-haiku', }); // Note: getCustomModelIds only checks truthiness, so whitespace passes // This test documents the current behavior expect(result.has('my-haiku')).toBe(true); }); }); describe('getModelsFromEnvironment', () => { it('returns empty array when no custom models configured', () => { const result = getModelsFromEnvironment({}); expect(result).toEqual([]); }); it('returns model for ANTHROPIC_MODEL', () => { const result = getModelsFromEnvironment({ ANTHROPIC_MODEL: 'custom-model-v1' }); expect(result).toHaveLength(1); expect(result[0].value).toBe('custom-model-v1'); expect(result[0].description).toContain('model'); }); it('formats label from hyphenated model name', () => { const result = getModelsFromEnvironment({ ANTHROPIC_MODEL: 'claude-3-opus' }); expect(result[0].label).toBe('Claude 3 Opus'); }); it('formats label from slash-separated model name', () => { const result = getModelsFromEnvironment({ ANTHROPIC_MODEL: 'org/custom-model' }); expect(result[0].label).toBe('custom-model'); }); it('returns models for tier-specific env vars', () => { const result = getModelsFromEnvironment({ ANTHROPIC_DEFAULT_OPUS_MODEL: 'my-opus', ANTHROPIC_DEFAULT_SONNET_MODEL: 'my-sonnet', ANTHROPIC_DEFAULT_HAIKU_MODEL: 'my-haiku', }); expect(result).toHaveLength(3); expect(result.map(m => m.value)).toContain('my-opus'); expect(result.map(m => m.value)).toContain('my-sonnet'); expect(result.map(m => m.value)).toContain('my-haiku'); }); it('deduplicates when multiple env vars point to same model', () => { const result = getModelsFromEnvironment({ ANTHROPIC_MODEL: 'shared-model', ANTHROPIC_DEFAULT_SONNET_MODEL: 'shared-model', }); expect(result).toHaveLength(1); expect(result[0].value).toBe('shared-model'); expect(result[0].description).toContain('model'); expect(result[0].description).toContain('sonnet'); }); it('sorts by type priority (model > haiku > sonnet > opus)', () => { const result = getModelsFromEnvironment({ ANTHROPIC_DEFAULT_OPUS_MODEL: 'opus-v1', ANTHROPIC_MODEL: 'main-model', ANTHROPIC_DEFAULT_HAIKU_MODEL: 'haiku-v1', }); expect(result[0].value).toBe('main-model'); expect(result[1].value).toBe('haiku-v1'); expect(result[2].value).toBe('opus-v1'); }); it('ignores unrelated env vars', () => { const result = getModelsFromEnvironment({ ANTHROPIC_API_KEY: 'sk-key', OTHER_VAR: 'value', }); expect(result).toEqual([]); }); it('ignores empty model values', () => { const result = getModelsFromEnvironment({ ANTHROPIC_MODEL: '', ANTHROPIC_DEFAULT_SONNET_MODEL: 'valid-model', }); expect(result).toHaveLength(1); expect(result[0].value).toBe('valid-model'); }); }); describe('getCurrentModelFromEnvironment', () => { it('returns null when no model env vars set', () => { expect(getCurrentModelFromEnvironment({})).toBeNull(); }); it('returns ANTHROPIC_MODEL when set', () => { expect(getCurrentModelFromEnvironment({ ANTHROPIC_MODEL: 'custom-model', })).toBe('custom-model'); }); it('prefers ANTHROPIC_MODEL over tier-specific vars', () => { expect(getCurrentModelFromEnvironment({ ANTHROPIC_MODEL: 'main-model', ANTHROPIC_DEFAULT_HAIKU_MODEL: 'haiku-model', ANTHROPIC_DEFAULT_SONNET_MODEL: 'sonnet-model', ANTHROPIC_DEFAULT_OPUS_MODEL: 'opus-model', })).toBe('main-model'); }); it('falls back to ANTHROPIC_DEFAULT_HAIKU_MODEL', () => { expect(getCurrentModelFromEnvironment({ ANTHROPIC_DEFAULT_HAIKU_MODEL: 'haiku-model', ANTHROPIC_DEFAULT_SONNET_MODEL: 'sonnet-model', })).toBe('haiku-model'); }); it('falls back to ANTHROPIC_DEFAULT_SONNET_MODEL', () => { expect(getCurrentModelFromEnvironment({ ANTHROPIC_DEFAULT_SONNET_MODEL: 'sonnet-model', ANTHROPIC_DEFAULT_OPUS_MODEL: 'opus-model', })).toBe('sonnet-model'); }); it('falls back to ANTHROPIC_DEFAULT_OPUS_MODEL', () => { expect(getCurrentModelFromEnvironment({ ANTHROPIC_DEFAULT_OPUS_MODEL: 'opus-model', })).toBe('opus-model'); }); it('returns null when only unrelated vars set', () => { expect(getCurrentModelFromEnvironment({ ANTHROPIC_API_KEY: 'sk-key', OTHER_VAR: 'value', })).toBeNull(); }); }); describe('getExtraBinaryPaths (Windows branches)', () => { const originalPlatform = process.platform; const originalEnv = { ...process.env }; afterEach(() => { Object.defineProperty(process, 'platform', { value: originalPlatform }); Object.keys(process.env).forEach(key => delete process.env[key]); Object.assign(process.env, originalEnv); jest.resetModules(); }); function loadWithWindowsPlatform(): typeof env { jest.resetModules(); Object.defineProperty(process, 'platform', { value: 'win32', writable: true }); // Dynamic require needed to re-evaluate module with mocked platform // eslint-disable-next-line @typescript-eslint/no-require-imports return require('../../../src/utils/env'); } it('includes APPDATA npm path when APPDATA is set', () => { process.env.APPDATA = '/mock/AppData/Roaming'; process.env.LOCALAPPDATA = '/mock/AppData/Local'; process.env.HOME = '/mock/home'; const mod = loadWithWindowsPlatform(); const result = mod.getEnhancedPath(); expect(result).toContain('npm'); expect(result).toContain('Roaming'); }); it('includes LOCALAPPDATA nodejs paths', () => { process.env.LOCALAPPDATA = '/mock/AppData/Local'; process.env.HOME = '/mock/home'; const mod = loadWithWindowsPlatform(); const result = mod.getEnhancedPath(); expect(result).toContain('Local'); expect(result).toContain('nodejs'); }); it('includes NVM_SYMLINK when set', () => { process.env.NVM_SYMLINK = '/mock/nvm/symlink'; process.env.HOME = '/mock/home'; const mod = loadWithWindowsPlatform(); const result = mod.getEnhancedPath(); expect(result).toContain('/mock/nvm/symlink'); }); it('includes NVM_HOME when set', () => { process.env.NVM_HOME = '/mock/nvm/home'; process.env.HOME = '/mock/home'; const mod = loadWithWindowsPlatform(); const result = mod.getEnhancedPath(); expect(result).toContain('/mock/nvm/home'); }); it('falls back to APPDATA/nvm when NVM_HOME not set', () => { delete process.env.NVM_HOME; process.env.APPDATA = '/mock/AppData/Roaming'; process.env.HOME = '/mock/home'; const mod = loadWithWindowsPlatform(); const result = mod.getEnhancedPath(); expect(result).toContain('nvm'); }); it('includes VOLTA_HOME/bin when set', () => { process.env.VOLTA_HOME = '/mock/volta'; process.env.HOME = '/mock/home'; const mod = loadWithWindowsPlatform(); const result = mod.getEnhancedPath(); expect(result).toContain('volta'); }); it('falls back to home/.volta/bin when VOLTA_HOME not set', () => { delete process.env.VOLTA_HOME; process.env.HOME = '/mock/home'; const mod = loadWithWindowsPlatform(); const result = mod.getEnhancedPath(); expect(result).toContain('.volta'); }); it('includes FNM_MULTISHELL_PATH when set', () => { process.env.FNM_MULTISHELL_PATH = '/mock/fnm/multishell'; process.env.HOME = '/mock/home'; const mod = loadWithWindowsPlatform(); const result = mod.getEnhancedPath(); expect(result).toContain('/mock/fnm/multishell'); }); it('includes FNM_DIR when set', () => { process.env.FNM_DIR = '/mock/fnm/dir'; process.env.HOME = '/mock/home'; const mod = loadWithWindowsPlatform(); const result = mod.getEnhancedPath(); expect(result).toContain('/mock/fnm/dir'); }); it('falls back to LOCALAPPDATA/fnm when FNM_DIR not set', () => { delete process.env.FNM_DIR; process.env.LOCALAPPDATA = '/mock/AppData/Local'; process.env.HOME = '/mock/home'; const mod = loadWithWindowsPlatform(); const result = mod.getEnhancedPath(); expect(result).toContain('fnm'); }); it('includes ChocolateyInstall/bin when set', () => { process.env.ChocolateyInstall = '/mock/choco'; process.env.HOME = '/mock/home'; const mod = loadWithWindowsPlatform(); const result = mod.getEnhancedPath(); expect(result).toContain('choco'); }); it('falls back to ProgramData/chocolatey/bin when ChocolateyInstall not set', () => { delete process.env.ChocolateyInstall; process.env.HOME = '/mock/home'; const mod = loadWithWindowsPlatform(); const result = mod.getEnhancedPath(); expect(result).toContain('chocolatey'); }); it('includes SCOOP paths when set', () => { process.env.SCOOP = '/mock/scoop'; process.env.HOME = '/mock/home'; const mod = loadWithWindowsPlatform(); const result = mod.getEnhancedPath(); expect(result).toContain('scoop'); expect(result).toContain('shims'); }); it('falls back to home/scoop when SCOOP not set', () => { delete process.env.SCOOP; process.env.HOME = '/mock/home'; const mod = loadWithWindowsPlatform(); const result = mod.getEnhancedPath(); expect(result).toContain('scoop'); }); it('includes Docker path under Program Files', () => { process.env.ProgramFiles = '/mock/Program Files'; process.env.HOME = '/mock/home'; const mod = loadWithWindowsPlatform(); const result = mod.getEnhancedPath(); expect(result).toContain('Docker'); }); it('includes home/.local/bin on Windows', () => { process.env.HOME = '/mock/home'; const mod = loadWithWindowsPlatform(); const result = mod.getEnhancedPath(); expect(result).toContain('.local'); }); it('uses semicolon separator on Windows', () => { process.env.HOME = '/mock/home'; process.env.PATH = '/existing'; const mod = loadWithWindowsPlatform(); const result = mod.getEnhancedPath(); expect(result).toContain(';'); }); }); describe('Obsidian CLI path integration', () => { const originalPlatform = process.platform; const originalExecPath = process.execPath; const originalEnv = { ...process.env }; afterEach(() => { Object.defineProperty(process, 'platform', { value: originalPlatform }); Object.defineProperty(process, 'execPath', { value: originalExecPath }); Object.keys(process.env).forEach(key => delete process.env[key]); Object.assign(process.env, originalEnv); jest.resetModules(); }); function loadWithPlatform(platform: NodeJS.Platform, execPath: string): typeof env { jest.resetModules(); Object.defineProperty(process, 'platform', { value: platform, writable: true }); Object.defineProperty(process, 'execPath', { value: execPath, configurable: true }); // Dynamic require needed to re-evaluate module with mocked platform/execPath // eslint-disable-next-line @typescript-eslint/no-require-imports return require('../../../src/utils/env'); } it('uses the top-level app bundle binary dir on macOS helper processes', () => { const helperExecPath = '/Applications/Obsidian.app/Contents/Frameworks/Obsidian Helper (Renderer).app/Contents/MacOS/Obsidian Helper (Renderer)'; process.env.PATH = ''; const mod = loadWithPlatform('darwin', helperExecPath); const result = mod.getEnhancedPath(); const segments = result.split(':'); expect(segments).toContain('/Applications/Obsidian.app/Contents/MacOS'); expect(segments).not.toContain('/Applications/Obsidian.app/Contents/Frameworks/Obsidian Helper (Renderer).app/Contents/MacOS'); }); it('does not add transient Linux AppImage mount dirs', () => { const appImageExecPath = '/tmp/.mount_Obsidian-abcd1234/usr/bin/obsidian'; const appImageDir = path.dirname(appImageExecPath); process.env.HOME = '/home/test'; process.env.PATH = ''; const mod = loadWithPlatform('linux', appImageExecPath); const result = mod.getEnhancedPath(); const segments = result.split(':'); expect(segments).not.toContain(appImageDir); expect(segments).toContain('/usr/local/bin'); expect(segments).toContain('/home/test/.local/bin'); }); }); ================================================ FILE: tests/unit/utils/externalContext.test.ts ================================================ import * as fs from 'fs'; import { filterValidPaths, findConflictingPath, getFolderName, isDuplicatePath, isValidDirectoryPath, normalizePathForComparison, validateDirectoryPath, } from '@/utils/externalContext'; jest.mock('fs'); describe('externalContext utilities', () => { describe('normalizePathForComparison', () => { const originalPlatform = process.platform; const expectNormalized = (input: string, expected: string) => { const normalized = normalizePathForComparison(input); const resolved = process.platform === 'win32' ? expected.toLowerCase() : expected; expect(normalized).toBe(resolved); }; afterEach(() => { Object.defineProperty(process, 'platform', { value: originalPlatform }); }); // eslint-disable-next-line jest/expect-expect it('should convert backslashes to forward slashes', () => { expectNormalized('C:\\Users\\test', 'C:/Users/test'); expectNormalized('path\\to\\file', 'path/to/file'); }); // eslint-disable-next-line jest/expect-expect it('should remove trailing slashes', () => { expectNormalized('/path/to/dir/', '/path/to/dir'); expectNormalized('/path/to/dir///', '/path/to/dir'); }); // eslint-disable-next-line jest/expect-expect it('should handle combined cases', () => { expectNormalized('C:\\Users\\test\\', 'C:/Users/test'); expectNormalized('C:\\Users\\test\\subdir\\', 'C:/Users/test/subdir'); }); // eslint-disable-next-line jest/expect-expect it('should handle paths without trailing slashes', () => { expectNormalized('/path/to/dir', '/path/to/dir'); expectNormalized('C:/Users/test', 'C:/Users/test'); }); // eslint-disable-next-line jest/expect-expect it('should handle Unix-style paths', () => { expectNormalized('/home/user/project', '/home/user/project'); expectNormalized('/home/user/project/', '/home/user/project'); }); it('should normalize Windows case when platform is win32', () => { Object.defineProperty(process, 'platform', { value: 'win32' }); expect(normalizePathForComparison('C:\\Users\\Test\\Docs')).toBe('c:/users/test/docs'); }); it('should translate MSYS paths on Windows', () => { Object.defineProperty(process, 'platform', { value: 'win32' }); expect(normalizePathForComparison('/c/Users/Test')).toBe('c:/users/test'); }); }); describe('findConflictingPath', () => { describe('child path detection (adding child when parent exists)', () => { it('should detect when new path is inside existing path', () => { const result = findConflictingPath('/parent/child', ['/parent']); expect(result).toEqual({ path: '/parent', type: 'parent' }); }); it('should detect deeply nested child paths', () => { const result = findConflictingPath('/parent/child/grandchild', ['/parent']); expect(result).toEqual({ path: '/parent', type: 'parent' }); }); it('should detect with multiple existing paths', () => { const result = findConflictingPath('/workspace/project', ['/other', '/workspace', '/another']); expect(result).toEqual({ path: '/workspace', type: 'parent' }); }); it('should detect with Windows paths', () => { const result = findConflictingPath('C:\\Users\\test\\project', ['C:\\Users\\test']); expect(result).toEqual({ path: 'C:\\Users\\test', type: 'parent' }); }); }); describe('parent path detection (adding parent when child exists)', () => { it('should detect when new path would contain existing path', () => { const result = findConflictingPath('/parent', ['/parent/child']); expect(result).toEqual({ path: '/parent/child', type: 'child' }); }); it('should detect when new path would contain deeply nested path', () => { const result = findConflictingPath('/parent', ['/parent/child/grandchild']); expect(result).toEqual({ path: '/parent/child/grandchild', type: 'child' }); }); it('should detect with Windows paths', () => { const result = findConflictingPath('C:\\Users\\test', ['C:\\Users\\test\\project']); expect(result).toEqual({ path: 'C:\\Users\\test\\project', type: 'child' }); }); }); describe('unrelated paths (should be allowed)', () => { it('should return null for completely unrelated paths', () => { const result = findConflictingPath('/project1', ['/project2']); expect(result).toBeNull(); }); it('should return null for sibling paths', () => { const result = findConflictingPath('/parent/sibling1', ['/parent/sibling2']); expect(result).toBeNull(); }); it('should return null when no existing paths', () => { const result = findConflictingPath('/new/path', []); expect(result).toBeNull(); }); it('should return null for paths with similar prefixes but not nested', () => { // /vault vs /vault2 - should NOT be considered nested const result = findConflictingPath('/vault2', ['/vault']); expect(result).toBeNull(); }); it('should handle Windows paths with similar prefixes', () => { const result = findConflictingPath('C:\\Users\\test2', ['C:\\Users\\test']); expect(result).toBeNull(); }); }); describe('edge cases', () => { it('should handle trailing slashes correctly', () => { const result = findConflictingPath('/parent/child/', ['/parent/']); expect(result).toEqual({ path: '/parent/', type: 'parent' }); }); it('should handle mixed path separators', () => { const result = findConflictingPath('/parent\\child', ['/parent']); expect(result).toEqual({ path: '/parent', type: 'parent' }); }); it('should handle exact same path', () => { // Exact same paths are not nested (handled by duplicate check elsewhere) const result = findConflictingPath('/parent', ['/parent']); expect(result).toBeNull(); }); it('should return first conflict when multiple exist', () => { const result = findConflictingPath('/a/b', ['/a', '/a/b/c']); // Should return /a as it appears first and is a parent expect(result).toEqual({ path: '/a', type: 'parent' }); }); }); }); describe('getFolderName', () => { it('should extract folder name from Unix path', () => { expect(getFolderName('/Users/test/workspace')).toBe('workspace'); }); it('should extract folder name from Windows path', () => { expect(getFolderName('C:\\Users\\test\\workspace')).toBe('workspace'); }); it('should handle trailing slashes', () => { expect(getFolderName('/Users/test/workspace/')).toBe('workspace'); }); it('should handle single segment paths', () => { expect(getFolderName('workspace')).toBe('workspace'); }); it('should handle root paths', () => { expect(getFolderName('/')).toBe(''); }); }); describe('validateDirectoryPath', () => { beforeEach(() => { jest.clearAllMocks(); }); it('should return valid for existing directory', () => { (fs.statSync as jest.Mock).mockReturnValue({ isDirectory: () => true }); expect(validateDirectoryPath('/existing/dir')).toEqual({ valid: true }); }); it('should return not-a-directory error for file path', () => { (fs.statSync as jest.Mock).mockReturnValue({ isDirectory: () => false }); expect(validateDirectoryPath('/path/to/file.txt')).toEqual({ valid: false, error: 'Path exists but is not a directory', }); }); it('should return ENOENT error for non-existent path', () => { const err = new Error('ENOENT') as NodeJS.ErrnoException; err.code = 'ENOENT'; (fs.statSync as jest.Mock).mockImplementation(() => { throw err; }); expect(validateDirectoryPath('/non/existent')).toEqual({ valid: false, error: 'Path does not exist', }); }); it('should return permission denied error for EACCES', () => { const err = new Error('EACCES') as NodeJS.ErrnoException; err.code = 'EACCES'; (fs.statSync as jest.Mock).mockImplementation(() => { throw err; }); expect(validateDirectoryPath('/restricted/path')).toEqual({ valid: false, error: 'Permission denied', }); }); it('should return generic error for unknown errors', () => { const err = new Error('Something went wrong') as NodeJS.ErrnoException; err.code = 'EIO'; (fs.statSync as jest.Mock).mockImplementation(() => { throw err; }); const result = validateDirectoryPath('/some/path'); expect(result.valid).toBe(false); expect(result.error).toContain('Cannot access path'); expect(result.error).toContain('Something went wrong'); }); }); describe('isValidDirectoryPath', () => { beforeEach(() => { jest.clearAllMocks(); }); it('should return true for existing directory', () => { (fs.statSync as jest.Mock).mockReturnValue({ isDirectory: () => true }); expect(isValidDirectoryPath('/existing/dir')).toBe(true); expect(fs.statSync).toHaveBeenCalledWith('/existing/dir'); }); it('should return false for non-existent path', () => { (fs.statSync as jest.Mock).mockImplementation(() => { throw new Error('ENOENT'); }); expect(isValidDirectoryPath('/non/existent')).toBe(false); }); it('should return false for file path (not directory)', () => { (fs.statSync as jest.Mock).mockReturnValue({ isDirectory: () => false }); expect(isValidDirectoryPath('/path/to/file.txt')).toBe(false); }); }); describe('filterValidPaths', () => { beforeEach(() => { jest.clearAllMocks(); }); it('should filter out non-existent paths', () => { (fs.statSync as jest.Mock).mockImplementation((p: string) => { if (p === '/valid/path') { return { isDirectory: () => true }; } throw new Error('ENOENT'); }); const result = filterValidPaths(['/valid/path', '/invalid/path', '/another/invalid']); expect(result).toEqual(['/valid/path']); }); it('should return empty array when all paths are invalid', () => { (fs.statSync as jest.Mock).mockImplementation(() => { throw new Error('ENOENT'); }); const result = filterValidPaths(['/invalid1', '/invalid2']); expect(result).toEqual([]); }); it('should return all paths when all are valid', () => { (fs.statSync as jest.Mock).mockReturnValue({ isDirectory: () => true }); const paths = ['/path1', '/path2', '/path3']; const result = filterValidPaths(paths); expect(result).toEqual(paths); }); it('should handle empty array', () => { const result = filterValidPaths([]); expect(result).toEqual([]); }); }); describe('isDuplicatePath', () => { const originalPlatform = process.platform; afterEach(() => { Object.defineProperty(process, 'platform', { value: originalPlatform }); }); it('should detect exact duplicate paths', () => { expect(isDuplicatePath('/path/to/dir', ['/path/to/dir'])).toBe(true); }); it('should return false for non-duplicate paths', () => { expect(isDuplicatePath('/path/to/dir', ['/other/path'])).toBe(false); }); it('should detect duplicates with different trailing slashes', () => { expect(isDuplicatePath('/path/to/dir/', ['/path/to/dir'])).toBe(true); expect(isDuplicatePath('/path/to/dir', ['/path/to/dir/'])).toBe(true); }); it('should detect duplicates with mixed path separators', () => { expect(isDuplicatePath('/path\\to\\dir', ['/path/to/dir'])).toBe(true); }); it('should detect case-insensitive duplicates on Windows', () => { Object.defineProperty(process, 'platform', { value: 'win32' }); expect(isDuplicatePath('C:\\Users\\Test', ['c:\\users\\test'])).toBe(true); expect(isDuplicatePath('C:/Users/Test', ['c:/users/test'])).toBe(true); }); it('should handle empty existing paths array', () => { expect(isDuplicatePath('/path/to/dir', [])).toBe(false); }); it('should check against multiple existing paths', () => { expect(isDuplicatePath('/path/b', ['/path/a', '/path/b', '/path/c'])).toBe(true); expect(isDuplicatePath('/path/d', ['/path/a', '/path/b', '/path/c'])).toBe(false); }); }); }); ================================================ FILE: tests/unit/utils/externalContextScanner.test.ts ================================================ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { type ExternalContextFile,externalContextScanner } from '@/utils/externalContextScanner'; describe('externalContextScanner', () => { let tempDir: string; beforeEach(() => { // Create a temp directory for testing tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claudian-test-')); // Create test file structure fs.mkdirSync(path.join(tempDir, 'subdir')); fs.writeFileSync(path.join(tempDir, 'file1.txt'), 'content1'); fs.writeFileSync(path.join(tempDir, 'file2.md'), 'content2'); fs.writeFileSync(path.join(tempDir, 'subdir', 'file3.ts'), 'content3'); // Create hidden file (should be skipped) fs.writeFileSync(path.join(tempDir, '.hidden'), 'hidden'); // Clear cache before each test externalContextScanner.invalidateCache(); }); afterEach(() => { // Clean up temp directory fs.rmSync(tempDir, { recursive: true, force: true }); }); describe('scanPaths', () => { it('should scan directory and return files', () => { const files = externalContextScanner.scanPaths([tempDir]); expect(files.length).toBe(3); expect(files.map((f: ExternalContextFile) => f.name).sort()).toEqual(['file1.txt', 'file2.md', 'file3.ts']); }); it('should include file metadata', () => { const files = externalContextScanner.scanPaths([tempDir]); const file1 = files.find((f: ExternalContextFile) => f.name === 'file1.txt'); expect(file1).toBeDefined(); expect(file1!.path).toBe(path.join(tempDir, 'file1.txt')); expect(file1!.relativePath).toBe('file1.txt'); expect(file1!.contextRoot).toBe(tempDir); expect(file1!.mtime).toBeGreaterThan(0); }); it('should include files in subdirectories', () => { const files = externalContextScanner.scanPaths([tempDir]); const file3 = files.find((f: ExternalContextFile) => f.name === 'file3.ts'); expect(file3).toBeDefined(); expect(file3!.relativePath).toBe(path.join('subdir', 'file3.ts')); }); it('should skip hidden files', () => { const files = externalContextScanner.scanPaths([tempDir]); const hidden = files.find((f: ExternalContextFile) => f.name === '.hidden'); expect(hidden).toBeUndefined(); }); it('should skip hidden directories', () => { // Create a hidden directory with a file const hiddenDir = path.join(tempDir, '.hidden-dir'); fs.mkdirSync(hiddenDir); fs.writeFileSync(path.join(hiddenDir, 'secret.txt'), 'secret'); const files = externalContextScanner.scanPaths([tempDir]); const secret = files.find((f: ExternalContextFile) => f.name === 'secret.txt'); expect(secret).toBeUndefined(); }); it('should skip node_modules', () => { // Create node_modules with a file const nodeModules = path.join(tempDir, 'node_modules'); fs.mkdirSync(nodeModules); fs.writeFileSync(path.join(nodeModules, 'package.json'), '{}'); const files = externalContextScanner.scanPaths([tempDir]); const pkg = files.find((f: ExternalContextFile) => f.name === 'package.json'); expect(pkg).toBeUndefined(); }); it('should handle non-existent paths', () => { const files = externalContextScanner.scanPaths(['/non/existent/path']); expect(files).toEqual([]); }); it('should handle multiple external context paths', () => { // Create a second temp directory const tempDir2 = fs.mkdtempSync(path.join(os.tmpdir(), 'claudian-test2-')); fs.writeFileSync(path.join(tempDir2, 'file4.js'), 'content4'); try { const files = externalContextScanner.scanPaths([tempDir, tempDir2]); expect(files.length).toBe(4); expect(files.map((f: ExternalContextFile) => f.name).sort()).toEqual([ 'file1.txt', 'file2.md', 'file3.ts', 'file4.js', ]); } finally { fs.rmSync(tempDir2, { recursive: true, force: true }); } }); }); describe('caching', () => { it('should cache results', () => { // First scan const files1 = externalContextScanner.scanPaths([tempDir]); // Add a new file fs.writeFileSync(path.join(tempDir, 'new-file.txt'), 'new content'); // Second scan should use cache const files2 = externalContextScanner.scanPaths([tempDir]); expect(files1.length).toBe(files2.length); expect(files2.find((f: ExternalContextFile) => f.name === 'new-file.txt')).toBeUndefined(); }); it('should respect cache invalidation', () => { // First scan externalContextScanner.scanPaths([tempDir]); // Add a new file fs.writeFileSync(path.join(tempDir, 'new-file.txt'), 'new content'); // Invalidate cache externalContextScanner.invalidateCache(); // Second scan should see new file const files = externalContextScanner.scanPaths([tempDir]); expect(files.find((f: ExternalContextFile) => f.name === 'new-file.txt')).toBeDefined(); }); it('should invalidate specific path', () => { // Create a second temp directory const tempDir2 = fs.mkdtempSync(path.join(os.tmpdir(), 'claudian-test2-')); fs.writeFileSync(path.join(tempDir2, 'file4.js'), 'content4'); try { // First scan both paths externalContextScanner.scanPaths([tempDir, tempDir2]); // Add files to both fs.writeFileSync(path.join(tempDir, 'new1.txt'), 'new1'); fs.writeFileSync(path.join(tempDir2, 'new2.txt'), 'new2'); // Invalidate only first path externalContextScanner.invalidatePath(tempDir); // Second scan const files = externalContextScanner.scanPaths([tempDir, tempDir2]); // Should see new file in first path (invalidated) expect(files.find((f: ExternalContextFile) => f.name === 'new1.txt')).toBeDefined(); // Should NOT see new file in second path (still cached) expect(files.find((f: ExternalContextFile) => f.name === 'new2.txt')).toBeUndefined(); } finally { fs.rmSync(tempDir2, { recursive: true, force: true }); } }); }); describe('home path expansion', () => { it('should expand home paths', () => { // This test uses a real path that should exist const files = externalContextScanner.scanPaths(['~']); // Should not throw and should return some files (or empty if home is empty) expect(Array.isArray(files)).toBe(true); }); }); }); ================================================ FILE: tests/unit/utils/fileLink.dom.test.ts ================================================ /** * @jest-environment jsdom */ import { processFileLinks } from '@/utils/fileLink'; function createMockApp(existingFiles: string[]) { const fileSet = new Set(existingFiles.map(f => f.toLowerCase())); return { metadataCache: { getFirstLinkpathDest: jest.fn((linkPath: string) => { return fileSet.has(linkPath.toLowerCase()) ? { path: linkPath } : null; }), }, vault: { getFileByPath: jest.fn((filePath: string) => { if (fileSet.has(filePath.toLowerCase())) return { path: filePath }; if (!filePath.endsWith('.md') && fileSet.has((filePath + '.md').toLowerCase())) { return { path: filePath + '.md' }; } return null; }), }, } as any; } describe('processFileLinks', () => { describe('null/empty inputs', () => { it('handles null app gracefully', () => { const container = document.createElement('div'); expect(() => processFileLinks(null as any, container)).not.toThrow(); }); it('handles null container gracefully', () => { const app = createMockApp([]); expect(() => processFileLinks(app, null as any)).not.toThrow(); }); it('handles empty container', () => { const app = createMockApp([]); const container = document.createElement('div'); processFileLinks(app, container); expect(container.innerHTML).toBe(''); }); }); describe('text nodes with wikilinks', () => { it('converts valid wikilinks to clickable links', () => { const app = createMockApp(['note.md']); const container = document.createElement('div'); const span = document.createElement('span'); span.textContent = 'See [[note.md]] for info'; container.appendChild(span); processFileLinks(app, container); const link = container.querySelector('a.claudian-file-link'); expect(link).not.toBeNull(); expect(link!.textContent).toBe('note.md'); expect(link!.getAttribute('data-href')).toBe('note.md'); }); it('does not create links for non-existent files', () => { const app = createMockApp([]); const container = document.createElement('div'); const span = document.createElement('span'); span.textContent = 'See [[missing.md]] for info'; container.appendChild(span); processFileLinks(app, container); const link = container.querySelector('a.claudian-file-link'); expect(link).toBeNull(); }); it('preserves text around links', () => { const app = createMockApp(['note.md']); const container = document.createElement('div'); const span = document.createElement('span'); span.textContent = 'Before [[note.md]] after'; container.appendChild(span); processFileLinks(app, container); expect(container.textContent).toBe('Before note.md after'); }); it('handles multiple wikilinks in one text node', () => { const app = createMockApp(['a.md', 'b.md']); const container = document.createElement('div'); const span = document.createElement('span'); span.textContent = '[[a.md]] and [[b.md]]'; container.appendChild(span); processFileLinks(app, container); const links = container.querySelectorAll('a.claudian-file-link'); expect(links.length).toBe(2); }); it('handles display text in wikilinks', () => { const app = createMockApp(['note.md']); const container = document.createElement('div'); const span = document.createElement('span'); span.textContent = 'See [[note.md|My Note]]'; container.appendChild(span); processFileLinks(app, container); const link = container.querySelector('a.claudian-file-link'); expect(link).not.toBeNull(); expect(link!.textContent).toBe('My Note'); }); it('resolves files without .md extension using vault fallback', () => { const app = createMockApp(['note.md']); const container = document.createElement('div'); const span = document.createElement('span'); span.textContent = 'See [[note]] here'; container.appendChild(span); processFileLinks(app, container); const link = container.querySelector('a.claudian-file-link'); expect(link).not.toBeNull(); }); }); describe('inline code wikilinks', () => { it('processes wikilinks inside inline code elements', () => { const app = createMockApp(['note.md']); const container = document.createElement('div'); const code = document.createElement('code'); code.textContent = '[[note.md]]'; container.appendChild(code); processFileLinks(app, container); const link = code.querySelector('a.claudian-file-link'); expect(link).not.toBeNull(); expect(link!.textContent).toBe('note.md'); }); it('skips code inside pre elements', () => { const app = createMockApp(['note.md']); const container = document.createElement('div'); const pre = document.createElement('pre'); const code = document.createElement('code'); code.textContent = '[[note.md]]'; pre.appendChild(code); container.appendChild(pre); processFileLinks(app, container); const link = container.querySelector('a.claudian-file-link'); expect(link).toBeNull(); expect(code.textContent).toBe('[[note.md]]'); }); }); describe('TreeWalker filtering', () => { it('skips text nodes inside pre elements', () => { const app = createMockApp(['note.md']); const container = document.createElement('div'); const pre = document.createElement('pre'); pre.textContent = '[[note.md]]'; container.appendChild(pre); processFileLinks(app, container); const link = container.querySelector('a.claudian-file-link'); expect(link).toBeNull(); }); it('skips text nodes inside a elements', () => { const app = createMockApp(['note.md']); const container = document.createElement('div'); const anchor = document.createElement('a'); anchor.textContent = '[[note.md]]'; container.appendChild(anchor); processFileLinks(app, container); const links = container.querySelectorAll('a.claudian-file-link'); expect(links.length).toBe(0); }); it('skips text nodes inside elements with .claudian-file-link class', () => { const app = createMockApp(['note.md']); const container = document.createElement('div'); const span = document.createElement('span'); span.className = 'claudian-file-link'; span.textContent = '[[note.md]]'; container.appendChild(span); processFileLinks(app, container); // Should not create nested links const links = container.querySelectorAll('a.claudian-file-link'); expect(links.length).toBe(0); }); it('skips text nodes inside elements with .internal-link class', () => { const app = createMockApp(['note.md']); const container = document.createElement('div'); const span = document.createElement('span'); span.className = 'internal-link'; span.textContent = '[[note.md]]'; container.appendChild(span); processFileLinks(app, container); const links = container.querySelectorAll('a.claudian-file-link'); expect(links.length).toBe(0); }); it('processes text nodes in regular elements', () => { const app = createMockApp(['note.md']); const container = document.createElement('div'); const p = document.createElement('p'); p.textContent = '[[note.md]]'; container.appendChild(p); processFileLinks(app, container); const link = container.querySelector('a.claudian-file-link'); expect(link).not.toBeNull(); }); }); describe('image embed exclusion', () => { it('does not convert image embeds to links', () => { const app = createMockApp(['image.png']); const container = document.createElement('div'); const span = document.createElement('span'); span.textContent = '![[image.png]]'; container.appendChild(span); processFileLinks(app, container); const link = container.querySelector('a.claudian-file-link'); expect(link).toBeNull(); }); it('converts file link but not image embed in same text', () => { const app = createMockApp(['note.md', 'image.png']); const container = document.createElement('div'); const span = document.createElement('span'); span.textContent = '[[note.md]] and ![[image.png]]'; container.appendChild(span); processFileLinks(app, container); const links = container.querySelectorAll('a.claudian-file-link'); expect(links.length).toBe(1); expect(links[0].textContent).toBe('note.md'); }); }); }); ================================================ FILE: tests/unit/utils/fileLink.handler.test.ts ================================================ import { registerFileLinkHandler } from '@/utils/fileLink'; describe('registerFileLinkHandler', () => { it('opens data-href target when present', () => { const app = { workspace: { openLinkText: jest.fn(), }, }; const link: any = { dataset: { href: 'note#section' }, getAttribute: jest.fn().mockReturnValue('note'), closest: jest.fn(), }; link.closest.mockReturnValue(link); const event = { target: link, preventDefault: jest.fn(), } as any; const component = { registerDomEvent: (_el: HTMLElement, _event: string, cb: (event: MouseEvent) => void) => { cb(event); }, }; registerFileLinkHandler(app as any, {} as HTMLElement, component as any); expect(event.preventDefault).toHaveBeenCalled(); expect(app.workspace.openLinkText).toHaveBeenCalledWith('note#section', '', 'tab'); }); it('falls back to href when data-href is missing', () => { const app = { workspace: { openLinkText: jest.fn(), }, }; const link: any = { dataset: {}, getAttribute: jest.fn().mockReturnValue('note^block'), closest: jest.fn(), }; link.closest.mockReturnValue(link); const event = { target: link, preventDefault: jest.fn(), } as any; const component = { registerDomEvent: (_el: HTMLElement, _event: string, cb: (event: MouseEvent) => void) => { cb(event); }, }; registerFileLinkHandler(app as any, {} as HTMLElement, component as any); expect(app.workspace.openLinkText).toHaveBeenCalledWith('note^block', '', 'tab'); }); }); ================================================ FILE: tests/unit/utils/fileLink.test.ts ================================================ import { extractLinkTarget } from '@/utils/fileLink'; // Extract the pattern from the module for testing // This matches the pattern in src/utils/fileLink.ts const WIKILINK_PATTERN = /(?<!!)\[\[([^\]|#^]+)(?:#[^\]|]+)?(?:\^[^\]|]+)?(?:\|[^\]]+)?\]\]/g; interface WikilinkMatch { fullMatch: string; linkPath: string; index: number; } function findWikilinks(text: string): WikilinkMatch[] { WIKILINK_PATTERN.lastIndex = 0; const matches: WikilinkMatch[] = []; let match: RegExpExecArray | null; while ((match = WIKILINK_PATTERN.exec(text)) !== null) { matches.push({ fullMatch: match[0], linkPath: match[1], index: match.index, }); } return matches; } function extractDisplayText(fullMatch: string, linkPath: string): string { const pipeIndex = fullMatch.lastIndexOf('|'); return pipeIndex > 0 ? fullMatch.slice(pipeIndex + 1, -2) : linkPath; } describe('wikilink pattern matching', () => { describe('basic wikilinks', () => { it('matches simple wikilink', () => { const matches = findWikilinks('[[note.md]]'); expect(matches).toHaveLength(1); expect(matches[0].linkPath).toBe('note.md'); }); it('matches wikilink without extension', () => { const matches = findWikilinks('[[note]]'); expect(matches).toHaveLength(1); expect(matches[0].linkPath).toBe('note'); }); it('matches wikilink with folder path', () => { const matches = findWikilinks('[[folder/subfolder/note.md]]'); expect(matches).toHaveLength(1); expect(matches[0].linkPath).toBe('folder/subfolder/note.md'); }); it('matches wikilink in surrounding text', () => { const matches = findWikilinks('Check [[note.md]] for info'); expect(matches).toHaveLength(1); expect(matches[0].linkPath).toBe('note.md'); expect(matches[0].index).toBe(6); }); }); describe('wikilinks with display text', () => { it('matches wikilink with pipe alias', () => { const matches = findWikilinks('[[note.md|My Note]]'); expect(matches).toHaveLength(1); expect(matches[0].linkPath).toBe('note.md'); expect(matches[0].fullMatch).toBe('[[note.md|My Note]]'); }); it('extracts display text correctly', () => { const fullMatch = '[[note.md|My Display Text]]'; const displayText = extractDisplayText(fullMatch, 'note.md'); expect(displayText).toBe('My Display Text'); }); it('uses link path when no display text', () => { const fullMatch = '[[note.md]]'; const displayText = extractDisplayText(fullMatch, 'note.md'); expect(displayText).toBe('note.md'); }); }); describe('wikilinks with headings and blocks', () => { it('matches wikilink with heading reference', () => { const matches = findWikilinks('[[note.md#section]]'); expect(matches).toHaveLength(1); expect(matches[0].linkPath).toBe('note.md'); }); it('matches wikilink with block reference', () => { const matches = findWikilinks('[[note.md^blockid]]'); expect(matches).toHaveLength(1); expect(matches[0].linkPath).toBe('note.md'); }); it('matches wikilink with heading and display text', () => { const matches = findWikilinks('[[note.md#section|Section Link]]'); expect(matches).toHaveLength(1); expect(matches[0].linkPath).toBe('note.md'); }); }); describe('multiple wikilinks', () => { it('matches multiple wikilinks in text', () => { const matches = findWikilinks('See [[note1.md]] and [[note2.md]]'); expect(matches).toHaveLength(2); expect(matches[0].linkPath).toBe('note1.md'); expect(matches[1].linkPath).toBe('note2.md'); }); it('matches consecutive wikilinks', () => { const matches = findWikilinks('[[a.md]][[b.md]]'); expect(matches).toHaveLength(2); }); it('captures correct indices for multiple matches', () => { const text = '[[first.md]] middle [[second.md]]'; const matches = findWikilinks(text); expect(matches[0].index).toBe(0); expect(matches[1].index).toBe(20); }); }); describe('image embeds (should NOT match)', () => { it('does not match image embed', () => { const matches = findWikilinks('![[image.png]]'); expect(matches).toHaveLength(0); }); it('does not match image embed with alt text', () => { const matches = findWikilinks('![[image.png|alt text]]'); expect(matches).toHaveLength(0); }); it('matches file link but not image embed', () => { const matches = findWikilinks('[[note.md]] and ![[image.png]]'); expect(matches).toHaveLength(1); expect(matches[0].linkPath).toBe('note.md'); }); it('handles mixed file links and image embeds', () => { const text = '![[img1.png]] [[file.md]] ![[img2.png]] [[other.md]]'; const matches = findWikilinks(text); expect(matches).toHaveLength(2); expect(matches[0].linkPath).toBe('file.md'); expect(matches[1].linkPath).toBe('other.md'); }); }); describe('edge cases', () => { it('handles empty text', () => { const matches = findWikilinks(''); expect(matches).toHaveLength(0); }); it('handles text without wikilinks', () => { const matches = findWikilinks('Just plain text here'); expect(matches).toHaveLength(0); }); it('handles incomplete wikilink syntax', () => { const matches = findWikilinks('[[incomplete'); expect(matches).toHaveLength(0); }); it('matches wikilink at start of text', () => { const matches = findWikilinks('[[note.md]] is first'); expect(matches).toHaveLength(1); expect(matches[0].index).toBe(0); }); it('matches wikilink at end of text', () => { const matches = findWikilinks('last is [[note.md]]'); expect(matches).toHaveLength(1); }); it('handles special characters in path', () => { const matches = findWikilinks('[[folder/my note (2024).md]]'); expect(matches).toHaveLength(1); expect(matches[0].linkPath).toBe('folder/my note (2024).md'); }); it('handles spaces in filename', () => { const matches = findWikilinks('[[my long filename.md]]'); expect(matches).toHaveLength(1); expect(matches[0].linkPath).toBe('my long filename.md'); }); it('handles deep folder paths', () => { const matches = findWikilinks('[[a/b/c/d/e/note.md]]'); expect(matches).toHaveLength(1); expect(matches[0].linkPath).toBe('a/b/c/d/e/note.md'); }); }); describe('real-world examples', () => { it('matches typical vault path from screenshot', () => { const text = 'Found in [[30.areas/a.finance/Investment lessons/2024.Current trading lessons.md]]'; const matches = findWikilinks(text); expect(matches).toHaveLength(1); expect(matches[0].linkPath).toBe('30.areas/a.finance/Investment lessons/2024.Current trading lessons.md'); }); it('matches multiple paths in a list', () => { const text = ` 1. [[30.areas/finance/note1.md]] - First 2. [[30.areas/finance/note2.md]] - Second `; const matches = findWikilinks(text); expect(matches).toHaveLength(2); }); it('handles markdown formatting around links', () => { const text = 'Check **[[important.md]]** for *[[details.md]]*'; const matches = findWikilinks(text); expect(matches).toHaveLength(2); }); }); describe('wikilink target extraction', () => { it('keeps heading references in target', () => { expect(extractLinkTarget('[[note#section]]')).toBe('note#section'); }); it('keeps block references in target', () => { expect(extractLinkTarget('[[note^block]]')).toBe('note^block'); }); it('drops display text while preserving anchors', () => { expect(extractLinkTarget('[[note#section|Alias]]')).toBe('note#section'); }); }); }); ================================================ FILE: tests/unit/utils/frontmatter.test.ts ================================================ import * as obsidian from 'obsidian'; import { extractBoolean, extractString, extractStringArray, normalizeStringArray, parseFrontmatter, } from '@/utils/frontmatter'; describe('parseFrontmatter', () => { it('parses valid frontmatter with body', () => { const content = `--- title: Hello --- Body text here`; const result = parseFrontmatter(content); expect(result).not.toBeNull(); expect(result!.frontmatter).toEqual({ title: 'Hello' }); expect(result!.body).toBe('Body text here'); }); it('returns null for content without frontmatter', () => { expect(parseFrontmatter('Just text')).toBeNull(); }); it('handles empty body', () => { const content = `--- key: value --- `; const result = parseFrontmatter(content); expect(result).not.toBeNull(); expect(result!.frontmatter).toEqual({ key: 'value' }); expect(result!.body).toBe(''); }); it('returns result with empty frontmatter for unrecognized YAML content', () => { const content = `--- : invalid yaml [{{ --- Body`; // Mock parseYaml doesn't throw — returns empty object for unrecognized content. // In production, Obsidian's parseYaml may throw, which parseFrontmatter catches. const result = parseFrontmatter(content); expect(result).not.toBeNull(); expect(result!.frontmatter).toEqual({}); expect(result!.body).toBe('Body'); }); it('handles CRLF line endings', () => { const content = '---\r\nkey: value\r\n---\r\nBody'; const result = parseFrontmatter(content); expect(result).not.toBeNull(); expect(result!.frontmatter).toEqual({ key: 'value' }); expect(result!.body).toBe('Body'); }); it('handles empty frontmatter block', () => { const content = `--- --- Body`; const result = parseFrontmatter(content); expect(result).not.toBeNull(); // parseYaml returns null for empty string expect(result!.body).toBe('Body'); }); it('parses complex YAML values', () => { const content = `--- description: "Value with: colon" tools: - Read - Grep model: sonnet enabled: true count: 5 --- Prompt`; const result = parseFrontmatter(content); expect(result).not.toBeNull(); expect(result!.frontmatter.description).toBe('Value with: colon'); expect(result!.frontmatter.tools).toEqual(['Read', 'Grep']); expect(result!.frontmatter.model).toBe('sonnet'); expect(result!.frontmatter.enabled).toBe(true); expect(result!.frontmatter.count).toBe(5); expect(result!.body).toBe('Prompt'); }); it('falls back to lenient parsing when YAML has unquoted colons', () => { // This mimics pr-review-toolkit agents with unquoted descriptions containing colons const content = `--- name: code-reviewer description: Use this agent when reviewing. Examples: Context: The user said something. user: hello model: opus --- You are a code reviewer.`; const result = parseFrontmatter(content); expect(result).not.toBeNull(); expect(result!.frontmatter.name).toBe('code-reviewer'); // Fallback parser takes first colon-space as separator, so description includes the rest expect(result!.frontmatter.description).toContain('Use this agent'); expect(result!.frontmatter.model).toBe('opus'); expect(result!.body).toBe('You are a code reviewer.'); }); it('fallback parser handles inline arrays', () => { const content = `--- name: test-agent description: A test agent tools: [Read, Grep, Glob] --- Body`; const result = parseFrontmatter(content); expect(result).not.toBeNull(); expect(result!.frontmatter.name).toBe('test-agent'); expect(result!.frontmatter.tools).toEqual(['Read', 'Grep', 'Glob']); }); }); describe('extractString', () => { it('extracts string value', () => { expect(extractString({ key: 'hello' }, 'key')).toBe('hello'); }); it('returns undefined for missing key', () => { expect(extractString({}, 'key')).toBeUndefined(); }); it('returns undefined for non-string value', () => { expect(extractString({ key: 123 }, 'key')).toBeUndefined(); }); it('returns undefined for empty string', () => { expect(extractString({ key: '' }, 'key')).toBeUndefined(); }); }); describe('extractStringArray', () => { it('extracts YAML array', () => { expect(extractStringArray({ tools: ['Read', 'Grep'] }, 'tools')) .toEqual(['Read', 'Grep']); }); it('splits comma-separated string', () => { expect(extractStringArray({ tools: 'Read, Grep, Glob' }, 'tools')) .toEqual(['Read', 'Grep', 'Glob']); }); it('wraps single string in array', () => { expect(extractStringArray({ tools: 'Read' }, 'tools')) .toEqual(['Read']); }); it('returns undefined for missing key', () => { expect(extractStringArray({}, 'tools')).toBeUndefined(); }); it('returns undefined for non-string/array value', () => { expect(extractStringArray({ tools: 123 }, 'tools')).toBeUndefined(); }); it('filters empty entries from comma-separated string', () => { expect(extractStringArray({ tools: 'Read,,Grep,' }, 'tools')) .toEqual(['Read', 'Grep']); }); it('converts non-string array elements to strings', () => { expect(extractStringArray({ tools: [123, 'Read'] }, 'tools')) .toEqual(['123', 'Read']); }); }); describe('normalizeStringArray', () => { it('returns undefined for undefined', () => { expect(normalizeStringArray(undefined)).toBeUndefined(); }); it('returns undefined for null', () => { expect(normalizeStringArray(null)).toBeUndefined(); }); it('normalizes array of strings', () => { expect(normalizeStringArray(['Read', 'Grep'])).toEqual(['Read', 'Grep']); }); it('trims and filters array elements', () => { expect(normalizeStringArray([' Read ', '', ' Grep ', ''])).toEqual(['Read', 'Grep']); }); it('converts non-string array elements to strings', () => { expect(normalizeStringArray([123, 'Read'])).toEqual(['123', 'Read']); }); it('splits comma-separated string', () => { expect(normalizeStringArray('Read, Grep, Glob')).toEqual(['Read', 'Grep', 'Glob']); }); it('wraps single string in array', () => { expect(normalizeStringArray('Read')).toEqual(['Read']); }); it('returns undefined for empty string', () => { expect(normalizeStringArray('')).toBeUndefined(); }); it('returns undefined for whitespace-only string', () => { expect(normalizeStringArray(' ')).toBeUndefined(); }); it('filters empty entries from comma-separated string', () => { expect(normalizeStringArray('Read,,Grep,')).toEqual(['Read', 'Grep']); }); it('returns undefined for non-string/array types', () => { expect(normalizeStringArray(123)).toBeUndefined(); expect(normalizeStringArray(true)).toBeUndefined(); }); }); describe('extractBoolean', () => { it('extracts true', () => { expect(extractBoolean({ flag: true }, 'flag')).toBe(true); }); it('extracts false', () => { expect(extractBoolean({ flag: false }, 'flag')).toBe(false); }); it('returns undefined for missing key', () => { expect(extractBoolean({}, 'flag')).toBeUndefined(); }); it('returns undefined for non-boolean', () => { expect(extractBoolean({ flag: 'yes' }, 'flag')).toBeUndefined(); }); }); describe('parseFrontmatter non-object return', () => { it('returns null when parseYaml returns a string', () => { jest.spyOn(obsidian, 'parseYaml').mockReturnValueOnce('just a string' as any); const content = `--- just a string --- Body`; expect(parseFrontmatter(content)).toBeNull(); }); it('returns null when parseYaml returns a number', () => { jest.spyOn(obsidian, 'parseYaml').mockReturnValueOnce(42 as any); const content = `--- 42 --- Body`; expect(parseFrontmatter(content)).toBeNull(); }); }); describe('parseFrontmatter fallback parser', () => { beforeEach(() => { jest.spyOn(obsidian, 'parseYaml').mockImplementation(() => { throw new Error('YAML parse error'); }); }); afterEach(() => { jest.restoreAllMocks(); }); it('falls back to line-by-line parsing when parseYaml throws', () => { const content = `--- name: test model: opus --- Body`; const result = parseFrontmatter(content); expect(result).not.toBeNull(); expect(result!.frontmatter.name).toBe('test'); expect(result!.frontmatter.model).toBe('opus'); expect(result!.body).toBe('Body'); }); it('fallback parser handles boolean coercion', () => { const content = `--- enabled: true disabled: false --- Body`; const result = parseFrontmatter(content); expect(result).not.toBeNull(); expect(result!.frontmatter.enabled).toBe(true); expect(result!.frontmatter.disabled).toBe(false); }); it('fallback parser handles null and empty values', () => { const content = `--- empty: null blank: --- Body`; const result = parseFrontmatter(content); expect(result).not.toBeNull(); expect(result!.frontmatter.empty).toBeNull(); // Trailing colon with no `: ` separator sets value to empty string expect(result!.frontmatter.blank).toBe(''); }); it('fallback parser handles number coercion', () => { const content = `--- count: 42 ratio: 3.14 --- Body`; const result = parseFrontmatter(content); expect(result).not.toBeNull(); expect(result!.frontmatter.count).toBe(42); expect(result!.frontmatter.ratio).toBe(3.14); }); it('fallback parser handles inline arrays', () => { const content = `--- tools: [Read, Grep, Glob] --- Body`; const result = parseFrontmatter(content); expect(result).not.toBeNull(); expect(result!.frontmatter.tools).toEqual(['Read', 'Grep', 'Glob']); }); it('fallback parser handles multi-line arrays', () => { const content = `--- description: Use this agent when reviewing. Examples: Context: The user said something. user: hello allowed-tools: - Read - Grep --- Body`; const result = parseFrontmatter(content); expect(result).not.toBeNull(); expect(result!.frontmatter.description).toContain('Use this agent'); expect(result!.frontmatter['allowed-tools']).toEqual(['Read', 'Grep']); }); it('fallback parser unquotes quoted scalars', () => { const content = `--- description: "Value with: colon" argument-hint: '[file] [focus]' --- Body`; const result = parseFrontmatter(content); expect(result).not.toBeNull(); expect(result!.frontmatter.description).toBe('Value with: colon'); expect(result!.frontmatter['argument-hint']).toBe('[file] [focus]'); }); it('fallback parser skips comment lines', () => { const content = `--- # This is a comment name: test --- Body`; const result = parseFrontmatter(content); expect(result).not.toBeNull(); expect(result!.frontmatter).not.toHaveProperty('#'); expect(result!.frontmatter.name).toBe('test'); }); it('fallback parser skips empty lines', () => { const content = `--- name: test model: opus --- Body`; const result = parseFrontmatter(content); expect(result).not.toBeNull(); expect(result!.frontmatter.name).toBe('test'); expect(result!.frontmatter.model).toBe('opus'); }); it('fallback parser handles keys with trailing colon only', () => { const content = `--- emptykey: name: test --- Body`; const result = parseFrontmatter(content); expect(result).not.toBeNull(); expect(result!.frontmatter.emptykey).toBe(''); expect(result!.frontmatter.name).toBe('test'); }); it('fallback parser rejects invalid key names', () => { const content = `--- valid-key: value invalid key: value 123start: value --- Body`; const result = parseFrontmatter(content); expect(result).not.toBeNull(); expect(result!.frontmatter['valid-key']).toBe('value'); expect(result!.frontmatter['123start']).toBe('value'); expect(result!.frontmatter).not.toHaveProperty('invalid key'); }); it('fallback parser handles string values with colons', () => { const content = `--- description: Use this: for reviewing --- Body`; const result = parseFrontmatter(content); expect(result).not.toBeNull(); expect(result!.frontmatter.description).toBe('Use this: for reviewing'); }); it('returns null when fallback parser finds no valid keys', () => { const content = `--- invalid key with spaces: value another bad key!: value --- Body`; const result = parseFrontmatter(content); expect(result).toBeNull(); }); }); ================================================ FILE: tests/unit/utils/imageEmbed.test.ts ================================================ import type { App, TFile } from 'obsidian'; import { replaceImageEmbedsWithHtml } from '@/utils/imageEmbed'; // Mock App and vault for testing function createMockApp(files: Map<string, string> = new Map()): App { const mockFiles = new Map<string, TFile>(); files.forEach((resourcePath, filePath) => { mockFiles.set(filePath, { path: filePath, basename: filePath.split('/').pop()?.replace(/\.[^.]+$/, '') || filePath, } as TFile); }); return { vault: { getFileByPath: (path: string) => mockFiles.get(path) || null, getResourcePath: (file: TFile) => files.get(file.path) || `app://local/${file.path}`, }, metadataCache: { getFirstLinkpathDest: (linkPath: string) => { // Try to find by basename for (const [path, file] of mockFiles) { if (path.endsWith(linkPath) || path === linkPath) { return file; } } return null; }, }, } as unknown as App; } describe('replaceImageEmbedsWithHtml', () => { describe('basic image embeds', () => { it('replaces simple image embed with img tag', () => { const app = createMockApp(new Map([['image.png', 'app://local/image.png']])); const result = replaceImageEmbedsWithHtml('![[image.png]]', app); expect(result).toContain('<img'); expect(result).toContain('src="app://local/image.png"'); expect(result).toContain('class="claudian-embedded-image"'); }); it('replaces image embed with folder path', () => { const app = createMockApp(new Map([['assets/photo.jpg', 'app://local/assets/photo.jpg']])); const result = replaceImageEmbedsWithHtml('![[assets/photo.jpg]]', app); expect(result).toContain('src="app://local/assets/photo.jpg"'); }); it('handles image in surrounding text', () => { const app = createMockApp(new Map([['test.png', 'app://local/test.png']])); const result = replaceImageEmbedsWithHtml('Check this ![[test.png]] image', app); expect(result).toContain('Check this'); expect(result).toContain('image'); expect(result).toContain('<img'); }); }); describe('alt text and dimensions', () => { it('uses alt text from wikilink', () => { const app = createMockApp(new Map([['image.png', 'app://local/image.png']])); const result = replaceImageEmbedsWithHtml('![[image.png|My Alt Text]]', app); expect(result).toContain('alt="My Alt Text"'); }); it('applies width dimension from alt text', () => { const app = createMockApp(new Map([['image.png', 'app://local/image.png']])); const result = replaceImageEmbedsWithHtml('![[image.png|300]]', app); expect(result).toContain('style="width: 300px;"'); }); it('applies width and height dimensions', () => { const app = createMockApp(new Map([['image.png', 'app://local/image.png']])); const result = replaceImageEmbedsWithHtml('![[image.png|200x150]]', app); expect(result).toContain('style="width: 200px; height: 150px;"'); }); it('uses basename as alt when no alt text provided', () => { const app = createMockApp(new Map([['folder/my-image.png', 'app://local/folder/my-image.png']])); const result = replaceImageEmbedsWithHtml('![[folder/my-image.png]]', app); expect(result).toContain('alt="my-image"'); }); }); describe('supported image extensions', () => { const extensions = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico']; extensions.forEach((ext) => { it(`replaces .${ext} image embed`, () => { const app = createMockApp(new Map([[`image.${ext}`, `app://local/image.${ext}`]])); const result = replaceImageEmbedsWithHtml(`![[image.${ext}]]`, app); expect(result).toContain('<img'); expect(result).toContain(`src="app://local/image.${ext}"`); }); }); it('handles uppercase extensions (case-insensitive)', () => { const app = createMockApp(new Map([['photo.PNG', 'app://local/photo.PNG']])); const result = replaceImageEmbedsWithHtml('![[photo.PNG]]', app); expect(result).toContain('<img'); }); it('handles mixed case extensions', () => { const app = createMockApp(new Map([['image.JpG', 'app://local/image.JpG']])); const result = replaceImageEmbedsWithHtml('![[image.JpG]]', app); expect(result).toContain('<img'); }); }); describe('non-image embeds (should pass through)', () => { it('leaves markdown file embed unchanged', () => { const app = createMockApp(); const result = replaceImageEmbedsWithHtml('![[note.md]]', app); expect(result).toBe('![[note.md]]'); }); it('leaves pdf embed unchanged', () => { const app = createMockApp(); const result = replaceImageEmbedsWithHtml('![[document.pdf]]', app); expect(result).toBe('![[document.pdf]]'); }); it('leaves audio embed unchanged', () => { const app = createMockApp(); const result = replaceImageEmbedsWithHtml('![[audio.mp3]]', app); expect(result).toBe('![[audio.mp3]]'); }); it('processes only image embeds in mixed content', () => { const app = createMockApp(new Map([['image.png', 'app://local/image.png']])); const result = replaceImageEmbedsWithHtml('![[note.md]] and ![[image.png]]', app); expect(result).toContain('![[note.md]]'); expect(result).toContain('<img'); }); }); describe('file not found (fallback)', () => { it('shows fallback when image file not found', () => { const app = createMockApp(); // Empty vault const result = replaceImageEmbedsWithHtml('![[missing.png]]', app); expect(result).toContain('class="claudian-embedded-image-fallback"'); expect(result).toContain('![[missing.png]]'); }); it('escapes HTML in fallback wikilink', () => { const app = createMockApp(); const result = replaceImageEmbedsWithHtml('![[<script>alert(1)</script>.png]]', app); expect(result).not.toContain('<script>'); expect(result).toContain('<script>'); }); }); describe('media folder resolution', () => { it('resolves image from media folder', () => { const app = createMockApp(new Map([['attachments/photo.png', 'app://local/attachments/photo.png']])); const result = replaceImageEmbedsWithHtml('![[photo.png]]', app, 'attachments'); expect(result).toContain('src="app://local/attachments/photo.png"'); }); it('prefers direct path over media folder', () => { const app = createMockApp( new Map([ ['image.png', 'app://local/image.png'], ['attachments/image.png', 'app://local/attachments/image.png'], ]) ); const result = replaceImageEmbedsWithHtml('![[image.png]]', app, 'attachments'); expect(result).toContain('src="app://local/image.png"'); }); }); describe('multiple image embeds', () => { it('replaces multiple images in text', () => { const app = createMockApp( new Map([ ['a.png', 'app://local/a.png'], ['b.png', 'app://local/b.png'], ]) ); const result = replaceImageEmbedsWithHtml('![[a.png]] and ![[b.png]]', app); expect(result).toContain('src="app://local/a.png"'); expect(result).toContain('src="app://local/b.png"'); }); it('replaces consecutive image embeds', () => { const app = createMockApp( new Map([ ['1.png', 'app://local/1.png'], ['2.png', 'app://local/2.png'], ]) ); const result = replaceImageEmbedsWithHtml('![[1.png]]![[2.png]]', app); expect((result.match(/<img/g) || []).length).toBe(2); }); }); describe('HTML escaping (security)', () => { it('escapes HTML in image src', () => { const app = createMockApp(new Map([['test.png', 'app://local/path"><script>alert(1)</script>']])); const result = replaceImageEmbedsWithHtml('![[test.png]]', app); expect(result).not.toContain('<script>'); expect(result).toContain('<script>'); }); it('escapes HTML in alt text', () => { const app = createMockApp(new Map([['test.png', 'app://local/test.png']])); const result = replaceImageEmbedsWithHtml('![[test.png|<b>bold</b>]]', app); expect(result).not.toContain('<b>'); expect(result).toContain('<b>'); }); }); describe('edge cases', () => { it('handles empty string', () => { const app = createMockApp(); const result = replaceImageEmbedsWithHtml('', app); expect(result).toBe(''); }); it('handles text without embeds', () => { const app = createMockApp(); const result = replaceImageEmbedsWithHtml('Just plain text', app); expect(result).toBe('Just plain text'); }); it('handles incomplete embed syntax', () => { const app = createMockApp(); const result = replaceImageEmbedsWithHtml('![[incomplete', app); expect(result).toBe('![[incomplete'); }); it('handles multiple sequential calls (regex lastIndex reset)', () => { const app = createMockApp(new Map([['a.png', 'app://local/a.png']])); // First call const result1 = replaceImageEmbedsWithHtml('![[a.png]]', app); // Second call - would fail without lastIndex reset const result2 = replaceImageEmbedsWithHtml('![[a.png]]', app); expect(result1).toContain('<img'); expect(result2).toContain('<img'); }); it('replaces image embeds in multiline content', () => { const app = createMockApp(new Map([['test.png', 'app://local/test.png']])); const result = replaceImageEmbedsWithHtml( 'First paragraph\n\n![[test.png]]\n\nThird paragraph', app ); expect(result).toContain('<img'); expect(result).toContain('First paragraph'); expect(result).toContain('Third paragraph'); }); it('handles special characters in filename', () => { const filename = 'photo (2024-01-01).png'; const app = createMockApp(new Map([[filename, `app://local/${filename}`]])); const result = replaceImageEmbedsWithHtml(`![[${filename}]]`, app); expect(result).toContain('<img'); }); it('handles spaces in filename', () => { const filename = 'my long image name.png'; const app = createMockApp(new Map([[filename, `app://local/${filename}`]])); const result = replaceImageEmbedsWithHtml(`![[${filename}]]`, app); expect(result).toContain('<img'); }); it('handles deep folder paths', () => { const path = 'a/b/c/d/image.png'; const app = createMockApp(new Map([[path, `app://local/${path}`]])); const result = replaceImageEmbedsWithHtml(`![[${path}]]`, app); expect(result).toContain('<img'); }); it('includes lazy loading attribute', () => { const app = createMockApp(new Map([['test.png', 'app://local/test.png']])); const result = replaceImageEmbedsWithHtml('![[test.png]]', app); expect(result).toContain('loading="lazy"'); }); }); describe('error handling', () => { it('returns unchanged markdown when app is not initialized', () => { // @ts-expect-error - testing invalid input const result = replaceImageEmbedsWithHtml('![[image.png]]', null); expect(result).toBe('![[image.png]]'); }); it('returns unchanged markdown when vault is missing', () => { const app = { metadataCache: {} } as unknown as App; const result = replaceImageEmbedsWithHtml('![[image.png]]', app); expect(result).toBe('![[image.png]]'); }); it('returns unchanged markdown when metadataCache is missing', () => { const app = { vault: {} } as unknown as App; const result = replaceImageEmbedsWithHtml('![[image.png]]', app); expect(result).toBe('![[image.png]]'); }); it('returns fallback when getResourcePath throws', () => { const mockFile = { path: 'test.png', basename: 'test' } as TFile; const app = { vault: { getFileByPath: () => mockFile, getResourcePath: () => { throw new Error('Resource path failed'); }, }, metadataCache: { getFirstLinkpathDest: () => null, }, } as unknown as App; const result = replaceImageEmbedsWithHtml('![[test.png]]', app); expect(result).toContain('class="claudian-embedded-image-fallback"'); }); }); describe('regular wikilinks (should NOT match)', () => { it('does not replace regular wikilink', () => { const app = createMockApp(); const result = replaceImageEmbedsWithHtml('[[note.md]]', app); expect(result).toBe('[[note.md]]'); expect(result).not.toContain('<img'); }); it('processes image embed but not file link', () => { const app = createMockApp(new Map([['image.png', 'app://local/image.png']])); const result = replaceImageEmbedsWithHtml('[[note.md]] and ![[image.png]]', app); expect(result).toContain('[[note.md]]'); expect(result).toContain('<img'); }); }); }); ================================================ FILE: tests/unit/utils/inlineEdit.test.ts ================================================ import { escapeHtml, normalizeInsertionText } from '@/utils/inlineEdit'; describe('normalizeInsertionText', () => { it('removes leading blank lines', () => { expect(normalizeInsertionText('\n\nHello')).toBe('Hello'); }); it('removes trailing blank lines', () => { expect(normalizeInsertionText('Hello\n\n')).toBe('Hello'); }); it('removes both leading and trailing blank lines', () => { expect(normalizeInsertionText('\n\nHello\n\n')).toBe('Hello'); }); it('handles \\r\\n line endings', () => { expect(normalizeInsertionText('\r\n\r\nHello\r\n\r\n')).toBe('Hello'); }); it('preserves internal newlines', () => { expect(normalizeInsertionText('\nLine 1\nLine 2\n')).toBe('Line 1\nLine 2'); }); it('returns empty string unchanged', () => { expect(normalizeInsertionText('')).toBe(''); }); it('returns text unchanged when no leading/trailing newlines', () => { expect(normalizeInsertionText('Hello World')).toBe('Hello World'); }); }); describe('escapeHtml', () => { it('escapes < to <', () => { expect(escapeHtml('<div>')).toBe('<div>'); }); it('escapes > to >', () => { expect(escapeHtml('a > b')).toBe('a > b'); }); it('escapes & to &', () => { expect(escapeHtml('a & b')).toBe('a & b'); }); it('escapes " to "', () => { expect(escapeHtml('a "b" c')).toBe('a "b" c'); }); it('escapes all special characters together', () => { expect(escapeHtml('<script>alert("x&y")</script>')).toBe( '<script>alert("x&y")</script>' ); }); it('returns text unchanged when no special characters', () => { expect(escapeHtml('Hello World')).toBe('Hello World'); }); it('handles empty string', () => { expect(escapeHtml('')).toBe(''); }); }); ================================================ FILE: tests/unit/utils/interrupt.test.ts ================================================ import { isBracketInterruptText, isCompactionCanceledStderr, isInterruptSignalText, } from '@/utils/interrupt'; describe('interrupt utils', () => { describe('isBracketInterruptText', () => { it('matches canonical SDK interrupt markers', () => { expect(isBracketInterruptText('[Request interrupted by user]')).toBe(true); expect(isBracketInterruptText('[Request interrupted by user for tool use]')).toBe(true); }); it('matches canonical markers with surrounding whitespace', () => { expect(isBracketInterruptText(' [Request interrupted by user] ')).toBe(true); expect(isBracketInterruptText('\n[Request interrupted by user for tool use]\n')).toBe(true); }); it('rejects partial and prefixed variants', () => { expect(isBracketInterruptText('[Request interrupted by user] extra')).toBe(false); expect(isBracketInterruptText('prefix [Request interrupted by user]')).toBe(false); expect(isBracketInterruptText('[Request interrupted]')).toBe(false); }); }); describe('isCompactionCanceledStderr', () => { it('matches canonical compaction stderr marker', () => { expect( isCompactionCanceledStderr( '<local-command-stderr>Error: Compaction canceled.</local-command-stderr>', ), ).toBe(true); }); it('accepts whitespace around canonical compaction stderr marker', () => { expect( isCompactionCanceledStderr( '\n<local-command-stderr> Error: Compaction canceled. </local-command-stderr>\n', ), ).toBe(true); }); it('rejects embedded mentions and non-canonical wrappers', () => { expect( isCompactionCanceledStderr( '## Context\\n<local-command-stderr>Error: Compaction canceled.</local-command-stderr>', ), ).toBe(false); expect( isCompactionCanceledStderr( '<task-notification><result><local-command-stderr>Error: Compaction canceled.</local-command-stderr></result></task-notification>', ), ).toBe(false); }); }); describe('isInterruptSignalText', () => { it('matches all supported interrupt markers', () => { expect(isInterruptSignalText('[Request interrupted by user]')).toBe(true); expect(isInterruptSignalText('[Request interrupted by user for tool use]')).toBe(true); expect( isInterruptSignalText( '<local-command-stderr>Error: Compaction canceled.</local-command-stderr>', ), ).toBe(true); }); it('rejects regular content', () => { expect(isInterruptSignalText('Hello')).toBe(false); expect(isInterruptSignalText('<local-command-stderr>Error: Timeout.</local-command-stderr>')).toBe(false); }); }); }); ================================================ FILE: tests/unit/utils/markdown.test.ts ================================================ import { appendMarkdownSnippet } from '@/utils/markdown'; describe('appendMarkdownSnippet', () => { it('returns existing prompt when snippet is empty', () => { expect(appendMarkdownSnippet('Hello', '')).toBe('Hello'); }); it('returns existing prompt when snippet is whitespace only', () => { expect(appendMarkdownSnippet('Hello', ' \n ')).toBe('Hello'); }); it('returns trimmed snippet when existing prompt is empty', () => { expect(appendMarkdownSnippet('', ' Hello ')).toBe('Hello'); }); it('returns trimmed snippet when existing prompt is whitespace only', () => { expect(appendMarkdownSnippet(' ', ' Hello ')).toBe('Hello'); }); it('adds double newline separator when prompt does not end with newline', () => { expect(appendMarkdownSnippet('First', 'Second')).toBe('First\n\nSecond'); }); it('adds single newline when prompt ends with one newline', () => { expect(appendMarkdownSnippet('First\n', 'Second')).toBe('First\n\nSecond'); }); it('adds no separator when prompt ends with double newline', () => { expect(appendMarkdownSnippet('First\n\n', 'Second')).toBe('First\n\nSecond'); }); it('trims the snippet before appending', () => { expect(appendMarkdownSnippet('First', ' Second ')).toBe('First\n\nSecond'); }); }); ================================================ FILE: tests/unit/utils/mcp.test.ts ================================================ import { extractMcpMentions, parseCommand, splitCommandString, transformMcpMentions } from '@/utils/mcp'; describe('extractMcpMentions', () => { it('extracts valid MCP mentions', () => { const validNames = new Set(['context7', 'server1']); const text = 'Check @context7 and @server1 for info'; const result = extractMcpMentions(text, validNames); expect(result).toEqual(new Set(['context7', 'server1'])); }); it('ignores invalid mentions', () => { const validNames = new Set(['context7']); const text = 'Check @unknown for info'; const result = extractMcpMentions(text, validNames); expect(result).toEqual(new Set()); }); it('ignores context folder mentions (with /)', () => { const validNames = new Set(['folder']); const text = 'Check @folder/ for files'; const result = extractMcpMentions(text, validNames); expect(result).toEqual(new Set()); }); }); describe('transformMcpMentions', () => { const validNames = new Set(['context7', 'server1']); it('appends MCP to valid mentions', () => { const text = 'Check @context7 for info'; const result = transformMcpMentions(text, validNames); expect(result).toBe('Check @context7 MCP for info'); }); it('transforms multiple mentions', () => { const text = '@context7 and @server1'; const result = transformMcpMentions(text, validNames); expect(result).toBe('@context7 MCP and @server1 MCP'); }); it('transforms duplicate mentions', () => { const text = '@context7 then @context7 again'; const result = transformMcpMentions(text, validNames); expect(result).toBe('@context7 MCP then @context7 MCP again'); }); it('does not double-transform if already has MCP', () => { const text = '@context7 MCP for info'; const result = transformMcpMentions(text, validNames); expect(result).toBe('@context7 MCP for info'); }); it('does not transform context folder mentions', () => { const names = new Set(['folder']); const text = '@folder/ for files'; const result = transformMcpMentions(text, names); expect(result).toBe('@folder/ for files'); }); it('does not transform partial matches', () => { const text = '@context7abc is different'; const result = transformMcpMentions(text, validNames); expect(result).toBe('@context7abc is different'); }); it('handles overlapping names correctly (longer first)', () => { const names = new Set(['context', 'context7']); const text = '@context7 and @context'; const result = transformMcpMentions(text, names); expect(result).toBe('@context7 MCP and @context MCP'); }); it('transforms mention at end of text', () => { const text = 'Check @context7'; const result = transformMcpMentions(text, validNames); expect(result).toBe('Check @context7 MCP'); }); it('transforms mention at start of text', () => { const text = '@context7 is useful'; const result = transformMcpMentions(text, validNames); expect(result).toBe('@context7 MCP is useful'); }); it('returns unchanged text when no valid names', () => { const text = '@context7 for info'; const result = transformMcpMentions(text, new Set()); expect(result).toBe('@context7 for info'); }); it('handles special regex characters in server name', () => { const names = new Set(['test.server']); const text = '@test.server for info'; const result = transformMcpMentions(text, names); expect(result).toBe('@test.server MCP for info'); }); // Punctuation edge cases it('transforms mention followed by period', () => { const text = 'Check @context7.'; const result = transformMcpMentions(text, validNames); expect(result).toBe('Check @context7 MCP.'); }); it('transforms mention followed by comma', () => { const text = '@context7, please check'; const result = transformMcpMentions(text, validNames); expect(result).toBe('@context7 MCP, please check'); }); it('transforms mention followed by colon', () => { const text = '@context7: check this'; const result = transformMcpMentions(text, validNames); expect(result).toBe('@context7 MCP: check this'); }); it('transforms mention followed by question mark', () => { const text = 'Did you check @context7?'; const result = transformMcpMentions(text, validNames); expect(result).toBe('Did you check @context7 MCP?'); }); it('does not transform partial match with dot-suffix', () => { // @test should NOT match in @test.foo when only "test" is valid const names = new Set(['test']); const text = '@test.foo is unknown'; const result = transformMcpMentions(text, names); expect(result).toBe('@test.foo is unknown'); }); it('transforms server with dot in name followed by period', () => { const names = new Set(['test.server']); const text = 'Check @test.server.'; const result = transformMcpMentions(text, names); expect(result).toBe('Check @test.server MCP.'); }); // Multiline it('transforms mentions across multiple lines', () => { const text = 'First @context7\nSecond @server1'; const result = transformMcpMentions(text, validNames); expect(result).toBe('First @context7 MCP\nSecond @server1 MCP'); }); it('transforms mention followed by newline', () => { const text = '@context7\nmore text'; const result = transformMcpMentions(text, validNames); expect(result).toBe('@context7 MCP\nmore text'); }); // Empty input it('handles empty input text', () => { const result = transformMcpMentions('', validNames); expect(result).toBe(''); }); }); describe('splitCommandString', () => { it('splits simple command', () => { expect(splitCommandString('node server.js')).toEqual(['node', 'server.js']); }); it('handles single word', () => { expect(splitCommandString('claude')).toEqual(['claude']); }); it('handles empty string', () => { expect(splitCommandString('')).toEqual([]); }); it('handles whitespace-only string', () => { expect(splitCommandString(' ')).toEqual([]); }); it('handles double-quoted arguments', () => { expect(splitCommandString('echo "hello world"')).toEqual(['echo', 'hello world']); }); it('handles single-quoted arguments', () => { expect(splitCommandString("echo 'hello world'")).toEqual(['echo', 'hello world']); }); it('handles multiple quoted arguments', () => { expect(splitCommandString('cmd "arg one" "arg two"')).toEqual(['cmd', 'arg one', 'arg two']); }); it('handles mixed quoted and unquoted arguments', () => { expect(splitCommandString('cmd --flag "quoted arg" plain')).toEqual(['cmd', '--flag', 'quoted arg', 'plain']); }); it('handles multiple spaces between arguments', () => { expect(splitCommandString('cmd arg1 arg2')).toEqual(['cmd', 'arg1', 'arg2']); }); it('handles leading and trailing whitespace', () => { expect(splitCommandString(' cmd arg ')).toEqual(['cmd', 'arg']); }); it('handles tab characters as whitespace', () => { expect(splitCommandString('cmd\targ1\targ2')).toEqual(['cmd', 'arg1', 'arg2']); }); it('preserves spaces inside quotes', () => { expect(splitCommandString('"path with spaces/bin" --arg')).toEqual(['path with spaces/bin', '--arg']); }); it('handles adjacent quoted and unquoted content', () => { // Quotes are stripped, so "foo"bar becomes foobar expect(splitCommandString('"foo"bar')).toEqual(['foobar']); }); }); describe('parseCommand', () => { it('parses command with no arguments', () => { expect(parseCommand('claude')).toEqual({ cmd: 'claude', args: [] }); }); it('parses command with arguments', () => { expect(parseCommand('node server.js --port 3000')).toEqual({ cmd: 'node', args: ['server.js', '--port', '3000'], }); }); it('uses providedArgs when given', () => { expect(parseCommand('node', ['--version'])).toEqual({ cmd: 'node', args: ['--version'], }); }); it('ignores command string parsing when providedArgs is non-empty', () => { expect(parseCommand('node server.js', ['--help'])).toEqual({ cmd: 'node server.js', args: ['--help'], }); }); it('falls back to parsing when providedArgs is empty array', () => { expect(parseCommand('node server.js', [])).toEqual({ cmd: 'node', args: ['server.js'], }); }); it('handles empty command string', () => { expect(parseCommand('')).toEqual({ cmd: '', args: [] }); }); it('handles quoted arguments in command string', () => { expect(parseCommand('echo "hello world" --verbose')).toEqual({ cmd: 'echo', args: ['hello world', '--verbose'], }); }); }); ================================================ FILE: tests/unit/utils/path.test.ts ================================================ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { expandHomePath, findClaudeCLIPath, getPathAccessType, isPathInAllowedExportPaths, isPathWithinVault, normalizePathForComparison, normalizePathForFilesystem, normalizePathForVault, parsePathEntries, translateMsysPath, } from '@/utils/path'; const isWindows = process.platform === 'win32'; describe('expandHomePath', () => { it('expands ~ to home directory', () => { expect(expandHomePath('~')).toBe(os.homedir()); }); it('expands ~/ prefix', () => { const result = expandHomePath('~/Documents'); expect(result).toBe(path.join(os.homedir(), 'Documents')); }); it('expands nested ~/path', () => { const result = expandHomePath('~/a/b/c'); expect(result).toBe(path.join(os.homedir(), 'a', 'b', 'c')); }); it('returns non-tilde path unchanged', () => { expect(expandHomePath('/usr/local/bin')).toBe('/usr/local/bin'); }); it('does not expand ~ in middle of path', () => { expect(expandHomePath('/some/~/path')).toBe('/some/~/path'); }); it('expands $VAR format environment variables', () => { const original = process.env.TEST_EXPAND_VAR; process.env.TEST_EXPAND_VAR = '/custom/path'; try { const result = expandHomePath('$TEST_EXPAND_VAR/bin'); expect(result).toBe('/custom/path/bin'); } finally { if (original === undefined) delete process.env.TEST_EXPAND_VAR; else process.env.TEST_EXPAND_VAR = original; } }); it('expands ${VAR} format environment variables', () => { const original = process.env.TEST_EXPAND_VAR2; process.env.TEST_EXPAND_VAR2 = '/another/path'; try { const result = expandHomePath('${TEST_EXPAND_VAR2}/lib'); expect(result).toBe('/another/path/lib'); } finally { if (original === undefined) delete process.env.TEST_EXPAND_VAR2; else process.env.TEST_EXPAND_VAR2 = original; } }); it('expands %VAR% format environment variables', () => { const original = process.env.TEST_EXPAND_PCT; process.env.TEST_EXPAND_PCT = '/pct/path'; try { const result = expandHomePath('%TEST_EXPAND_PCT%/dir'); expect(result).toBe('/pct/path/dir'); } finally { if (original === undefined) delete process.env.TEST_EXPAND_PCT; else process.env.TEST_EXPAND_PCT = original; } }); it('preserves unmatched variable patterns', () => { delete process.env.NONEXISTENT_VAR_12345; expect(expandHomePath('$NONEXISTENT_VAR_12345/bin')).toBe('$NONEXISTENT_VAR_12345/bin'); }); it('returns path unchanged when no special patterns', () => { expect(expandHomePath('/plain/path')).toBe('/plain/path'); }); it('expands ~\\ backslash prefix', () => { const result = expandHomePath('~\\Documents'); expect(result).toBe(path.join(os.homedir(), 'Documents')); }); }); describe('parsePathEntries', () => { it('returns empty array for undefined', () => { expect(parsePathEntries(undefined)).toEqual([]); }); it('returns empty array for empty string', () => { expect(parsePathEntries('')).toEqual([]); }); it('splits on platform separator', () => { const sep = isWindows ? ';' : ':'; const result = parsePathEntries(`/a${sep}/b${sep}/c`); expect(result).toContain('/a'); expect(result).toContain('/b'); expect(result).toContain('/c'); }); it('filters out empty segments', () => { const sep = isWindows ? ';' : ':'; const result = parsePathEntries(`${sep}/a${sep}${sep}/b${sep}`); expect(result.every(s => s.length > 0)).toBe(true); }); it('filters out $PATH placeholder', () => { const sep = isWindows ? ';' : ':'; const result = parsePathEntries(`/a${sep}$PATH${sep}/b`); expect(result).not.toContain('$PATH'); }); it('filters out ${PATH} placeholder', () => { const sep = isWindows ? ';' : ':'; const result = parsePathEntries(`/a${sep}\${PATH}${sep}/b`); expect(result).not.toContain('${PATH}'); }); it('filters out %PATH% placeholder', () => { const sep = isWindows ? ';' : ':'; const result = parsePathEntries(`/a${sep}%PATH%${sep}/b`); expect(result).not.toContain('%PATH%'); }); it('strips surrounding double quotes', () => { const sep = isWindows ? ';' : ':'; const result = parsePathEntries(`"/quoted/path"${sep}/normal`); expect(result[0]).toBe('/quoted/path'); }); it('strips surrounding single quotes', () => { const sep = isWindows ? ';' : ':'; const result = parsePathEntries(`'/quoted/path'${sep}/normal`); expect(result[0]).toBe('/quoted/path'); }); it('expands ~ in entries', () => { const result = parsePathEntries('~/bin'); expect(result[0]).toBe(path.join(os.homedir(), 'bin')); }); }); describe('translateMsysPath', () => { if (!isWindows) { it('returns value unchanged on non-Windows', () => { expect(translateMsysPath('/c/Users/test')).toBe('/c/Users/test'); }); } if (isWindows) { it('translates /c/ to C:\\ on Windows', () => { expect(translateMsysPath('/c/Users/test')).toBe('C:\\Users\\test'); }); it('translates uppercase drive letter', () => { expect(translateMsysPath('/D/projects')).toBe('D:\\projects'); }); it('returns non-msys path unchanged', () => { expect(translateMsysPath('C:\\Users\\test')).toBe('C:\\Users\\test'); }); } }); describe('normalizePathForFilesystem', () => { it('returns empty string for empty input', () => { expect(normalizePathForFilesystem('')).toBe(''); }); it('returns empty string for null-like input', () => { expect(normalizePathForFilesystem(null as any)).toBe(''); expect(normalizePathForFilesystem(undefined as any)).toBe(''); }); it('returns empty string for non-string input', () => { expect(normalizePathForFilesystem(123 as any)).toBe(''); }); it('normalizes a regular path', () => { const result = normalizePathForFilesystem('/usr/local/bin'); expect(result).toBe('/usr/local/bin'); }); it('normalizes path with redundant separators', () => { const result = normalizePathForFilesystem('/usr//local///bin'); expect(result).toBe('/usr/local/bin'); }); it('normalizes path with . segments', () => { const result = normalizePathForFilesystem('/usr/./local/./bin'); expect(result).toBe('/usr/local/bin'); }); it('normalizes path with .. segments', () => { const result = normalizePathForFilesystem('/usr/local/../bin'); expect(result).toBe('/usr/bin'); }); it('expands ~ in path', () => { const result = normalizePathForFilesystem('~/Documents'); expect(result).toBe(path.normalize(path.join(os.homedir(), 'Documents'))); }); it('expands environment variables', () => { const original = process.env.TEST_NORM_VAR; process.env.TEST_NORM_VAR = '/test/val'; try { const result = normalizePathForFilesystem('$TEST_NORM_VAR/sub'); expect(result).toBe(path.normalize('/test/val/sub')); } finally { if (original === undefined) delete process.env.TEST_NORM_VAR; else process.env.TEST_NORM_VAR = original; } }); }); describe('normalizePathForComparison', () => { it('returns empty string for empty input', () => { expect(normalizePathForComparison('')).toBe(''); }); it('returns empty string for null-like input', () => { expect(normalizePathForComparison(null as any)).toBe(''); expect(normalizePathForComparison(undefined as any)).toBe(''); }); it('normalizes slashes to forward slash', () => { // On any platform, result should use forward slashes const result = normalizePathForComparison('/usr/local/bin'); expect(result).not.toContain('\\'); }); it('removes trailing slash', () => { const result = normalizePathForComparison('/usr/local/bin/'); expect(result).not.toMatch(/\/$/); }); it('removes multiple trailing slashes', () => { const result = normalizePathForComparison('/usr/local/bin///'); expect(result).not.toMatch(/\/$/); }); if (isWindows) { it('lowercases on Windows for case-insensitive comparison', () => { const result = normalizePathForComparison('C:\\Users\\Test'); expect(result).toBe(result.toLowerCase()); }); } if (!isWindows) { it('preserves case on Unix', () => { const result = normalizePathForComparison('/Users/Test'); expect(result).toContain('Test'); }); } it('normalizes redundant separators', () => { const result = normalizePathForComparison('/usr//local///bin'); expect(result).toBe('/usr/local/bin'); }); }); describe('isPathWithinVault', () => { const vaultPath = path.resolve('/tmp/test-vault'); it('returns true for path within vault', () => { expect(isPathWithinVault(path.join(vaultPath, 'notes', 'file.md'), vaultPath)).toBe(true); }); it('returns true for vault path itself', () => { expect(isPathWithinVault(vaultPath, vaultPath)).toBe(true); }); it('returns false for path outside vault', () => { expect(isPathWithinVault('/completely/different/path', vaultPath)).toBe(false); }); it('returns false for sibling directory', () => { expect(isPathWithinVault(path.resolve('/tmp/other-vault'), vaultPath)).toBe(false); }); it('handles relative paths resolved against vault', () => { expect(isPathWithinVault('notes/file.md', vaultPath)).toBe(true); }); }); describe('normalizePathForVault', () => { const vaultPath = path.resolve('/tmp/test-vault'); it('returns null for null/undefined input', () => { expect(normalizePathForVault(null, vaultPath)).toBeNull(); expect(normalizePathForVault(undefined, vaultPath)).toBeNull(); }); it('returns null for empty string', () => { expect(normalizePathForVault('', vaultPath)).toBeNull(); }); it('returns relative path for file within vault', () => { const fullPath = path.join(vaultPath, 'notes', 'file.md'); const result = normalizePathForVault(fullPath, vaultPath); expect(result).toBe('notes/file.md'); }); it('returns normalized path for file outside vault', () => { const result = normalizePathForVault('/other/path/file.md', vaultPath); expect(result).toContain('file.md'); }); it('uses forward slashes in result', () => { const fullPath = path.join(vaultPath, 'a', 'b', 'c.md'); const result = normalizePathForVault(fullPath, vaultPath); expect(result).not.toContain('\\'); }); it('handles null vaultPath', () => { const result = normalizePathForVault('/some/path.md', null); expect(result).toContain('path.md'); }); }); describe('isPathInAllowedExportPaths', () => { const vaultPath = path.resolve('/tmp/test-vault'); it('returns false for empty allowedExportPaths', () => { expect(isPathInAllowedExportPaths('/some/path', [], vaultPath)).toBe(false); }); it('returns true for path within allowed export path', () => { const exportDir = path.resolve('/tmp/exports'); const candidate = path.join(exportDir, 'file.txt'); expect(isPathInAllowedExportPaths(candidate, [exportDir], vaultPath)).toBe(true); }); it('returns true for exact match of export path', () => { const exportDir = path.resolve('/tmp/exports'); expect(isPathInAllowedExportPaths(exportDir, [exportDir], vaultPath)).toBe(true); }); it('returns false for path outside all export paths', () => { const exportDir = path.resolve('/tmp/exports'); expect(isPathInAllowedExportPaths('/other/path', [exportDir], vaultPath)).toBe(false); }); it('checks multiple export paths', () => { const export1 = path.resolve('/tmp/export1'); const export2 = path.resolve('/tmp/export2'); const candidate = path.join(export2, 'file.txt'); expect(isPathInAllowedExportPaths(candidate, [export1, export2], vaultPath)).toBe(true); }); }); describe('getPathAccessType', () => { const vaultPath = path.resolve('/tmp/test-vault'); it('returns none for empty candidate', () => { expect(getPathAccessType('', [], [], vaultPath)).toBe('none'); }); it('returns vault for path inside vault', () => { const candidate = path.join(vaultPath, 'notes', 'file.md'); expect(getPathAccessType(candidate, [], [], vaultPath)).toBe('vault'); }); it('returns vault for vault path itself', () => { expect(getPathAccessType(vaultPath, [], [], vaultPath)).toBe('vault'); }); it('returns vault for ~/.claude safe subdirectory', () => { expect(getPathAccessType(path.join(os.homedir(), '.claude', 'settings.json'), [], [], vaultPath)).toBe('vault'); expect(getPathAccessType(path.join(os.homedir(), '.claude', 'sessions', 'abc.jsonl'), [], [], vaultPath)).toBe('vault'); expect(getPathAccessType(path.join(os.homedir(), '.claude', 'projects', 'test'), [], [], vaultPath)).toBe('vault'); expect(getPathAccessType(path.join(os.homedir(), '.claude', 'commands', 'cmd.md'), [], [], vaultPath)).toBe('vault'); expect(getPathAccessType(path.join(os.homedir(), '.claude', 'agents', 'agent.md'), [], [], vaultPath)).toBe('vault'); expect(getPathAccessType(path.join(os.homedir(), '.claude', 'skills', 'skill'), [], [], vaultPath)).toBe('vault'); expect(getPathAccessType(path.join(os.homedir(), '.claude', 'plans', 'plan.md'), [], [], vaultPath)).toBe('vault'); expect(getPathAccessType(path.join(os.homedir(), '.claude', 'mcp.json'), [], [], vaultPath)).toBe('vault'); expect(getPathAccessType(path.join(os.homedir(), '.claude', 'claudian-settings.json'), [], [], vaultPath)).toBe('vault'); }); it('returns context (read-only) for unknown ~/.claude paths', () => { expect(getPathAccessType(path.join(os.homedir(), '.claude', 'credentials'), [], [], vaultPath)).toBe('context'); expect(getPathAccessType(path.join(os.homedir(), '.claude', 'secrets.json'), [], [], vaultPath)).toBe('context'); }); it('returns context for ~/.claude directory itself', () => { expect(getPathAccessType(path.join(os.homedir(), '.claude'), [], [], vaultPath)).toBe('context'); }); it('returns context for path in context paths only', () => { const contextDir = path.resolve('/tmp/context-dir'); const candidate = path.join(contextDir, 'file.md'); expect(getPathAccessType(candidate, [contextDir], [], vaultPath)).toBe('context'); }); it('returns export for path in export paths only', () => { const exportDir = path.resolve('/tmp/export-dir'); const candidate = path.join(exportDir, 'file.md'); expect(getPathAccessType(candidate, [], [exportDir], vaultPath)).toBe('export'); }); it('returns readwrite for path in both context and export paths', () => { const sharedDir = path.resolve('/tmp/shared-dir'); const candidate = path.join(sharedDir, 'file.md'); expect(getPathAccessType(candidate, [sharedDir], [sharedDir], vaultPath)).toBe('readwrite'); }); it('returns none for path not in any allowed path', () => { const contextDir = path.resolve('/tmp/context-dir'); expect(getPathAccessType('/other/path', [contextDir], [], vaultPath)).toBe('none'); }); it('handles undefined context and export paths', () => { expect(getPathAccessType('/some/path', undefined, undefined, vaultPath)).toBe('none'); }); it('uses most specific matching root', () => { const parentDir = path.resolve('/tmp/parent'); const childDir = path.join(parentDir, 'child'); const candidate = path.join(childDir, 'file.md'); // Parent is context only, child is both context and export const result = getPathAccessType( candidate, [parentDir, childDir], [childDir], vaultPath ); expect(result).toBe('readwrite'); }); }); describe('findClaudeCLIPath', () => { afterEach(() => { jest.restoreAllMocks(); }); it('returns null when nothing found', () => { jest.spyOn(fs, 'existsSync').mockReturnValue(false); const result = findClaudeCLIPath('/nonexistent/path'); expect(result).toBeNull(); }); it('resolves from custom path entries', () => { const claudePath = isWindows ? 'C:\\custom\\bin\\claude.exe' : '/custom/bin/claude'; jest.spyOn(fs, 'existsSync').mockImplementation( p => String(p) === claudePath ); jest.spyOn(fs, 'statSync').mockImplementation( p => ({ isFile: () => String(p) === claudePath }) as fs.Stats ); const result = findClaudeCLIPath(isWindows ? 'C:\\custom\\bin' : '/custom/bin'); expect(result).toBe(claudePath); }); it('returns string or null', () => { const result = findClaudeCLIPath(); expect(result === null || typeof result === 'string').toBe(true); }); it('finds claude from common paths when no custom path provided', () => { const commonPath = path.join(os.homedir(), '.claude', 'local', 'claude'); jest.spyOn(fs, 'existsSync').mockImplementation( p => String(p) === commonPath ); jest.spyOn(fs, 'statSync').mockImplementation( p => ({ isFile: () => String(p) === commonPath }) as fs.Stats ); const result = findClaudeCLIPath(); expect(result).toBe(commonPath); }); it('falls back to npm cli.js paths when binary not found', () => { const cliJsPath = path.join( os.homedir(), '.npm-global', 'lib', 'node_modules', '@anthropic-ai', 'claude-code', 'cli.js' ); jest.spyOn(fs, 'existsSync').mockImplementation( p => String(p) === cliJsPath ); jest.spyOn(fs, 'statSync').mockImplementation( p => ({ isFile: () => String(p) === cliJsPath }) as fs.Stats ); const result = findClaudeCLIPath(); expect(result).toBe(cliJsPath); }); it('falls back to PATH environment when common and npm paths fail', () => { const envClaudePath = '/env/specific/bin/claude'; const originalPath = process.env.PATH; process.env.PATH = `/env/specific/bin:${originalPath}`; jest.spyOn(fs, 'existsSync').mockImplementation( p => String(p) === envClaudePath ); jest.spyOn(fs, 'statSync').mockImplementation( p => ({ isFile: () => String(p) === envClaudePath }) as fs.Stats ); try { const result = findClaudeCLIPath(); expect(result).toBe(envClaudePath); } finally { process.env.PATH = originalPath; } }); it('returns null for custom path without claude binary on non-Windows', () => { // On non-Windows, custom path resolution only looks for 'claude' binary const customDir = '/custom/tools'; jest.spyOn(fs, 'existsSync').mockReturnValue(false); const result = findClaudeCLIPath(customDir); expect(result).toBeNull(); }); it('handles inaccessible filesystem paths gracefully', () => { jest.spyOn(fs, 'existsSync').mockImplementation(() => { throw new Error('Permission denied'); }); const result = findClaudeCLIPath('/some/path'); expect(result).toBeNull(); }); it('finds claude via nvm default version when NVM_BIN is not set (Unix)', () => { if (isWindows) return; const savedNvmBin = process.env.NVM_BIN; const savedNvmDir = process.env.NVM_DIR; delete process.env.NVM_BIN; delete process.env.NVM_DIR; const nvmDir = '/fake/home/.nvm'; const claudePath = path.join(nvmDir, 'versions', 'node', 'v22.18.0', 'bin', 'claude'); const binDir = path.join(nvmDir, 'versions', 'node', 'v22.18.0', 'bin'); jest.spyOn(os, 'homedir').mockReturnValue('/fake/home'); jest.spyOn(fs, 'existsSync').mockImplementation(p => { const s = String(p); return s === claudePath || s === binDir; }); jest.spyOn(fs, 'readFileSync').mockImplementation(((p: string) => { if (String(p) === path.join(nvmDir, 'alias', 'default')) return '22'; throw new Error('not found'); }) as typeof fs.readFileSync); jest.spyOn(fs, 'readdirSync').mockImplementation(((p: string) => { if (String(p) === path.join(nvmDir, 'versions', 'node')) return ['v22.18.0']; return []; }) as typeof fs.readdirSync); jest.spyOn(fs, 'statSync').mockImplementation( () => ({ isFile: () => true }) as fs.Stats ); const result = findClaudeCLIPath(); expect(result).toBe(claudePath); if (savedNvmBin !== undefined) process.env.NVM_BIN = savedNvmBin; else delete process.env.NVM_BIN; if (savedNvmDir !== undefined) process.env.NVM_DIR = savedNvmDir; else delete process.env.NVM_DIR; }); it('finds claude via built-in nvm node alias when NVM_BIN is not set (Unix)', () => { if (isWindows) return; const savedNvmBin = process.env.NVM_BIN; const savedNvmDir = process.env.NVM_DIR; delete process.env.NVM_BIN; delete process.env.NVM_DIR; const nvmDir = '/fake/home/.nvm'; const claudePath = path.join(nvmDir, 'versions', 'node', 'v22.18.0', 'bin', 'claude'); const binDir = path.join(nvmDir, 'versions', 'node', 'v22.18.0', 'bin'); jest.spyOn(os, 'homedir').mockReturnValue('/fake/home'); jest.spyOn(fs, 'existsSync').mockImplementation(p => { const s = String(p); return s === claudePath || s === binDir; }); jest.spyOn(fs, 'readFileSync').mockImplementation(((p: string) => { if (String(p) === path.join(nvmDir, 'alias', 'default')) return 'node'; throw new Error('not found'); }) as typeof fs.readFileSync); jest.spyOn(fs, 'readdirSync').mockImplementation(((p: string) => { if (String(p) === path.join(nvmDir, 'versions', 'node')) return ['v20.10.0', 'v22.18.0']; return []; }) as typeof fs.readdirSync); jest.spyOn(fs, 'statSync').mockImplementation( () => ({ isFile: () => true }) as fs.Stats ); const result = findClaudeCLIPath(); expect(result).toBe(claudePath); if (savedNvmBin !== undefined) process.env.NVM_BIN = savedNvmBin; else delete process.env.NVM_BIN; if (savedNvmDir !== undefined) process.env.NVM_DIR = savedNvmDir; else delete process.env.NVM_DIR; }); }); describe('expandHomePath - Windows environment variable formats', () => { const originalPlatform = process.platform; afterEach(() => { Object.defineProperty(process, 'platform', { value: originalPlatform }); }); it('expands Windows !VAR! delayed expansion format on win32', () => { Object.defineProperty(process, 'platform', { value: 'win32' }); const original = process.env.TEST_DELAYED; process.env.TEST_DELAYED = '/delayed/path'; try { const result = expandHomePath('!TEST_DELAYED!/bin'); expect(result).toBe('/delayed/path/bin'); } finally { if (original === undefined) delete process.env.TEST_DELAYED; else process.env.TEST_DELAYED = original; } }); it('does not expand !VAR! format on non-Windows', () => { Object.defineProperty(process, 'platform', { value: 'darwin' }); const original = process.env.TEST_DELAYED2; process.env.TEST_DELAYED2 = '/delayed/path2'; try { const result = expandHomePath('!TEST_DELAYED2!/bin'); expect(result).toBe('!TEST_DELAYED2!/bin'); } finally { if (original === undefined) delete process.env.TEST_DELAYED2; else process.env.TEST_DELAYED2 = original; } }); it('expands Windows $env:VAR PowerShell format on win32', () => { Object.defineProperty(process, 'platform', { value: 'win32' }); const original = process.env.TEST_PSVAR; process.env.TEST_PSVAR = '/ps/path'; try { const result = expandHomePath('$env:TEST_PSVAR/bin'); expect(result).toBe('/ps/path/bin'); } finally { if (original === undefined) delete process.env.TEST_PSVAR; else process.env.TEST_PSVAR = original; } }); it('does not expand $env:VAR format on non-Windows', () => { Object.defineProperty(process, 'platform', { value: 'darwin' }); const original = process.env.TEST_PSVAR2; process.env.TEST_PSVAR2 = '/ps/path2'; try { const result = expandHomePath('$env:TEST_PSVAR2/bin'); // On non-Windows, $env is treated as a regular $VAR lookup for "env" // which won't match TEST_PSVAR2, so the $env: prefix persists partially expect(result).not.toBe('/ps/path2/bin'); } finally { if (original === undefined) delete process.env.TEST_PSVAR2; else process.env.TEST_PSVAR2 = original; } }); it('performs case-insensitive env lookup on win32', () => { Object.defineProperty(process, 'platform', { value: 'win32' }); const original = process.env.MY_CI_VAR; process.env.MY_CI_VAR = '/ci/val'; try { // %var% format uses getEnvValue which does case-insensitive search on Windows const result = expandHomePath('%my_ci_var%/test'); expect(result).toBe('/ci/val/test'); } finally { if (original === undefined) delete process.env.MY_CI_VAR; else process.env.MY_CI_VAR = original; } }); }); describe('getPathAccessType - edge cases', () => { const vaultPath = path.resolve('/tmp/test-vault'); it('returns none when no root matches the candidate', () => { expect(getPathAccessType('/outside/path', [' '], [' '], vaultPath)).toBe('none'); }); }); ================================================ FILE: tests/unit/utils/sdkSession.test.ts ================================================ import { existsSync } from 'fs'; import * as fsPromises from 'fs/promises'; import * as os from 'os'; import { extractToolResultContent } from '@/core/sdk/toolResultContent'; import { collectAsyncSubagentResults, deleteSDKSession, encodeVaultPathForSDK, filterActiveBranch, getSDKProjectsPath, getSDKSessionPath, isValidSessionId, loadSDKSessionMessages, loadSubagentFinalResult, loadSubagentToolCalls, parseSDKMessageToChat, readSDKSession, resolveToolUseResultStatus, type SDKNativeMessage, sdkSessionExists, } from '@/utils/sdkSession'; // Mock fs, fs/promises, and os modules jest.mock('fs', () => ({ existsSync: jest.fn(), })); jest.mock('fs/promises'); jest.mock('os'); const mockExistsSync = existsSync as jest.MockedFunction<typeof existsSync>; const mockFsPromises = fsPromises as jest.Mocked<typeof fsPromises>; const mockOs = os as jest.Mocked<typeof os>; describe('sdkSession', () => { beforeEach(() => { jest.clearAllMocks(); mockOs.homedir.mockReturnValue('/Users/test'); }); describe('encodeVaultPathForSDK', () => { it('encodes vault path by replacing all non-alphanumeric chars with dash', () => { const encoded = encodeVaultPathForSDK('/Users/test/vault'); // SDK replaces ALL non-alphanumeric characters with `-` expect(encoded).toBe('-Users-test-vault'); }); it('handles paths with spaces and special characters', () => { const encoded = encodeVaultPathForSDK("/Users/test/My Vault's~Data"); expect(encoded).toBe('-Users-test-My-Vault-s-Data'); }); it('handles Unicode characters (Chinese, Japanese, etc.)', () => { // Unicode characters should be replaced with `-` to match SDK behavior const encoded = encodeVaultPathForSDK('/Volumes/[Work]弘毅之鹰/学习/东京大学/2025年 秋'); // All non-alphanumeric (including Chinese, brackets) become `-` expect(encoded).toBe('-Volumes--Work--------------2025---'); // Verify only ASCII alphanumeric and dash remain expect(encoded).toMatch(/^[a-zA-Z0-9-]+$/); }); it('handles brackets and other special characters', () => { const encoded = encodeVaultPathForSDK('/Users/test/[my-vault](notes)'); expect(encoded).toBe('-Users-test--my-vault--notes-'); expect(encoded).not.toContain('['); expect(encoded).not.toContain(']'); expect(encoded).not.toContain('('); expect(encoded).not.toContain(')'); }); it('produces consistent encoding', () => { const path1 = '/Users/test/my-vault'; const encoded1 = encodeVaultPathForSDK(path1); const encoded2 = encodeVaultPathForSDK(path1); expect(encoded1).toBe(encoded2); }); it('produces different encodings for different paths', () => { const encoded1 = encodeVaultPathForSDK('/Users/test/vault1'); const encoded2 = encodeVaultPathForSDK('/Users/test/vault2'); expect(encoded1).not.toBe(encoded2); }); it('handles backslashes for Windows compatibility', () => { // Test that backslashes are replaced (Windows path separators) // Note: path.resolve may modify the input, so we check the output contains no backslashes const encoded = encodeVaultPathForSDK('C:\\Users\\test\\vault'); expect(encoded).not.toContain('\\'); expect(encoded).toContain('-Users-test-vault'); }); it('replaces colons for Windows drive letters', () => { // Windows paths have colons after drive letter const encoded = encodeVaultPathForSDK('C:\\Users\\test\\vault'); expect(encoded).not.toContain(':'); }); }); describe('getSDKProjectsPath', () => { it('returns path under home directory', () => { const projectsPath = getSDKProjectsPath(); expect(projectsPath).toBe('/Users/test/.claude/projects'); }); }); describe('isValidSessionId', () => { it('accepts valid UUID-style session IDs', () => { expect(isValidSessionId('abc123')).toBe(true); expect(isValidSessionId('session-123')).toBe(true); expect(isValidSessionId('a1b2c3d4-e5f6-7890-abcd-ef1234567890')).toBe(true); expect(isValidSessionId('test_session_id')).toBe(true); }); it('rejects empty or too long session IDs', () => { expect(isValidSessionId('')).toBe(false); expect(isValidSessionId('a'.repeat(129))).toBe(false); }); it('rejects path traversal attempts', () => { expect(isValidSessionId('../etc/passwd')).toBe(false); expect(isValidSessionId('..\\windows\\system32')).toBe(false); expect(isValidSessionId('foo/../bar')).toBe(false); expect(isValidSessionId('session/subdir')).toBe(false); expect(isValidSessionId('session\\subdir')).toBe(false); }); it('rejects special characters', () => { expect(isValidSessionId('session.jsonl')).toBe(false); expect(isValidSessionId('session:123')).toBe(false); expect(isValidSessionId('session@host')).toBe(false); }); }); describe('getSDKSessionPath', () => { it('constructs correct session file path', () => { const sessionPath = getSDKSessionPath('/Users/test/vault', 'session-123'); expect(sessionPath).toContain('.claude/projects'); expect(sessionPath).toContain('session-123.jsonl'); }); it('throws error for path traversal attempts', () => { expect(() => getSDKSessionPath('/Users/test/vault', '../etc/passwd')).toThrow('Invalid session ID'); expect(() => getSDKSessionPath('/Users/test/vault', 'foo/../bar')).toThrow('Invalid session ID'); expect(() => getSDKSessionPath('/Users/test/vault', 'session/subdir')).toThrow('Invalid session ID'); }); it('throws error for empty session ID', () => { expect(() => getSDKSessionPath('/Users/test/vault', '')).toThrow('Invalid session ID'); }); }); describe('sdkSessionExists', () => { it('returns true when session file exists', () => { mockExistsSync.mockReturnValue(true); const exists = sdkSessionExists('/Users/test/vault', 'session-abc'); expect(exists).toBe(true); }); it('returns false when session file does not exist', () => { mockExistsSync.mockReturnValue(false); const exists = sdkSessionExists('/Users/test/vault', 'session-xyz'); expect(exists).toBe(false); }); it('returns false on error', () => { mockExistsSync.mockImplementation(() => { throw new Error('Permission denied'); }); const exists = sdkSessionExists('/Users/test/vault', 'session-err'); expect(exists).toBe(false); }); }); describe('deleteSDKSession', () => { it('deletes session file when it exists', async () => { mockExistsSync.mockReturnValue(true); mockFsPromises.unlink.mockResolvedValue(undefined); await deleteSDKSession('/Users/test/vault', 'session-abc'); expect(mockFsPromises.unlink).toHaveBeenCalledWith( '/Users/test/.claude/projects/-Users-test-vault/session-abc.jsonl' ); }); it('does nothing when session file does not exist', async () => { mockExistsSync.mockReturnValue(false); await deleteSDKSession('/Users/test/vault', 'nonexistent'); expect(mockFsPromises.unlink).not.toHaveBeenCalled(); }); it('fails silently when unlink throws', async () => { mockExistsSync.mockReturnValue(true); mockFsPromises.unlink.mockRejectedValue(new Error('Permission denied')); // Should not throw await expect(deleteSDKSession('/Users/test/vault', 'session-err')).resolves.toBeUndefined(); }); it('does nothing for invalid session ID', async () => { await deleteSDKSession('/Users/test/vault', '../invalid'); expect(mockFsPromises.unlink).not.toHaveBeenCalled(); }); }); describe('readSDKSession', () => { it('returns empty result when file does not exist', async () => { mockExistsSync.mockReturnValue(false); const result = await readSDKSession('/Users/test/vault', 'nonexistent'); expect(result.messages).toEqual([]); expect(result.skippedLines).toBe(0); expect(result.error).toBeUndefined(); }); it('parses valid JSONL file', async () => { mockExistsSync.mockReturnValue(true); mockFsPromises.readFile.mockResolvedValue([ '{"type":"user","uuid":"u1","message":{"content":"Hello"}}', '{"type":"assistant","uuid":"a1","message":{"content":"Hi there"}}', ].join('\n')); const result = await readSDKSession('/Users/test/vault', 'session-1'); expect(result.messages).toHaveLength(2); expect(result.messages[0].type).toBe('user'); expect(result.messages[1].type).toBe('assistant'); expect(result.skippedLines).toBe(0); }); it('skips invalid JSON lines and reports count', async () => { mockExistsSync.mockReturnValue(true); mockFsPromises.readFile.mockResolvedValue([ '{"type":"user","uuid":"u1","message":{"content":"Hello"}}', 'invalid json line', '{"type":"assistant","uuid":"a1","message":{"content":"Hi"}}', ].join('\n')); const result = await readSDKSession('/Users/test/vault', 'session-2'); expect(result.messages).toHaveLength(2); expect(result.skippedLines).toBe(1); }); it('handles empty lines', async () => { mockExistsSync.mockReturnValue(true); mockFsPromises.readFile.mockResolvedValue([ '{"type":"user","uuid":"u1","message":{"content":"Test"}}', '', ' ', '{"type":"assistant","uuid":"a1","message":{"content":"Response"}}', ].join('\n')); const result = await readSDKSession('/Users/test/vault', 'session-3'); expect(result.messages).toHaveLength(2); }); it('returns error on read failure', async () => { mockExistsSync.mockReturnValue(true); mockFsPromises.readFile.mockRejectedValue(new Error('Read error')); const result = await readSDKSession('/Users/test/vault', 'session-err'); expect(result.messages).toEqual([]); expect(result.error).toBe('Read error'); }); }); describe('loadSubagentToolCalls', () => { it('loads tool calls from subagent sidechain JSONL', async () => { mockExistsSync.mockReturnValue(true); mockFsPromises.readFile.mockResolvedValue([ '{"type":"assistant","timestamp":"2024-01-15T10:00:00Z","message":{"content":[{"type":"tool_use","id":"sub-tool-1","name":"Bash","input":{"command":"ls"}}]}}', '{"type":"user","timestamp":"2024-01-15T10:00:01Z","message":{"content":[{"type":"tool_result","tool_use_id":"sub-tool-1","content":"ok","is_error":false}]}}', ].join('\n')); const toolCalls = await loadSubagentToolCalls( '/Users/test/vault', 'session-abc', 'a123' ); expect(mockFsPromises.readFile).toHaveBeenCalledWith( '/Users/test/.claude/projects/-Users-test-vault/session-abc/subagents/agent-a123.jsonl', 'utf-8' ); expect(toolCalls).toHaveLength(1); expect(toolCalls[0]).toEqual( expect.objectContaining({ id: 'sub-tool-1', name: 'Bash', input: { command: 'ls' }, status: 'completed', result: 'ok', }) ); }); it('filters out entries that only have tool_result but no tool_use', async () => { mockExistsSync.mockReturnValue(true); mockFsPromises.readFile.mockResolvedValue( '{"type":"user","timestamp":"2024-01-15T10:00:01Z","message":{"content":[{"type":"tool_result","tool_use_id":"missing","content":"done"}]}}' ); const toolCalls = await loadSubagentToolCalls( '/Users/test/vault', 'session-abc', 'a123' ); expect(toolCalls).toEqual([]); }); it('returns empty when agent id is invalid', async () => { const toolCalls = await loadSubagentToolCalls( '/Users/test/vault', 'session-abc', '../bad-agent' ); expect(toolCalls).toEqual([]); expect(mockFsPromises.readFile).not.toHaveBeenCalled(); }); }); describe('loadSubagentFinalResult', () => { it('returns the latest assistant text from sidecar JSONL', async () => { mockExistsSync.mockReturnValue(true); mockFsPromises.readFile.mockResolvedValue([ '{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"First"}]}}', '{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Final answer"}]}}', ].join('\n')); const result = await loadSubagentFinalResult( '/Users/test/vault', 'session-abc', 'a123' ); expect(result).toBe('Final answer'); expect(mockFsPromises.readFile).toHaveBeenCalledWith( '/Users/test/.claude/projects/-Users-test-vault/session-abc/subagents/agent-a123.jsonl', 'utf-8' ); }); it('falls back to top-level result when assistant text is absent', async () => { mockExistsSync.mockReturnValue(true); mockFsPromises.readFile.mockResolvedValue([ '{"type":"progress","result":"Intermediate result"}', '{"type":"result","result":"Final result text"}', ].join('\n')); const result = await loadSubagentFinalResult( '/Users/test/vault', 'session-abc', 'a123' ); expect(result).toBe('Final result text'); }); it('returns null when sidecar file is missing or agent id is invalid', async () => { mockExistsSync.mockReturnValue(false); const missing = await loadSubagentFinalResult( '/Users/test/vault', 'session-abc', 'a123' ); expect(missing).toBeNull(); const invalid = await loadSubagentFinalResult( '/Users/test/vault', 'session-abc', '../bad-agent' ); expect(invalid).toBeNull(); expect(mockFsPromises.readFile).not.toHaveBeenCalled(); }); }); describe('parseSDKMessageToChat', () => { it('converts user message with string content', () => { const sdkMsg: SDKNativeMessage = { type: 'user', uuid: 'user-123', timestamp: '2024-01-15T10:30:00Z', message: { content: 'What is the weather?', }, }; const chatMsg = parseSDKMessageToChat(sdkMsg); expect(chatMsg).not.toBeNull(); expect(chatMsg!.id).toBe('user-123'); expect(chatMsg!.role).toBe('user'); expect(chatMsg!.content).toBe('What is the weather?'); expect(chatMsg!.timestamp).toBe(new Date('2024-01-15T10:30:00Z').getTime()); }); it('sets sdkUserUuid on user messages with uuid', () => { const sdkMsg: SDKNativeMessage = { type: 'user', uuid: 'user-rewind-123', timestamp: '2024-01-15T10:30:00Z', message: { content: 'Hello' }, }; const chatMsg = parseSDKMessageToChat(sdkMsg); expect(chatMsg!.sdkUserUuid).toBe('user-rewind-123'); expect(chatMsg!.sdkAssistantUuid).toBeUndefined(); }); it('sets sdkAssistantUuid on assistant messages with uuid', () => { const sdkMsg: SDKNativeMessage = { type: 'assistant', uuid: 'asst-rewind-456', timestamp: '2024-01-15T10:31:00Z', message: { content: [{ type: 'text', text: 'Hello back' }], }, }; const chatMsg = parseSDKMessageToChat(sdkMsg); expect(chatMsg!.sdkAssistantUuid).toBe('asst-rewind-456'); expect(chatMsg!.sdkUserUuid).toBeUndefined(); }); it('does not set SDK UUIDs when uuid is absent', () => { const sdkMsg: SDKNativeMessage = { type: 'user', timestamp: '2024-01-15T10:30:00Z', message: { content: 'No uuid' }, }; const chatMsg = parseSDKMessageToChat(sdkMsg); expect(chatMsg!.sdkUserUuid).toBeUndefined(); expect(chatMsg!.sdkAssistantUuid).toBeUndefined(); }); it('converts assistant message with text content blocks', () => { const sdkMsg: SDKNativeMessage = { type: 'assistant', uuid: 'asst-456', timestamp: '2024-01-15T10:31:00Z', message: { content: [ { type: 'text', text: 'The weather is sunny.' }, { type: 'text', text: 'Temperature is 72°F.' }, ], }, }; const chatMsg = parseSDKMessageToChat(sdkMsg); expect(chatMsg).not.toBeNull(); expect(chatMsg!.id).toBe('asst-456'); expect(chatMsg!.role).toBe('assistant'); expect(chatMsg!.content).toBe('The weather is sunny.\nTemperature is 72°F.'); }); it('extracts tool calls from content blocks', () => { const sdkMsg: SDKNativeMessage = { type: 'assistant', uuid: 'asst-tool', timestamp: '2024-01-15T10:32:00Z', message: { content: [ { type: 'text', text: 'Let me search for that.' }, { type: 'tool_use', id: 'tool-1', name: 'WebSearch', input: { query: 'weather today' }, }, { type: 'tool_result', tool_use_id: 'tool-1', content: 'Sunny, 72°F', }, ], }, }; const chatMsg = parseSDKMessageToChat(sdkMsg); expect(chatMsg).not.toBeNull(); expect(chatMsg!.toolCalls).toHaveLength(1); expect(chatMsg!.toolCalls![0].id).toBe('tool-1'); expect(chatMsg!.toolCalls![0].name).toBe('WebSearch'); expect(chatMsg!.toolCalls![0].input).toEqual({ query: 'weather today' }); expect(chatMsg!.toolCalls![0].status).toBe('completed'); expect(chatMsg!.toolCalls![0].result).toBe('Sunny, 72°F'); }); it('marks tool call as error when is_error is true', () => { const sdkMsg: SDKNativeMessage = { type: 'assistant', uuid: 'asst-err', timestamp: '2024-01-15T10:33:00Z', message: { content: [ { type: 'tool_use', id: 'tool-err', name: 'Bash', input: { command: 'invalid' }, }, { type: 'tool_result', tool_use_id: 'tool-err', content: 'Command not found', is_error: true, }, ], }, }; const chatMsg = parseSDKMessageToChat(sdkMsg); expect(chatMsg!.toolCalls![0].status).toBe('error'); }); it('keeps tool calls running when no matching tool_result exists yet', () => { const sdkMsg: SDKNativeMessage = { type: 'assistant', uuid: 'asst-running', timestamp: '2024-01-15T10:33:30Z', message: { content: [ { type: 'tool_use', id: 'tool-running', name: 'Read', input: { file_path: 'notes/todo.md' }, }, ], }, }; const chatMsg = parseSDKMessageToChat(sdkMsg); expect(chatMsg!.toolCalls![0].status).toBe('running'); expect(chatMsg!.toolCalls![0].result).toBeUndefined(); }); it('extracts thinking content blocks', () => { const sdkMsg: SDKNativeMessage = { type: 'assistant', uuid: 'asst-think', timestamp: '2024-01-15T10:34:00Z', message: { content: [ { type: 'thinking', thinking: 'Let me consider this...' }, { type: 'text', text: 'Here is my answer.' }, ], }, }; const chatMsg = parseSDKMessageToChat(sdkMsg); expect(chatMsg!.contentBlocks).toHaveLength(2); const thinkingBlock = chatMsg!.contentBlocks![0]; expect(thinkingBlock.type).toBe('thinking'); // Type narrowing for thinking block content check expect(thinkingBlock.type === 'thinking' && thinkingBlock.content).toBe('Let me consider this...'); expect(chatMsg!.contentBlocks![1].type).toBe('text'); }); it('preserves text block whitespace in contentBlocks', () => { const sdkMsg: SDKNativeMessage = { type: 'assistant', uuid: 'asst-whitespace', timestamp: '2024-01-15T10:34:30Z', message: { content: [ { type: 'text', text: ' Preserve leading and trailing space ' }, ], }, }; const chatMsg = parseSDKMessageToChat(sdkMsg); expect(chatMsg!.content).toBe(' Preserve leading and trailing space '); expect(chatMsg!.contentBlocks).toEqual([ { type: 'text', content: ' Preserve leading and trailing space ' }, ]); }); it('returns null for system messages', () => { const sdkMsg: SDKNativeMessage = { type: 'system', uuid: 'sys-1', }; const chatMsg = parseSDKMessageToChat(sdkMsg); expect(chatMsg).toBeNull(); }); it('returns synthetic assistant message for compact_boundary system messages', () => { const sdkMsg: SDKNativeMessage = { type: 'system', subtype: 'compact_boundary', uuid: 'compact-1', timestamp: '2024-06-15T12:00:00Z', }; const chatMsg = parseSDKMessageToChat(sdkMsg); expect(chatMsg).not.toBeNull(); expect(chatMsg!.id).toBe('compact-1'); expect(chatMsg!.role).toBe('assistant'); expect(chatMsg!.content).toBe(''); expect(chatMsg!.timestamp).toBe(new Date('2024-06-15T12:00:00Z').getTime()); expect(chatMsg!.contentBlocks).toEqual([{ type: 'compact_boundary' }]); }); it('generates ID for compact_boundary without uuid', () => { const sdkMsg: SDKNativeMessage = { type: 'system', subtype: 'compact_boundary', }; const chatMsg = parseSDKMessageToChat(sdkMsg); expect(chatMsg).not.toBeNull(); expect(chatMsg!.id).toMatch(/^compact-/); }); it('returns null for result messages', () => { const sdkMsg: SDKNativeMessage = { type: 'result', uuid: 'res-1', }; const chatMsg = parseSDKMessageToChat(sdkMsg); expect(chatMsg).toBeNull(); }); it('returns null for file-history-snapshot messages', () => { const sdkMsg: SDKNativeMessage = { type: 'file-history-snapshot', uuid: 'fhs-1', }; const chatMsg = parseSDKMessageToChat(sdkMsg); expect(chatMsg).toBeNull(); }); it('generates ID when uuid is missing', () => { const sdkMsg: SDKNativeMessage = { type: 'user', timestamp: '2024-01-15T10:35:00Z', message: { content: 'No UUID message', }, }; const chatMsg = parseSDKMessageToChat(sdkMsg); expect(chatMsg).not.toBeNull(); expect(chatMsg!.id).toMatch(/^sdk-/); }); it('uses current time when timestamp is missing', () => { const before = Date.now(); const sdkMsg: SDKNativeMessage = { type: 'user', uuid: 'no-time', message: { content: 'No timestamp', }, }; const chatMsg = parseSDKMessageToChat(sdkMsg); const after = Date.now(); expect(chatMsg!.timestamp).toBeGreaterThanOrEqual(before); expect(chatMsg!.timestamp).toBeLessThanOrEqual(after); }); it('marks interrupt messages with isInterrupt flag', () => { const sdkMsg: SDKNativeMessage = { type: 'user', uuid: 'interrupt-1', timestamp: '2024-01-15T10:30:00Z', message: { content: [{ type: 'text', text: '[Request interrupted by user]' }], }, }; const chatMsg = parseSDKMessageToChat(sdkMsg); expect(chatMsg).not.toBeNull(); expect(chatMsg!.isInterrupt).toBe(true); expect(chatMsg!.content).toBe('[Request interrupted by user]'); }); it('does not mark non-canonical interrupt text variants', () => { const sdkMsg: SDKNativeMessage = { type: 'user', uuid: 'interrupt-non-canonical', timestamp: '2024-01-15T10:30:00Z', message: { content: 'prefix [Request interrupted by user]', }, }; const chatMsg = parseSDKMessageToChat(sdkMsg); expect(chatMsg).not.toBeNull(); expect(chatMsg!.isInterrupt).toBeUndefined(); }); it('does not mark regular user messages as interrupt', () => { const sdkMsg: SDKNativeMessage = { type: 'user', uuid: 'user-regular', timestamp: '2024-01-15T10:30:00Z', message: { content: 'Hello, how are you?', }, }; const chatMsg = parseSDKMessageToChat(sdkMsg); expect(chatMsg).not.toBeNull(); expect(chatMsg!.isInterrupt).toBeUndefined(); }); it('marks rebuilt context messages with isRebuiltContext flag', () => { const sdkMsg: SDKNativeMessage = { type: 'user', uuid: 'rebuilt-1', timestamp: '2024-01-15T10:30:00Z', message: { content: 'User: hi\n\nAssistant: Hello!\n\nUser: how are you?', }, }; const chatMsg = parseSDKMessageToChat(sdkMsg); expect(chatMsg).not.toBeNull(); expect(chatMsg!.isRebuiltContext).toBe(true); }); it('marks rebuilt context messages starting with Assistant', () => { const sdkMsg: SDKNativeMessage = { type: 'user', uuid: 'rebuilt-2', timestamp: '2024-01-15T10:31:00Z', message: { content: 'Assistant: Hello\n\nUser: Hi again', }, }; const chatMsg = parseSDKMessageToChat(sdkMsg); expect(chatMsg).not.toBeNull(); expect(chatMsg!.isRebuiltContext).toBe(true); }); it('does not mark regular messages starting with User as rebuilt context', () => { const sdkMsg: SDKNativeMessage = { type: 'user', uuid: 'user-normal', timestamp: '2024-01-15T10:30:00Z', message: { content: 'User settings should be configurable', }, }; const chatMsg = parseSDKMessageToChat(sdkMsg); expect(chatMsg).not.toBeNull(); expect(chatMsg!.isRebuiltContext).toBeUndefined(); }); it('extracts displayContent from user message with current_note tag', () => { const sdkMsg: SDKNativeMessage = { type: 'user', uuid: 'user-note', timestamp: '2024-01-15T10:30:00Z', message: { content: 'Explain this file\n\n<current_note>\nnotes/test.md\n</current_note>', }, }; const chatMsg = parseSDKMessageToChat(sdkMsg); expect(chatMsg).not.toBeNull(); expect(chatMsg!.content).toBe('Explain this file\n\n<current_note>\nnotes/test.md\n</current_note>'); expect(chatMsg!.displayContent).toBe('Explain this file'); }); it('extracts displayContent from user message with editor_selection tag', () => { const sdkMsg: SDKNativeMessage = { type: 'user', uuid: 'user-selection', timestamp: '2024-01-15T10:30:00Z', message: { content: 'Refactor this code\n\n<editor_selection path="src/main.ts">\nfunction foo() {}\n</editor_selection>', }, }; const chatMsg = parseSDKMessageToChat(sdkMsg); expect(chatMsg).not.toBeNull(); expect(chatMsg!.displayContent).toBe('Refactor this code'); }); it('extracts displayContent from user message with multiple context tags', () => { const sdkMsg: SDKNativeMessage = { type: 'user', uuid: 'user-multi', timestamp: '2024-01-15T10:30:00Z', message: { content: 'Update this\n\n<current_note>\ntest.md\n</current_note>\n\n<editor_selection path="test.md">\nselected\n</editor_selection>', }, }; const chatMsg = parseSDKMessageToChat(sdkMsg); expect(chatMsg).not.toBeNull(); expect(chatMsg!.displayContent).toBe('Update this'); }); it('does not set displayContent for plain user messages without XML context', () => { const sdkMsg: SDKNativeMessage = { type: 'user', uuid: 'user-plain', timestamp: '2024-01-15T10:30:00Z', message: { content: 'Just a regular question', }, }; const chatMsg = parseSDKMessageToChat(sdkMsg); expect(chatMsg).not.toBeNull(); expect(chatMsg!.displayContent).toBeUndefined(); }); }); describe('loadSDKSessionMessages', () => { it('loads and converts all messages from session file', async () => { mockExistsSync.mockReturnValue(true); mockFsPromises.readFile.mockResolvedValue([ '{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:00:00Z","message":{"content":"Hello"}}', '{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:01:00Z","message":{"content":[{"type":"text","text":"Hi!"}]}}', '{"type":"system","uuid":"s1"}', '{"type":"user","uuid":"u2","timestamp":"2024-01-15T10:02:00Z","message":{"content":"Thanks"}}', ].join('\n')); const result = await loadSDKSessionMessages('/Users/test/vault', 'session-full'); // Should have 3 messages (system skipped) expect(result.messages).toHaveLength(3); expect(result.messages[0].role).toBe('user'); expect(result.messages[0].content).toBe('Hello'); expect(result.messages[1].role).toBe('assistant'); expect(result.messages[1].content).toBe('Hi!'); expect(result.messages[2].role).toBe('user'); expect(result.messages[2].content).toBe('Thanks'); }); it('sorts messages by timestamp ascending', async () => { mockExistsSync.mockReturnValue(true); mockFsPromises.readFile.mockResolvedValue([ '{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:01:00Z","message":{"content":[{"type":"text","text":"Second"}]}}', '{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:00:00Z","message":{"content":"First"}}', '{"type":"user","uuid":"u2","timestamp":"2024-01-15T10:02:00Z","message":{"content":"Third"}}', ].join('\n')); const result = await loadSDKSessionMessages('/Users/test/vault', 'session-unordered'); expect(result.messages[0].content).toBe('First'); expect(result.messages[1].content).toBe('Second'); expect(result.messages[2].content).toBe('Third'); }); it('returns empty result when session does not exist', async () => { mockExistsSync.mockReturnValue(false); const result = await loadSDKSessionMessages('/Users/test/vault', 'nonexistent'); expect(result.messages).toEqual([]); }); it('matches tool_result from user message to tool_use in assistant message', async () => { mockExistsSync.mockReturnValue(true); mockFsPromises.readFile.mockResolvedValue([ '{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:00:00Z","message":{"content":"Search for cats"}}', '{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:01:00Z","message":{"content":[{"type":"text","text":"Let me search"},{"type":"tool_use","id":"tool-1","name":"WebSearch","input":{"query":"cats"}}]}}', '{"type":"user","uuid":"u2","timestamp":"2024-01-15T10:02:00Z","toolUseResult":{},"message":{"content":[{"type":"tool_result","tool_use_id":"tool-1","content":"Found 10 results"}]}}', '{"type":"assistant","uuid":"a2","timestamp":"2024-01-15T10:03:00Z","message":{"content":[{"type":"text","text":"I found 10 results about cats."}]}}', ].join('\n')); const result = await loadSDKSessionMessages('/Users/test/vault', 'session-cross-tool'); // Should have 2 messages (tool_result-only user skipped, assistant messages merged) expect(result.messages).toHaveLength(2); expect(result.messages[0].content).toBe('Search for cats'); // Merged assistant message has tool calls and combined content expect(result.messages[1].toolCalls).toHaveLength(1); expect(result.messages[1].toolCalls![0].id).toBe('tool-1'); expect(result.messages[1].toolCalls![0].result).toBe('Found 10 results'); expect(result.messages[1].toolCalls![0].status).toBe('completed'); expect(result.messages[1].content).toContain('Let me search'); expect(result.messages[1].content).toContain('I found 10 results about cats.'); }); it('hydrates AskUserQuestion answers from result text when toolUseResult has no answers', async () => { mockExistsSync.mockReturnValue(true); mockFsPromises.readFile.mockResolvedValue([ '{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:01:00Z","message":{"content":[{"type":"tool_use","id":"ask-1","name":"AskUserQuestion","input":{"questions":[{"question":"Color?","options":["Blue","Red"]}]}}]}}', '{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:02:00Z","toolUseResult":{},"message":{"content":[{"type":"tool_result","tool_use_id":"ask-1","content":"\\"Color?\\"=\\"Blue\\""}]}}', ].join('\n')); const result = await loadSDKSessionMessages('/Users/test/vault', 'session-ask-result-fallback'); expect(result.messages).toHaveLength(1); expect(result.messages[0].toolCalls).toHaveLength(1); expect(result.messages[0].toolCalls?.[0].resolvedAnswers).toEqual({ 'Color?': 'Blue' }); }); it('skips user messages that are tool results', async () => { mockExistsSync.mockReturnValue(true); mockFsPromises.readFile.mockResolvedValue([ '{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:00:00Z","message":{"content":"Hello"}}', '{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:01:00Z","message":{"content":[{"type":"tool_use","id":"t1","name":"Bash","input":{}}]}}', '{"type":"user","uuid":"u2","timestamp":"2024-01-15T10:02:00Z","toolUseResult":{},"message":{"content":[{"type":"tool_result","tool_use_id":"t1","content":"done"}]}}', ].join('\n')); const result = await loadSDKSessionMessages('/Users/test/vault', 'session-skip-tool-result'); // Should have 2 messages (tool_result user skipped) expect(result.messages).toHaveLength(2); expect(result.messages[0].role).toBe('user'); expect(result.messages[0].content).toBe('Hello'); expect(result.messages[1].role).toBe('assistant'); }); it('skips skill prompt injection messages (sourceToolUseID)', async () => { mockExistsSync.mockReturnValue(true); mockFsPromises.readFile.mockResolvedValue([ '{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:00:00Z","message":{"content":"/commit"}}', '{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:01:00Z","message":{"content":[{"type":"tool_use","id":"t1","name":"Skill","input":{"skill":"commit"}}]}}', '{"type":"user","uuid":"u2","timestamp":"2024-01-15T10:02:00Z","toolUseResult":{},"message":{"content":[{"type":"tool_result","tool_use_id":"t1","content":"Launching skill: commit"}]}}', '{"type":"user","uuid":"u3","timestamp":"2024-01-15T10:02:01Z","sourceToolUseID":"t1","isMeta":true,"message":{"content":[{"type":"text","text":"## Your task\\n\\nCommit the changes..."}]}}', '{"type":"assistant","uuid":"a2","timestamp":"2024-01-15T10:03:00Z","message":{"content":[{"type":"text","text":"Committing the changes now."}]}}', ].join('\n')); const result = await loadSDKSessionMessages('/Users/test/vault', 'session-skip-skill'); // Should have 2 messages: user query, merged assistant (tool_use + text merged together) // Skill prompt injection (u3) and tool result (u2) should be skipped // Consecutive assistant messages are merged expect(result.messages).toHaveLength(2); expect(result.messages[0].role).toBe('user'); expect(result.messages[0].content).toBe('/commit'); expect(result.messages[1].role).toBe('assistant'); expect(result.messages[1].toolCalls?.[0].name).toBe('Skill'); expect(result.messages[1].content).toContain('Committing'); }); it('skips meta messages without sourceToolUseID', async () => { mockExistsSync.mockReturnValue(true); mockFsPromises.readFile.mockResolvedValue([ '{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:00:00Z","message":{"content":"Hello"}}', '{"type":"user","uuid":"u2","timestamp":"2024-01-15T10:00:01Z","isMeta":true,"message":{"content":"System context injection"}}', '{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:01:00Z","message":{"content":[{"type":"text","text":"Hi there!"}]}}', ].join('\n')); const result = await loadSDKSessionMessages('/Users/test/vault', 'session-skip-meta'); // Should have 2 messages (meta message u2 skipped) expect(result.messages).toHaveLength(2); expect(result.messages[0].role).toBe('user'); expect(result.messages[0].content).toBe('Hello'); expect(result.messages[1].role).toBe('assistant'); }); it('preserves /compact command as user message with clean displayContent', async () => { // File ordering mirrors real SDK JSONL: compact_boundary written BEFORE /compact command. // The timestamp sort must reorder so /compact (earlier) precedes boundary (later). mockExistsSync.mockReturnValue(true); mockFsPromises.readFile.mockResolvedValue([ '{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:00:00Z","message":{"content":"Hello"}}', '{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:01:00Z","message":{"content":[{"type":"text","text":"Hi!"}]}}', '{"type":"system","subtype":"compact_boundary","uuid":"c1","timestamp":"2024-01-15T10:02:10Z"}', '{"type":"user","uuid":"u2","timestamp":"2024-01-15T10:02:00Z","isMeta":true,"message":{"content":"<local-command-caveat>Caveat</local-command-caveat>"}}', '{"type":"user","uuid":"u3","timestamp":"2024-01-15T10:02:01Z","message":{"content":"<command-name>/compact</command-name>\\n<command-message>compact</command-message>\\n<command-args></command-args>"}}', '{"type":"user","uuid":"u4","timestamp":"2024-01-15T10:02:11Z","message":{"content":"<local-command-stdout>Compacted </local-command-stdout>"}}', ].join('\n')); const result = await loadSDKSessionMessages('/Users/test/vault', 'session-compact'); // Should have: user "Hello", assistant "Hi!", user "/compact", assistant compact_boundary // Meta (u2), stdout (u4) should be skipped // /compact (10:02:01) sorted before compact_boundary (10:02:10) by timestamp expect(result.messages).toHaveLength(4); expect(result.messages[0].role).toBe('user'); expect(result.messages[0].content).toBe('Hello'); expect(result.messages[1].role).toBe('assistant'); expect(result.messages[2].role).toBe('user'); expect(result.messages[2].displayContent).toBe('/compact'); expect(result.messages[3].role).toBe('assistant'); expect(result.messages[3].contentBlocks).toEqual([{ type: 'compact_boundary' }]); }); it('renders compact cancellation stderr as interrupt (not filtered)', async () => { mockExistsSync.mockReturnValue(true); mockFsPromises.readFile.mockResolvedValue([ '{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:00:00Z","message":{"content":"Hello"}}', '{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:01:00Z","message":{"content":[{"type":"text","text":"Hi!"}]}}', '{"type":"user","uuid":"u2","timestamp":"2024-01-15T10:02:00Z","message":{"content":"<command-name>/compact</command-name>\\n<command-message>compact</command-message>\\n<command-args></command-args>"}}', '{"type":"user","uuid":"u3","timestamp":"2024-01-15T10:02:01Z","message":{"content":"<local-command-stderr>Error: Compaction canceled.</local-command-stderr>"}}', ].join('\n')); const result = await loadSDKSessionMessages('/Users/test/vault', 'session-compact-cancel'); // Compact cancellation stderr should appear as interrupt, not be filtered const interruptMsg = result.messages.find(m => m.isInterrupt); expect(interruptMsg).toBeDefined(); expect(interruptMsg!.isInterrupt).toBe(true); }); it('does not treat embedded compaction stderr mentions as interrupt markers', async () => { mockExistsSync.mockReturnValue(true); mockFsPromises.readFile.mockResolvedValue([ '{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:00:00Z","message":{"content":"Hello"}}', '{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:01:00Z","message":{"content":[{"type":"text","text":"Hi!"}]}}', '{"type":"user","uuid":"u2","timestamp":"2024-01-15T10:02:01Z","message":{"content":"## Context\\n<local-command-stderr>Error: Compaction canceled.</local-command-stderr>"}}', ].join('\n')); const result = await loadSDKSessionMessages('/Users/test/vault', 'session-compact-quoted-cancel'); expect(result.messages).toHaveLength(2); expect(result.messages.some(m => m.isInterrupt)).toBe(false); }); it('preserves slash command invocations with clean displayContent', async () => { mockExistsSync.mockReturnValue(true); mockFsPromises.readFile.mockResolvedValue([ '{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:00:00Z","message":{"content":"Hello"}}', '{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:01:00Z","message":{"content":[{"type":"text","text":"Hi!"}]}}', '{"type":"user","uuid":"u2","timestamp":"2024-01-15T10:02:00Z","message":{"content":"<command-message>md2docx</command-message>\\n<command-name>/md2docx</command-name>"}}', '{"type":"user","uuid":"u3","timestamp":"2024-01-15T10:02:00Z","isMeta":true,"message":{"content":"Use bash command md2word..."}}', '{"type":"assistant","uuid":"a2","timestamp":"2024-01-15T10:03:00Z","message":{"content":[{"type":"text","text":"(no content)"}]}}', '{"type":"assistant","uuid":"a3","timestamp":"2024-01-15T10:03:01Z","message":{"content":[{"type":"tool_use","id":"t1","name":"Skill","input":{"skill":"md2docx"}}]}}', ].join('\n')); const result = await loadSDKSessionMessages('/Users/test/vault', 'session-slash-cmd'); // user "Hello", assistant "Hi!", user "/md2docx", assistant with Skill tool // META (u3) should be skipped; "(no content)" text should be filtered expect(result.messages).toHaveLength(4); expect(result.messages[2].role).toBe('user'); expect(result.messages[2].displayContent).toBe('/md2docx'); expect(result.messages[3].role).toBe('assistant'); expect(result.messages[3].content).toBe(''); expect(result.messages[3].toolCalls).toHaveLength(1); expect(result.messages[3].toolCalls![0].name).toBe('Skill'); }); it('handles tool_result with error flag', async () => { mockExistsSync.mockReturnValue(true); mockFsPromises.readFile.mockResolvedValue([ '{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:00:00Z","message":{"content":[{"type":"tool_use","id":"t1","name":"Bash","input":{"command":"invalid"}}]}}', '{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:01:00Z","toolUseResult":{},"message":{"content":[{"type":"tool_result","tool_use_id":"t1","content":"Command not found","is_error":true}]}}', ].join('\n')); const result = await loadSDKSessionMessages('/Users/test/vault', 'session-error-result'); expect(result.messages).toHaveLength(1); expect(result.messages[0].toolCalls![0].status).toBe('error'); expect(result.messages[0].toolCalls![0].result).toBe('Command not found'); }); it('returns error pass-through from readSDKSession', async () => { mockExistsSync.mockReturnValue(true); mockFsPromises.readFile.mockRejectedValue(new Error('Disk failure')); const result = await loadSDKSessionMessages('/Users/test/vault', 'session-disk-err'); expect(result.messages).toEqual([]); expect(result.error).toBe('Disk failure'); }); it('merges tool calls from consecutive assistant messages', async () => { mockExistsSync.mockReturnValue(true); mockFsPromises.readFile.mockResolvedValue([ '{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:00:00Z","message":{"content":[{"type":"tool_use","id":"t1","name":"Read","input":{"path":"a.ts"}}]}}', '{"type":"assistant","uuid":"a2","timestamp":"2024-01-15T10:00:01Z","message":{"content":[{"type":"tool_use","id":"t2","name":"Write","input":{"path":"b.ts"}}]}}', ].join('\n')); const result = await loadSDKSessionMessages('/Users/test/vault', 'session-merge-tools'); // Consecutive assistant messages should merge into one expect(result.messages).toHaveLength(1); expect(result.messages[0].toolCalls).toHaveLength(2); expect(result.messages[0].toolCalls![0].name).toBe('Read'); expect(result.messages[0].toolCalls![1].name).toBe('Write'); }); it('updates sdkAssistantUuid to last entry when merging assistant messages', async () => { mockExistsSync.mockReturnValue(true); mockFsPromises.readFile.mockResolvedValue([ '{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:00:00Z","message":{"content":"hello"}}', '{"type":"assistant","uuid":"a1-first","parentUuid":"u1","timestamp":"2024-01-15T10:00:01Z","message":{"content":[{"type":"text","text":"thinking..."}]}}', '{"type":"assistant","uuid":"a1-mid","parentUuid":"a1-first","timestamp":"2024-01-15T10:00:02Z","message":{"content":[{"type":"tool_use","id":"t1","name":"Read","input":{"path":"a.ts"}}]}}', '{"type":"assistant","uuid":"a1-last","parentUuid":"a1-mid","timestamp":"2024-01-15T10:00:03Z","message":{"content":[{"type":"text","text":"Done!"}]}}', ].join('\n')); const result = await loadSDKSessionMessages('/Users/test/vault', 'session-merge-uuid'); expect(result.messages).toHaveLength(2); const assistant = result.messages[1]; expect(assistant.role).toBe('assistant'); // Must be the last UUID so rewind targets the end of the turn expect(assistant.sdkAssistantUuid).toBe('a1-last'); }); }); describe('parseSDKMessageToChat - image extraction', () => { it('extracts image attachments from user message with image blocks', () => { const sdkMsg: SDKNativeMessage = { type: 'user', uuid: 'user-img', timestamp: '2024-01-15T10:30:00Z', message: { content: [ { type: 'text', text: 'Check this image' }, { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk', }, }, ], }, }; const chatMsg = parseSDKMessageToChat(sdkMsg); expect(chatMsg).not.toBeNull(); expect(chatMsg!.images).toHaveLength(1); expect(chatMsg!.images![0].mediaType).toBe('image/png'); expect(chatMsg!.images![0].data).toContain('iVBORw0KGgo'); expect(chatMsg!.images![0].source).toBe('paste'); expect(chatMsg!.images![0].name).toBe('image-1'); }); it('does not extract images from assistant messages', () => { const sdkMsg: SDKNativeMessage = { type: 'assistant', uuid: 'asst-img', timestamp: '2024-01-15T10:30:00Z', message: { content: [ { type: 'text', text: 'Here is a response' }, ], }, }; const chatMsg = parseSDKMessageToChat(sdkMsg); expect(chatMsg).not.toBeNull(); expect(chatMsg!.images).toBeUndefined(); }); it('returns null for user message with only tool_result content blocks', () => { const sdkMsg: SDKNativeMessage = { type: 'user', uuid: 'user-tool-only', timestamp: '2024-01-15T10:30:00Z', message: { content: [ { type: 'tool_result', tool_use_id: 't1', content: 'result data' }, ], }, }; const chatMsg = parseSDKMessageToChat(sdkMsg); // Array content bypasses the null-return guard even without text/tool_use/images expect(chatMsg).not.toBeNull(); }); it('returns null for user message with empty string content', () => { const sdkMsg: SDKNativeMessage = { type: 'user', uuid: 'user-empty', timestamp: '2024-01-15T10:30:00Z', message: { content: '', }, }; const chatMsg = parseSDKMessageToChat(sdkMsg); expect(chatMsg).toBeNull(); }); it('returns null for user message with no content', () => { const sdkMsg: SDKNativeMessage = { type: 'user', uuid: 'user-nocontent', timestamp: '2024-01-15T10:30:00Z', message: {}, }; const chatMsg = parseSDKMessageToChat(sdkMsg); expect(chatMsg).toBeNull(); }); it('returns null for queue-operation messages', () => { const sdkMsg: SDKNativeMessage = { type: 'queue-operation', uuid: 'queue-1', }; const chatMsg = parseSDKMessageToChat(sdkMsg); expect(chatMsg).toBeNull(); }); }); describe('parseSDKMessageToChat - content block edge cases', () => { it('skips text blocks that are whitespace-only in contentBlocks', () => { const sdkMsg: SDKNativeMessage = { type: 'assistant', uuid: 'asst-whitespace', timestamp: '2024-01-15T10:30:00Z', message: { content: [ { type: 'text', text: ' ' }, { type: 'text', text: 'Actual content' }, ], }, }; const chatMsg = parseSDKMessageToChat(sdkMsg); expect(chatMsg).not.toBeNull(); // The whitespace-only text block should be skipped in contentBlocks expect(chatMsg!.contentBlocks).toHaveLength(1); expect(chatMsg!.contentBlocks![0].type).toBe('text'); }); it('skips thinking blocks with empty thinking field', () => { const sdkMsg: SDKNativeMessage = { type: 'assistant', uuid: 'asst-empty-think', timestamp: '2024-01-15T10:30:00Z', message: { content: [ { type: 'thinking', thinking: '' }, { type: 'text', text: 'Some answer' }, ], }, }; const chatMsg = parseSDKMessageToChat(sdkMsg); expect(chatMsg).not.toBeNull(); // Empty thinking block should be skipped expect(chatMsg!.contentBlocks).toHaveLength(1); expect(chatMsg!.contentBlocks![0].type).toBe('text'); }); it('skips tool_use blocks without id in contentBlocks', () => { const sdkMsg: SDKNativeMessage = { type: 'assistant', uuid: 'asst-no-id-tool', timestamp: '2024-01-15T10:30:00Z', message: { content: [ { type: 'tool_use', name: 'Bash', input: {} }, { type: 'text', text: 'After tool' }, ], }, }; const chatMsg = parseSDKMessageToChat(sdkMsg); expect(chatMsg).not.toBeNull(); // tool_use without id should be skipped in contentBlocks expect(chatMsg!.contentBlocks).toHaveLength(1); expect(chatMsg!.contentBlocks![0].type).toBe('text'); }); it('returns undefined contentBlocks when all blocks are filtered out', () => { const sdkMsg: SDKNativeMessage = { type: 'assistant', uuid: 'asst-all-filtered', timestamp: '2024-01-15T10:30:00Z', message: { content: [ { type: 'tool_result', tool_use_id: 't1', content: 'result' }, ], }, }; const chatMsg = parseSDKMessageToChat(sdkMsg); // Content is array (so not null), but all blocks filtered → undefined contentBlocks expect(chatMsg).not.toBeNull(); expect(chatMsg!.contentBlocks).toBeUndefined(); }); it('handles tool_use without input field', () => { const sdkMsg: SDKNativeMessage = { type: 'assistant', uuid: 'asst-no-input', timestamp: '2024-01-15T10:30:00Z', message: { content: [ { type: 'tool_use', id: 'tool-noinput', name: 'SomeTool' }, ], }, }; const chatMsg = parseSDKMessageToChat(sdkMsg); expect(chatMsg).not.toBeNull(); expect(chatMsg!.toolCalls).toHaveLength(1); expect(chatMsg!.toolCalls![0].input).toEqual({}); }); it('handles tool_result with non-string content (JSON object)', () => { const sdkMsg: SDKNativeMessage = { type: 'assistant', uuid: 'asst-json-result', timestamp: '2024-01-15T10:30:00Z', message: { content: [ { type: 'tool_use', id: 'tool-json', name: 'Read', input: {} }, { type: 'tool_result', tool_use_id: 'tool-json', content: { file: 'test.ts', lines: 42 }, }, ], }, }; const chatMsg = parseSDKMessageToChat(sdkMsg); expect(chatMsg).not.toBeNull(); expect(chatMsg!.toolCalls).toHaveLength(1); // Non-string content should be JSON.stringified expect(chatMsg!.toolCalls![0].result).toBe('{"file":"test.ts","lines":42}'); }); }); describe('parseSDKMessageToChat - rebuilt context with A: shorthand', () => { it('detects rebuilt context using A: shorthand marker', () => { const sdkMsg: SDKNativeMessage = { type: 'user', uuid: 'rebuilt-short', timestamp: '2024-01-15T10:30:00Z', message: { content: 'User: hello\n\nA: hi there', }, }; const chatMsg = parseSDKMessageToChat(sdkMsg); expect(chatMsg).not.toBeNull(); expect(chatMsg!.isRebuiltContext).toBe(true); }); }); describe('parseSDKMessageToChat - interrupt tool use variant', () => { it('marks tool use interrupt messages', () => { const sdkMsg: SDKNativeMessage = { type: 'user', uuid: 'interrupt-tool', timestamp: '2024-01-15T10:30:00Z', message: { content: '[Request interrupted by user for tool use]', }, }; const chatMsg = parseSDKMessageToChat(sdkMsg); expect(chatMsg).not.toBeNull(); expect(chatMsg!.isInterrupt).toBe(true); }); it('does not mark quoted compact cancellation mention as interrupt', () => { const sdkMsg: SDKNativeMessage = { type: 'user', uuid: 'interrupt-compact-quoted', timestamp: '2024-01-15T10:30:00Z', message: { content: '## Context\n<local-command-stderr>Error: Compaction canceled.</local-command-stderr>', }, }; const chatMsg = parseSDKMessageToChat(sdkMsg); expect(chatMsg).not.toBeNull(); expect(chatMsg!.isInterrupt).toBeUndefined(); }); it('marks compact cancellation stderr as interrupt', () => { const sdkMsg: SDKNativeMessage = { type: 'user', uuid: 'interrupt-compact', timestamp: '2024-01-15T10:30:00Z', message: { content: '<local-command-stderr>Error: Compaction canceled.</local-command-stderr>', }, }; const chatMsg = parseSDKMessageToChat(sdkMsg); expect(chatMsg).not.toBeNull(); expect(chatMsg!.isInterrupt).toBe(true); }); }); describe('loadSDKSessionMessages - merge edge cases', () => { it('merges assistant content blocks when first has no content blocks', async () => { mockExistsSync.mockReturnValue(true); mockFsPromises.readFile.mockResolvedValue([ '{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:00:00Z","message":{"content":[{"type":"tool_use","id":"t1","name":"Bash","input":{}}]}}', '{"type":"assistant","uuid":"a2","timestamp":"2024-01-15T10:00:01Z","message":{"content":[{"type":"thinking","thinking":"hmm"},{"type":"text","text":"Result here"}]}}', ].join('\n')); const result = await loadSDKSessionMessages('/Users/test/vault', 'session-merge-blocks'); expect(result.messages).toHaveLength(1); // Merged: tool call from first + content blocks from both expect(result.messages[0].toolCalls).toHaveLength(1); expect(result.messages[0].contentBlocks!.length).toBeGreaterThanOrEqual(2); }); it('merges assistant with empty target content', async () => { mockExistsSync.mockReturnValue(true); mockFsPromises.readFile.mockResolvedValue([ // First assistant: only tool_use, no text '{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:00:00Z","message":{"content":[{"type":"tool_use","id":"t1","name":"Bash","input":{}}]}}', // Second assistant: has text content '{"type":"assistant","uuid":"a2","timestamp":"2024-01-15T10:00:01Z","message":{"content":[{"type":"text","text":"Here is the result"}]}}', ].join('\n')); const result = await loadSDKSessionMessages('/Users/test/vault', 'session-merge-empty-target'); expect(result.messages).toHaveLength(1); expect(result.messages[0].content).toBe('Here is the result'); }); it('handles multiple user images in a message', async () => { mockExistsSync.mockReturnValue(true); mockFsPromises.readFile.mockResolvedValue([ JSON.stringify({ type: 'user', uuid: 'u-imgs', timestamp: '2024-01-15T10:00:00Z', message: { content: [ { type: 'text', text: 'Check these images' }, { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'abc123' } }, { type: 'image', source: { type: 'base64', media_type: 'image/jpeg', data: 'def456' } }, ], }, }), ].join('\n')); const result = await loadSDKSessionMessages('/Users/test/vault', 'session-multi-images'); expect(result.messages).toHaveLength(1); expect(result.messages[0].images).toHaveLength(2); expect(result.messages[0].images![0].mediaType).toBe('image/png'); expect(result.messages[0].images![1].mediaType).toBe('image/jpeg'); expect(result.messages[0].images![1].name).toBe('image-2'); }); it('extracts text from Agent tool results with array content', async () => { mockExistsSync.mockReturnValue(true); mockFsPromises.readFile.mockResolvedValue([ JSON.stringify({ type: 'user', uuid: 'u1', timestamp: '2024-01-15T10:00:00Z', message: { content: 'Use an agent' }, }), JSON.stringify({ type: 'assistant', uuid: 'a1', parentUuid: 'u1', timestamp: '2024-01-15T10:00:01Z', message: { content: [{ type: 'tool_use', id: 'agent-1', name: 'Agent', input: { description: 'test', prompt: 'do stuff' } }] }, }), // Agent tool result has array content (not string) JSON.stringify({ type: 'user', uuid: 'tr1', parentUuid: 'a1', timestamp: '2024-01-15T10:00:30Z', toolUseResult: { status: 'completed', agentId: 'abc123' }, message: { content: [{ type: 'tool_result', tool_use_id: 'agent-1', is_error: false, content: [ { type: 'text', text: 'Agent completed the task successfully.' }, { type: 'text', text: 'agentId: abc123\n<usage>total_tokens: 500</usage>' }, ], }] }, }), JSON.stringify({ type: 'assistant', uuid: 'a2', parentUuid: 'tr1', timestamp: '2024-01-15T10:00:31Z', message: { content: [{ type: 'text', text: 'Done.' }] }, }), ].join('\n')); const result = await loadSDKSessionMessages('/Users/test/vault', 'session-agent-result'); // The Agent tool call should have extracted text, not JSON.stringify'd array const assistantMsg = result.messages.find(m => m.role === 'assistant' && m.toolCalls?.length); expect(assistantMsg).toBeDefined(); const agentToolCall = assistantMsg!.toolCalls!.find(tc => tc.name === 'Agent'); expect(agentToolCall).toBeDefined(); expect(agentToolCall!.result).toBe( 'Agent completed the task successfully.\nagentId: abc123\n<usage>total_tokens: 500</usage>' ); // Must NOT contain JSON artifacts expect(agentToolCall!.result).not.toContain('"type":"text"'); }); }); describe('filterActiveBranch', () => { it('returns all entries for linear chain without resumeSessionAt', () => { const entries: SDKNativeMessage[] = [ { type: 'user', uuid: 'u1', parentUuid: null }, { type: 'assistant', uuid: 'a1', parentUuid: 'u1' }, { type: 'user', uuid: 'u2', parentUuid: 'a1' }, { type: 'assistant', uuid: 'a2', parentUuid: 'u2' }, ]; const result = filterActiveBranch(entries); expect(result).toHaveLength(4); expect(result).toEqual(entries); }); it('truncates linear chain at resumeSessionAt UUID', () => { const entries: SDKNativeMessage[] = [ { type: 'user', uuid: 'u1', parentUuid: null }, { type: 'assistant', uuid: 'a1', parentUuid: 'u1' }, { type: 'user', uuid: 'u2', parentUuid: 'a1' }, { type: 'assistant', uuid: 'a2', parentUuid: 'u2' }, ]; const result = filterActiveBranch(entries, 'a1'); expect(result).toHaveLength(2); expect(result.map(e => e.uuid)).toEqual(['u1', 'a1']); }); it('returns only new branch after rewind + follow-up', () => { // Original: u1 → a1 → u2 → a2 // Rewind to a1, follow-up: u3 → a3 (u3.parentUuid = a1) const entries: SDKNativeMessage[] = [ { type: 'user', uuid: 'u1', parentUuid: null }, { type: 'assistant', uuid: 'a1', parentUuid: 'u1' }, { type: 'user', uuid: 'u2', parentUuid: 'a1' }, { type: 'assistant', uuid: 'a2', parentUuid: 'u2' }, { type: 'user', uuid: 'u3', parentUuid: 'a1' }, // Branch point: a1 has 2 children { type: 'assistant', uuid: 'a3', parentUuid: 'u3' }, ]; const result = filterActiveBranch(entries); // Should include: u1, a1, u3, a3 (new branch), not u2, a2 expect(result.map(e => e.uuid)).toEqual(['u1', 'a1', 'u3', 'a3']); }); it('returns latest branch after multiple rewinds', () => { // Original: u1 → a1 → u2 → a2 // Rewind 1: u3 → a3 (parent a1) // Rewind 2: u4 → a4 (parent a1) — third child of a1 const entries: SDKNativeMessage[] = [ { type: 'user', uuid: 'u1', parentUuid: null }, { type: 'assistant', uuid: 'a1', parentUuid: 'u1' }, { type: 'user', uuid: 'u2', parentUuid: 'a1' }, { type: 'assistant', uuid: 'a2', parentUuid: 'u2' }, { type: 'user', uuid: 'u3', parentUuid: 'a1' }, { type: 'assistant', uuid: 'a3', parentUuid: 'u3' }, { type: 'user', uuid: 'u4', parentUuid: 'a1' }, { type: 'assistant', uuid: 'a4', parentUuid: 'u4' }, ]; const result = filterActiveBranch(entries); // Last entry with uuid is a4, walk back: a4 → u4 → a1 → u1 expect(result.map(e => e.uuid)).toEqual(['u1', 'a1', 'u4', 'a4']); }); it('returns all entries when resumeSessionAt UUID not found (safety)', () => { const entries: SDKNativeMessage[] = [ { type: 'user', uuid: 'u1', parentUuid: null }, { type: 'assistant', uuid: 'a1', parentUuid: 'u1' }, ]; const result = filterActiveBranch(entries, 'nonexistent-uuid'); expect(result).toHaveLength(2); expect(result).toEqual(entries); }); it('returns empty for empty entries', () => { const result = filterActiveBranch([]); expect(result).toEqual([]); }); it('does not misdetect branching when duplicate uuid entries exist', () => { // SDK may write the same message twice (e.g., around compaction). // Without dedup, duplicate entries inflate childCount causing false branch detection. const entries: SDKNativeMessage[] = [ { type: 'user', uuid: 'u1', parentUuid: null }, { type: 'assistant', uuid: 'a1', parentUuid: 'u1' }, { type: 'user', uuid: 'u2', parentUuid: 'a1' }, { type: 'assistant', uuid: 'a2', parentUuid: 'u2' }, // Duplicate of u2 — SDK writes this again { type: 'user', uuid: 'u2', parentUuid: 'a1' }, ]; // Without dedup fix, a1 would have childCount=2 (u2 counted twice), // triggering branch detection and excluding u2/a2. const result = filterActiveBranch(entries); // Should be a no-op (linear chain, no branching) expect(result).toHaveLength(4); expect(result.map(e => e.uuid)).toEqual(['u1', 'a1', 'u2', 'a2']); }); it('correctly truncates at resumeSessionAt when duplicates exist', () => { const entries: SDKNativeMessage[] = [ { type: 'user', uuid: 'u1', parentUuid: null }, { type: 'assistant', uuid: 'a1', parentUuid: 'u1' }, { type: 'user', uuid: 'u2', parentUuid: 'a1' }, { type: 'assistant', uuid: 'a2', parentUuid: 'u2' }, // Duplicate of u2 { type: 'user', uuid: 'u2', parentUuid: 'a1' }, ]; const result = filterActiveBranch(entries, 'a1'); // Should truncate at a1, including only u1 and a1 expect(result).toHaveLength(2); expect(result.map(e => e.uuid)).toEqual(['u1', 'a1']); }); it('preserves no-uuid entries within active branch region', () => { const entries: SDKNativeMessage[] = [ { type: 'user', uuid: 'u1', parentUuid: null }, { type: 'queue-operation' }, // No uuid — between u1 (active) and a1 (active) { type: 'assistant', uuid: 'a1', parentUuid: 'u1' }, { type: 'user', uuid: 'u2', parentUuid: 'a1' }, { type: 'assistant', uuid: 'a2', parentUuid: 'u2' }, { type: 'user', uuid: 'u3', parentUuid: 'a1' }, // Branch { type: 'assistant', uuid: 'a3', parentUuid: 'u3' }, ]; const result = filterActiveBranch(entries); const uuids = result.filter(e => e.uuid).map(e => e.uuid); expect(uuids).toEqual(['u1', 'a1', 'u3', 'a3']); // queue-operation is between u1 (active) and a1 (active), so preserved expect(result.some(e => e.type === 'queue-operation')).toBe(true); }); it('truncates at resumeSessionAt on latest branch when branching exists', () => { // Rewind 1 + follow-up created a branch: u3/a3 branch off a1 // Rewind 2 on the new branch (no follow-up): resumeSessionAt = a1 // On reload, should truncate at a1, not show u3/a3 const entries: SDKNativeMessage[] = [ { type: 'user', uuid: 'u1', parentUuid: null }, { type: 'assistant', uuid: 'a1', parentUuid: 'u1' }, { type: 'user', uuid: 'u2', parentUuid: 'a1' }, // old branch { type: 'assistant', uuid: 'a2', parentUuid: 'u2' }, // old branch { type: 'user', uuid: 'u3', parentUuid: 'a1' }, // new branch (from rewind 1) { type: 'assistant', uuid: 'a3', parentUuid: 'u3' }, // new branch ]; // Rewind 2 on new branch: truncate at a1 const result = filterActiveBranch(entries, 'a1'); expect(result.map(e => e.uuid)).toEqual(['u1', 'a1']); }); it('truncates at resumeSessionAt mid-branch when branching exists', () => { // Branch from a1: old (u2→a2) and new (u3→a3→u4→a4) // Rewind on new branch to u4: resumeSessionAt = a3 const entries: SDKNativeMessage[] = [ { type: 'user', uuid: 'u1', parentUuid: null }, { type: 'assistant', uuid: 'a1', parentUuid: 'u1' }, { type: 'user', uuid: 'u2', parentUuid: 'a1' }, { type: 'assistant', uuid: 'a2', parentUuid: 'u2' }, { type: 'user', uuid: 'u3', parentUuid: 'a1' }, { type: 'assistant', uuid: 'a3', parentUuid: 'u3' }, { type: 'user', uuid: 'u4', parentUuid: 'a3' }, { type: 'assistant', uuid: 'a4', parentUuid: 'u4' }, ]; const result = filterActiveBranch(entries, 'a3'); expect(result.map(e => e.uuid)).toEqual(['u1', 'a1', 'u3', 'a3']); }); it('ignores resumeSessionAt not on latest branch', () => { // Branch from a1: old (u2→a2) and new (u3→a3) // resumeSessionAt points to a2 (on the OLD branch) — should be ignored const entries: SDKNativeMessage[] = [ { type: 'user', uuid: 'u1', parentUuid: null }, { type: 'assistant', uuid: 'a1', parentUuid: 'u1' }, { type: 'user', uuid: 'u2', parentUuid: 'a1' }, { type: 'assistant', uuid: 'a2', parentUuid: 'u2' }, { type: 'user', uuid: 'u3', parentUuid: 'a1' }, { type: 'assistant', uuid: 'a3', parentUuid: 'u3' }, ]; // a2 is on old branch, not an ancestor of leaf a3 const result = filterActiveBranch(entries, 'a2'); // Should return full latest branch (ignoring stale resumeSessionAt) expect(result.map(e => e.uuid)).toEqual(['u1', 'a1', 'u3', 'a3']); }); it('drops no-uuid entries in old branch region', () => { const entries: SDKNativeMessage[] = [ { type: 'user', uuid: 'u1', parentUuid: null }, { type: 'assistant', uuid: 'a1', parentUuid: 'u1' }, { type: 'user', uuid: 'u2', parentUuid: 'a1' }, { type: 'assistant', uuid: 'a2', parentUuid: 'u2' }, { type: 'queue-operation' }, // No uuid — between a2 (old) and u3 (active) { type: 'user', uuid: 'u3', parentUuid: 'a1' }, // Branch { type: 'assistant', uuid: 'a3', parentUuid: 'u3' }, ]; const result = filterActiveBranch(entries); const uuids = result.filter(e => e.uuid).map(e => e.uuid); expect(uuids).toEqual(['u1', 'a1', 'u3', 'a3']); // queue-operation between a2 (not active) and u3 (active) — should be dropped expect(result.some(e => e.type === 'queue-operation')).toBe(false); }); it('excludes progress entries and does not treat them as branches', () => { // Simulates Agent tool call: assistant issues tool_use, SDK writes progress chain, // then next user message is parented to end of progress chain. // Without fix: progress creates false branching, losing the conversation branch. const entries: SDKNativeMessage[] = [ { type: 'user', uuid: 'u1', parentUuid: null }, { type: 'assistant', uuid: 'a1', parentUuid: 'u1' }, // a1 is a tool_use (Agent). SDK writes tool_result + progress chain as siblings: { type: 'user', uuid: 'tr1', parentUuid: 'a1', toolUseResult: {} }, // tool result { type: 'assistant', uuid: 'a2', parentUuid: 'tr1' }, // response after tool // Progress chain branching off a1 (subagent execution logs): { type: 'progress' as SDKNativeMessage['type'], uuid: 'p1', parentUuid: 'a1' }, { type: 'progress' as SDKNativeMessage['type'], uuid: 'p2', parentUuid: 'p1' }, { type: 'progress' as SDKNativeMessage['type'], uuid: 'p3', parentUuid: 'p2' }, // Next conversation message parented to end of progress chain: { type: 'user', uuid: 'u2', parentUuid: 'p3' }, { type: 'assistant', uuid: 'a3', parentUuid: 'u2' }, ]; const result = filterActiveBranch(entries); const uuids = result.filter(e => e.uuid).map(e => e.uuid); // All conversation entries should be present, progress entries excluded expect(uuids).toEqual(['u1', 'a1', 'tr1', 'a2', 'u2', 'a3']); expect(result.every(e => (e.type as string) !== 'progress')).toBe(true); }); it('reparents through long progress chains to preserve full conversation', () => { // Two turns, each with Agent tool calls generating progress entries. // The second turn's user message is parented to the end of the first progress chain. const entries: SDKNativeMessage[] = [ { type: 'user', uuid: 'u1', parentUuid: null }, { type: 'assistant', uuid: 'a1-think', parentUuid: 'u1' }, { type: 'assistant', uuid: 'a1-tool', parentUuid: 'a1-think' }, // Agent tool_use // Conversation branch: tool result → assistant response { type: 'user', uuid: 'tr1', parentUuid: 'a1-tool', toolUseResult: {} }, { type: 'assistant', uuid: 'a1-think2', parentUuid: 'tr1' }, { type: 'assistant', uuid: 'a1-text', parentUuid: 'a1-think2' }, // Progress chain off a1-tool: { type: 'progress' as SDKNativeMessage['type'], uuid: 'p1', parentUuid: 'a1-tool' }, { type: 'progress' as SDKNativeMessage['type'], uuid: 'p2', parentUuid: 'p1' }, // System entry chained to progress: { type: 'system', uuid: 'sys1', parentUuid: 'p2' }, // Second turn parented to system (which is parented to progress chain): { type: 'user', uuid: 'u2', parentUuid: 'sys1' }, { type: 'assistant', uuid: 'a2', parentUuid: 'u2' }, ]; const result = filterActiveBranch(entries); const uuids = result.filter(e => e.uuid).map(e => e.uuid); // Must include BOTH turns' content — nothing lost expect(uuids).toContain('a1-text'); // First turn's response expect(uuids).toContain('u2'); // Second turn's input expect(uuids).toContain('a2'); // Second turn's response // Progress entries must be excluded expect(uuids).not.toContain('p1'); expect(uuids).not.toContain('p2'); }); it('does not treat parallel tool calls as branches', () => { // Assistant sends two tool_use blocks in parallel. SDK writes them as // separate entries. Their tool results are parented to respective entries. const entries: SDKNativeMessage[] = [ { type: 'user', uuid: 'u1', parentUuid: null }, { type: 'assistant', uuid: 'a1-text', parentUuid: 'u1' }, { type: 'assistant', uuid: 'a1-tool1', parentUuid: 'a1-text' }, // first tool_use { type: 'assistant', uuid: 'a1-tool2', parentUuid: 'a1-text' }, // second tool_use (parallel) // Tool results: { type: 'user', uuid: 'tr1', parentUuid: 'a1-tool1', toolUseResult: {} }, { type: 'user', uuid: 'tr2', parentUuid: 'a1-tool2', toolUseResult: {} }, // Assistant continues after both results: { type: 'assistant', uuid: 'a2', parentUuid: 'tr2' }, ]; const result = filterActiveBranch(entries); const uuids = result.filter(e => e.uuid).map(e => e.uuid); // Both tool calls and their results should be present expect(uuids).toContain('a1-tool1'); expect(uuids).toContain('a1-tool2'); expect(uuids).toContain('tr1'); expect(uuids).toContain('tr2'); expect(uuids).toContain('a2'); }); it('handles real rewind alongside progress entries', () => { // Turn 1 with Agent tool (progress entries), then a real rewind at a1. const entries: SDKNativeMessage[] = [ { type: 'user', uuid: 'u1', parentUuid: null }, { type: 'assistant', uuid: 'a1', parentUuid: 'u1' }, // Original continuation: { type: 'user', uuid: 'u2-old', parentUuid: 'a1' }, { type: 'assistant', uuid: 'a2-old', parentUuid: 'u2-old' }, // Progress entries off a1: { type: 'progress' as SDKNativeMessage['type'], uuid: 'p1', parentUuid: 'a1' }, { type: 'progress' as SDKNativeMessage['type'], uuid: 'p2', parentUuid: 'p1' }, // Rewind: new user message also branching off a1 { type: 'user', uuid: 'u2-new', parentUuid: 'a1' }, { type: 'assistant', uuid: 'a2-new', parentUuid: 'u2-new' }, ]; const result = filterActiveBranch(entries); const uuids = result.filter(e => e.uuid).map(e => e.uuid); // Should follow the latest branch (u2-new), not the old one or progress expect(uuids).toEqual(['u1', 'a1', 'u2-new', 'a2-new']); }); it('detects rewind when abandoned path continues through assistant/tool nodes', () => { const entries: SDKNativeMessage[] = [ { type: 'user', uuid: 'u1', parentUuid: null }, { type: 'assistant', uuid: 'a1', parentUuid: 'u1' }, { type: 'assistant', uuid: 'a1-tool', parentUuid: 'a1' }, { type: 'user', uuid: 'tr1', parentUuid: 'a1-tool', toolUseResult: {} }, { type: 'assistant', uuid: 'a2', parentUuid: 'tr1' }, { type: 'user', uuid: 'u2-new', parentUuid: 'a1' }, { type: 'assistant', uuid: 'a3-new', parentUuid: 'u2-new' }, ]; const result = filterActiveBranch(entries); const uuids = result.filter(e => e.uuid).map(e => e.uuid); expect(uuids).toEqual(['u1', 'a1', 'u2-new', 'a3-new']); }); it('preserves earlier parallel tool-result descendants when a later rewind exists', () => { const entries: SDKNativeMessage[] = [ { type: 'user', uuid: 'u1', parentUuid: null }, { type: 'assistant', uuid: 'a1-text', parentUuid: 'u1' }, { type: 'assistant', uuid: 'a1-tool1', parentUuid: 'a1-text' }, { type: 'assistant', uuid: 'a1-tool2', parentUuid: 'a1-text' }, { type: 'user', uuid: 'tr1', parentUuid: 'a1-tool1', toolUseResult: {} }, { type: 'user', uuid: 'tr2', parentUuid: 'a1-tool2', toolUseResult: {} }, { type: 'assistant', uuid: 'a2', parentUuid: 'tr2' }, { type: 'user', uuid: 'u3-old', parentUuid: 'a2' }, { type: 'assistant', uuid: 'a3-old', parentUuid: 'u3-old' }, { type: 'user', uuid: 'u3-new', parentUuid: 'a2' }, { type: 'assistant', uuid: 'a3-new', parentUuid: 'u3-new' }, ]; const result = filterActiveBranch(entries); const uuids = result.filter(e => e.uuid).map(e => e.uuid); expect(uuids).toEqual([ 'u1', 'a1-text', 'a1-tool1', 'a1-tool2', 'tr1', 'tr2', 'a2', 'u3-new', 'a3-new', ]); }); }); describe('loadSDKSessionMessages with resumeSessionAt', () => { it('returns identical behavior without resumeSessionAt', async () => { mockExistsSync.mockReturnValue(true); mockFsPromises.readFile.mockResolvedValue([ '{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:00:00Z","message":{"content":"Hello"}}', '{"type":"assistant","uuid":"a1","parentUuid":"u1","timestamp":"2024-01-15T10:01:00Z","message":{"content":[{"type":"text","text":"Hi!"}]}}', ].join('\n')); const result = await loadSDKSessionMessages('/Users/test/vault', 'session-no-resume'); expect(result.messages).toHaveLength(2); }); it('truncates messages at resumeSessionAt on linear JSONL', async () => { mockExistsSync.mockReturnValue(true); mockFsPromises.readFile.mockResolvedValue([ '{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:00:00Z","message":{"content":"Hello"}}', '{"type":"assistant","uuid":"a1","parentUuid":"u1","timestamp":"2024-01-15T10:01:00Z","message":{"content":[{"type":"text","text":"Hi!"}]}}', '{"type":"user","uuid":"u2","parentUuid":"a1","timestamp":"2024-01-15T10:02:00Z","message":{"content":"More"}}', '{"type":"assistant","uuid":"a2","parentUuid":"u2","timestamp":"2024-01-15T10:03:00Z","message":{"content":[{"type":"text","text":"More response"}]}}', ].join('\n')); const result = await loadSDKSessionMessages('/Users/test/vault', 'session-truncate', 'a1'); // Should only have u1 and a1 (truncated at a1) expect(result.messages).toHaveLength(2); expect(result.messages[0].content).toBe('Hello'); expect(result.messages[1].content).toBe('Hi!'); }); it('returns correct active branch on branched JSONL', async () => { mockExistsSync.mockReturnValue(true); mockFsPromises.readFile.mockResolvedValue([ '{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:00:00Z","message":{"content":"Hello"}}', '{"type":"assistant","uuid":"a1","parentUuid":"u1","timestamp":"2024-01-15T10:01:00Z","message":{"content":[{"type":"text","text":"Hi!"}]}}', '{"type":"user","uuid":"u2","parentUuid":"a1","timestamp":"2024-01-15T10:02:00Z","message":{"content":"Old branch"}}', '{"type":"assistant","uuid":"a2","parentUuid":"u2","timestamp":"2024-01-15T10:03:00Z","message":{"content":[{"type":"text","text":"Old response"}]}}', '{"type":"user","uuid":"u3","parentUuid":"a1","timestamp":"2024-01-15T10:04:00Z","message":{"content":"New branch"}}', '{"type":"assistant","uuid":"a3","parentUuid":"u3","timestamp":"2024-01-15T10:05:00Z","message":{"content":[{"type":"text","text":"New response"}]}}', ].join('\n')); const result = await loadSDKSessionMessages('/Users/test/vault', 'session-branched'); // Should have: u1 "Hello", a1 "Hi!", u3 "New branch", a3 "New response" // Old branch (u2, a2) should be excluded expect(result.messages).toHaveLength(4); expect(result.messages[0].content).toBe('Hello'); expect(result.messages[1].content).toBe('Hi!'); expect(result.messages[2].content).toBe('New branch'); expect(result.messages[3].content).toBe('New response'); }); }); describe('extractToolResultContent', () => { it('passes through string content unchanged', () => { expect(extractToolResultContent('hello world')).toBe('hello world'); }); it('extracts text from array of content blocks (Agent results)', () => { const content = [ { type: 'text', text: 'First block of output.' }, { type: 'text', text: 'agentId: abc123\n<usage>total_tokens: 1000</usage>' }, ]; expect(extractToolResultContent(content)).toBe( 'First block of output.\nagentId: abc123\n<usage>total_tokens: 1000</usage>' ); }); it('skips non-text blocks in array content', () => { const content = [ { type: 'image', source: { type: 'base64', data: 'abc' } }, { type: 'text', text: 'The only text.' }, ]; expect(extractToolResultContent(content)).toBe('The only text.'); }); it('returns empty string for null/undefined content', () => { expect(extractToolResultContent(null)).toBe(''); expect(extractToolResultContent(undefined)).toBe(''); }); it('JSON-stringifies unknown content types as fallback', () => { expect(extractToolResultContent({ custom: 'value' })).toBe('{"custom":"value"}'); }); it('handles empty array content', () => { expect(extractToolResultContent([])).toBe(''); }); it('JSON-stringifies non-empty array with no text blocks (e.g. tool_reference)', () => { const content = [ { type: 'tool_reference', tool_name: 'WebSearch' }, { type: 'tool_reference', tool_name: 'Grep' }, ]; expect(extractToolResultContent(content)).toBe(JSON.stringify(content)); }); it('JSON-stringifies non-empty array with no text blocks using fallbackIndent', () => { const content = [ { type: 'tool_reference', tool_name: 'Read' }, ]; expect(extractToolResultContent(content, { fallbackIndent: 2 })).toBe( JSON.stringify(content, null, 2) ); }); }); describe('collectAsyncSubagentResults', () => { it('extracts task-notification data from queue-operation enqueue entries', () => { const entries: SDKNativeMessage[] = [ { type: 'queue-operation', operation: 'enqueue', content: `<task-notification> <task-id>ae5eb9a</task-id> <status>completed</status> <summary>Agent "Review code" completed</summary> <result>Found 3 issues in the codebase. 1. Missing error handling in auth module. 2. Unused import in utils.ts. 3. Race condition in fetchData.</result> </task-notification>`, }, ]; const results = collectAsyncSubagentResults(entries); expect(results.size).toBe(1); const entry = results.get('ae5eb9a')!; expect(entry.status).toBe('completed'); expect(entry.result).toContain('Found 3 issues'); expect(entry.result).toContain('Race condition in fetchData.'); }); it('collects multiple queue-operation entries', () => { const entries: SDKNativeMessage[] = [ { type: 'queue-operation', operation: 'enqueue', content: '<task-notification><task-id>agent-1</task-id><status>completed</status><result>Result 1</result></task-notification>', }, { type: 'queue-operation', operation: 'enqueue', content: '<task-notification><task-id>agent-2</task-id><status>error</status><result>Task failed</result></task-notification>', }, ]; const results = collectAsyncSubagentResults(entries); expect(results.size).toBe(2); expect(results.get('agent-1')!.status).toBe('completed'); expect(results.get('agent-2')!.status).toBe('error'); expect(results.get('agent-2')!.result).toBe('Task failed'); }); it('skips dequeue operations', () => { const entries: SDKNativeMessage[] = [ { type: 'queue-operation', operation: 'dequeue', sessionId: 'session-1', }, ]; const results = collectAsyncSubagentResults(entries); expect(results.size).toBe(0); }); it('skips entries without task-notification content', () => { const entries: SDKNativeMessage[] = [ { type: 'queue-operation', operation: 'enqueue', content: 'some other content', }, ]; const results = collectAsyncSubagentResults(entries); expect(results.size).toBe(0); }); it('skips entries without task-id or result', () => { const entries: SDKNativeMessage[] = [ { type: 'queue-operation', operation: 'enqueue', content: '<task-notification><status>completed</status><result>No task-id</result></task-notification>', }, { type: 'queue-operation', operation: 'enqueue', content: '<task-notification><task-id>has-id</task-id><status>completed</status></task-notification>', }, ]; const results = collectAsyncSubagentResults(entries); expect(results.size).toBe(0); }); it('defaults status to completed when status tag is missing', () => { const entries: SDKNativeMessage[] = [ { type: 'queue-operation', operation: 'enqueue', content: '<task-notification><task-id>no-status</task-id><result>Done</result></task-notification>', }, ]; const results = collectAsyncSubagentResults(entries); expect(results.get('no-status')!.status).toBe('completed'); }); it('ignores non-queue-operation messages', () => { const entries: SDKNativeMessage[] = [ { type: 'user', uuid: 'u1', message: { content: 'hello' } }, { type: 'assistant', uuid: 'a1', message: { content: [{ type: 'text', text: 'hi' }] } }, ]; const results = collectAsyncSubagentResults(entries); expect(results.size).toBe(0); }); }); describe('resolveToolUseResultStatus', () => { it('preserves orphaned fallback when the tool result has no stronger signal', () => { expect(resolveToolUseResultStatus(undefined, 'orphaned')).toBe('orphaned'); expect(resolveToolUseResultStatus({ status: 'unknown' }, 'orphaned')).toBe('orphaned'); }); }); describe('loadSDKSessionMessages - async subagent hydration', () => { it('populates toolCall.subagent for async Task tools from queue-operation results', async () => { mockExistsSync.mockReturnValue(true); mockFsPromises.readFile.mockImplementation(async (filePath: any) => { const p = String(filePath); if (p.endsWith('.jsonl') && !p.includes('subagents')) { return [ '{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:00:00Z","message":{"content":"Run background task"}}', // Assistant spawns async Task '{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:01:00Z","message":{"content":[{"type":"tool_use","id":"task-1","name":"Task","input":{"description":"Review code","prompt":"Check for bugs","run_in_background":true}}]}}', // Task tool_result with agentId (SDK launch shape) `{"type":"user","uuid":"u2","timestamp":"2024-01-15T10:01:01Z","toolUseResult":{"isAsync":true,"agentId":"ae5eb9a","status":"async_launched","description":"Review code","prompt":"Check for bugs","outputFile":"/tmp/agent.output"},"message":{"content":[{"type":"tool_result","tool_use_id":"task-1","content":"Task launched in background."}]}}`, // Queue-operation with full result `{"type":"queue-operation","operation":"enqueue","content":"<task-notification><task-id>ae5eb9a</task-id><status>completed</status><summary>Agent completed</summary><result>Found 3 issues:\\n1. Missing error handling\\n2. Unused import\\n3. Race condition</result></task-notification>"}`, // Assistant continues after '{"type":"assistant","uuid":"a2","timestamp":"2024-01-15T10:05:00Z","message":{"content":[{"type":"text","text":"The review found 3 issues."}]}}', ].join('\n'); } // Subagent sidecar file return ''; }); const result = await loadSDKSessionMessages('/Users/test/vault', 'session-async-hydrate'); // Should have: user message, merged assistant with Task tool, assistant follow-up expect(result.messages.length).toBeGreaterThanOrEqual(2); const assistantMsg = result.messages.find(m => m.role === 'assistant' && m.toolCalls?.some(tc => tc.name === 'Task')); expect(assistantMsg).toBeDefined(); const taskToolCall = assistantMsg!.toolCalls!.find(tc => tc.name === 'Task')!; expect(taskToolCall.subagent).toBeDefined(); expect(taskToolCall.subagent!.mode).toBe('async'); expect(taskToolCall.subagent!.agentId).toBe('ae5eb9a'); expect(taskToolCall.subagent!.status).toBe('completed'); expect(taskToolCall.subagent!.asyncStatus).toBe('completed'); expect(taskToolCall.subagent!.result).toContain('Found 3 issues'); expect(taskToolCall.subagent!.result).toContain('Race condition'); // toolCall.result should also be updated expect(taskToolCall.result).toContain('Found 3 issues'); expect(taskToolCall.status).toBe('completed'); }); it('prefers queue-operation completion over misleading async tool_result error flags', async () => { mockExistsSync.mockReturnValue(true); mockFsPromises.readFile.mockImplementation(async (filePath: any) => { const p = String(filePath); if (p.endsWith('.jsonl') && !p.includes('subagents')) { return [ '{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:00:00Z","message":{"content":"Run background task"}}', '{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:01:00Z","message":{"content":[{"type":"tool_use","id":"task-1","name":"Task","input":{"description":"Review code","prompt":"Check for bugs","run_in_background":true}}]}}', `{"type":"user","uuid":"u2","timestamp":"2024-01-15T10:01:01Z","toolUseResult":{"isAsync":true,"agentId":"ae5eb9a","status":"async_launched"},"message":{"content":[{"type":"tool_result","tool_use_id":"task-1","content":"Task launched in background.","is_error":true}]}}`, `{"type":"queue-operation","operation":"enqueue","content":"<task-notification><task-id>ae5eb9a</task-id><status>completed</status><summary>Agent completed</summary><result>Background work finished cleanly</result></task-notification>"}`, ].join('\n'); } return ''; }); const result = await loadSDKSessionMessages('/Users/test/vault', 'session-async-error-flag'); const assistantMsg = result.messages.find(m => m.role === 'assistant' && m.toolCalls?.some(tc => tc.name === 'Task')); const taskToolCall = assistantMsg!.toolCalls!.find(tc => tc.name === 'Task')!; expect(taskToolCall.subagent).toBeDefined(); expect(taskToolCall.subagent!.status).toBe('completed'); expect(taskToolCall.subagent!.asyncStatus).toBe('completed'); expect(taskToolCall.subagent!.result).toBe('Background work finished cleanly'); expect(taskToolCall.status).toBe('completed'); expect(taskToolCall.result).toBe('Background work finished cleanly'); }); it('uses truncated API result when no queue-operation exists', async () => { mockExistsSync.mockReturnValue(true); mockFsPromises.readFile.mockImplementation(async (filePath: any) => { const p = String(filePath); if (p.endsWith('.jsonl') && !p.includes('subagents')) { return [ '{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:00:00Z","message":{"content":"Run task"}}', '{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:01:00Z","message":{"content":[{"type":"tool_use","id":"task-1","name":"Task","input":{"description":"Test task","prompt":"test","run_in_background":true}}]}}', `{"type":"user","uuid":"u2","timestamp":"2024-01-15T10:01:01Z","toolUseResult":{"isAsync":true,"agentId":"abc123"},"message":{"content":[{"type":"tool_result","tool_use_id":"task-1","content":"Task launched."}]}}`, // No queue-operation entry ].join('\n'); } return ''; }); const result = await loadSDKSessionMessages('/Users/test/vault', 'session-no-queue-op'); const assistantMsg = result.messages.find(m => m.toolCalls?.some(tc => tc.name === 'Task')); const taskToolCall = assistantMsg!.toolCalls!.find(tc => tc.name === 'Task')!; expect(taskToolCall.subagent).toBeDefined(); expect(taskToolCall.subagent!.agentId).toBe('abc123'); expect(taskToolCall.subagent!.status).toBe('running'); expect(taskToolCall.subagent!.asyncStatus).toBe('running'); expect(taskToolCall.status).toBe('running'); // Falls back to the API content (truncated) expect(taskToolCall.subagent!.result).toBe('Task launched.'); }); it('treats async launch tool results as running even when the content block is flagged as error', async () => { mockExistsSync.mockReturnValue(true); mockFsPromises.readFile.mockImplementation(async (filePath: any) => { const p = String(filePath); if (p.endsWith('.jsonl') && !p.includes('subagents')) { return [ '{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:00:00Z","message":{"content":"Run task"}}', '{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:01:00Z","message":{"content":[{"type":"tool_use","id":"task-1","name":"Task","input":{"description":"Test task","prompt":"test","run_in_background":true}}]}}', '{"type":"user","uuid":"u2","timestamp":"2024-01-15T10:01:01Z","toolUseResult":{"isAsync":true,"agentId":"abc123","status":"async_launched"},"message":{"content":[{"type":"tool_result","tool_use_id":"task-1","content":"Task launched in background.","is_error":true}]}}', ].join('\n'); } return ''; }); const result = await loadSDKSessionMessages('/Users/test/vault', 'session-async-launch-error-flag'); const assistantMsg = result.messages.find(m => m.toolCalls?.some(tc => tc.name === 'Task')); const taskToolCall = assistantMsg!.toolCalls!.find(tc => tc.name === 'Task')!; expect(taskToolCall.subagent).toBeDefined(); expect(taskToolCall.subagent!.status).toBe('running'); expect(taskToolCall.subagent!.asyncStatus).toBe('running'); expect(taskToolCall.status).toBe('running'); expect(taskToolCall.result).toBe('Task launched in background.'); }); it('treats queue-operation success status as completed', async () => { mockExistsSync.mockReturnValue(true); mockFsPromises.readFile.mockImplementation(async (filePath: any) => { const p = String(filePath); if (p.endsWith('.jsonl') && !p.includes('subagents')) { return [ '{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:00:00Z","message":{"content":"Run task"}}', '{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:01:00Z","message":{"content":[{"type":"tool_use","id":"task-1","name":"Task","input":{"description":"Test task","prompt":"test","run_in_background":true}}]}}', '{"type":"user","uuid":"u2","timestamp":"2024-01-15T10:01:01Z","toolUseResult":{"isAsync":true,"agentId":"abc123","status":"async_launched"},"message":{"content":[{"type":"tool_result","tool_use_id":"task-1","content":"Task launched in background."}]}}', '{"type":"queue-operation","operation":"enqueue","content":"<task-notification><task-id>abc123</task-id><status>success</status><result>Background task succeeded</result></task-notification>"}', ].join('\n'); } return ''; }); const result = await loadSDKSessionMessages('/Users/test/vault', 'session-queue-success'); const assistantMsg = result.messages.find(m => m.toolCalls?.some(tc => tc.name === 'Task')); const taskToolCall = assistantMsg!.toolCalls!.find(tc => tc.name === 'Task')!; expect(taskToolCall.subagent).toBeDefined(); expect(taskToolCall.subagent!.status).toBe('completed'); expect(taskToolCall.subagent!.asyncStatus).toBe('completed'); expect(taskToolCall.status).toBe('completed'); expect(taskToolCall.result).toBe('Background task succeeded'); }); it('does not build SubagentInfo for sync Task tools', async () => { mockExistsSync.mockReturnValue(true); mockFsPromises.readFile.mockImplementation(async (filePath: any) => { const p = String(filePath); if (p.endsWith('.jsonl') && !p.includes('subagents')) { return [ '{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:00:00Z","message":{"content":"Run sync task"}}', '{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:01:00Z","message":{"content":[{"type":"tool_use","id":"task-1","name":"Task","input":{"description":"Sync task","prompt":"test","run_in_background":false}}]}}', '{"type":"user","uuid":"u2","timestamp":"2024-01-15T10:01:01Z","toolUseResult":{},"message":{"content":[{"type":"tool_result","tool_use_id":"task-1","content":"Sync result"}]}}', ].join('\n'); } return ''; }); const result = await loadSDKSessionMessages('/Users/test/vault', 'session-sync-task'); const assistantMsg = result.messages.find(m => m.toolCalls?.some(tc => tc.name === 'Task')); const taskToolCall = assistantMsg!.toolCalls!.find(tc => tc.name === 'Task')!; // Sync tasks should NOT get SubagentInfo from this pass expect(taskToolCall.subagent).toBeUndefined(); }); it('loads subagent tool calls from sidecar JSONL', async () => { mockExistsSync.mockReturnValue(true); mockFsPromises.readFile.mockImplementation(async (filePath: any) => { const p = String(filePath); if (p.includes('subagents/agent-ae5eb9a.jsonl')) { return [ '{"type":"assistant","timestamp":"2024-01-15T10:02:00Z","message":{"content":[{"type":"tool_use","id":"sub-tool-1","name":"Grep","input":{"pattern":"TODO"}}]}}', '{"type":"user","timestamp":"2024-01-15T10:02:01Z","message":{"content":[{"type":"tool_result","tool_use_id":"sub-tool-1","content":"3 matches found"}]}}', ].join('\n'); } if (p.endsWith('.jsonl')) { return [ '{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:00:00Z","message":{"content":"Review"}}', '{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:01:00Z","message":{"content":[{"type":"tool_use","id":"task-1","name":"Task","input":{"description":"Review","prompt":"check","run_in_background":true}}]}}', `{"type":"user","uuid":"u2","timestamp":"2024-01-15T10:01:01Z","toolUseResult":{"isAsync":true,"agentId":"ae5eb9a"},"message":{"content":[{"type":"tool_result","tool_use_id":"task-1","content":"Launched"}]}}`, `{"type":"queue-operation","operation":"enqueue","content":"<task-notification><task-id>ae5eb9a</task-id><status>completed</status><result>Done reviewing</result></task-notification>"}`, ].join('\n'); } return ''; }); const result = await loadSDKSessionMessages('/Users/test/vault', 'session-sidecar'); const assistantMsg = result.messages.find(m => m.toolCalls?.some(tc => tc.name === 'Task')); const taskToolCall = assistantMsg!.toolCalls!.find(tc => tc.name === 'Task')!; expect(taskToolCall.subagent).toBeDefined(); expect(taskToolCall.subagent!.toolCalls).toHaveLength(1); expect(taskToolCall.subagent!.toolCalls[0].name).toBe('Grep'); expect(taskToolCall.subagent!.toolCalls[0].result).toBe('3 matches found'); }); }); }); ================================================ FILE: tests/unit/utils/session.test.ts ================================================ import type { ChatMessage, ToolCallInfo } from '@/core/types'; import { buildContextFromHistory, buildPromptWithHistoryContext, formatContextLine, formatToolCallForContext, getLastUserMessage, isSessionExpiredError, truncateToolResult, } from '@/utils/session'; describe('session utilities', () => { describe('isSessionExpiredError', () => { it('returns true for "session expired" error', () => { const error = new Error('Session expired'); expect(isSessionExpiredError(error)).toBe(true); }); it('returns true for "session not found" error', () => { const error = new Error('Session not found'); expect(isSessionExpiredError(error)).toBe(true); }); it('returns true for "invalid session" error', () => { const error = new Error('Invalid session'); expect(isSessionExpiredError(error)).toBe(true); }); it('returns true for "session invalid" error', () => { const error = new Error('Session invalid'); expect(isSessionExpiredError(error)).toBe(true); }); it('returns true for "process exited with code" error', () => { const error = new Error('Process exited with code 1'); expect(isSessionExpiredError(error)).toBe(true); }); it('returns true for compound pattern "session" + "expired"', () => { const error = new Error('The session has expired'); expect(isSessionExpiredError(error)).toBe(true); }); it('returns true for compound pattern "resume" + "failed"', () => { const error = new Error('Failed to resume session'); expect(isSessionExpiredError(error)).toBe(true); }); it('returns true for compound pattern "resume" + "error"', () => { const error = new Error('Resume error occurred'); expect(isSessionExpiredError(error)).toBe(true); }); it('returns false for unrelated errors', () => { const error = new Error('Network timeout'); expect(isSessionExpiredError(error)).toBe(false); }); it('returns false for non-Error values', () => { expect(isSessionExpiredError('string error')).toBe(false); expect(isSessionExpiredError(null)).toBe(false); expect(isSessionExpiredError(undefined)).toBe(false); expect(isSessionExpiredError(42)).toBe(false); }); it('is case-insensitive', () => { const error = new Error('SESSION EXPIRED'); expect(isSessionExpiredError(error)).toBe(true); }); }); describe('formatToolCallForContext', () => { it('formats successful tool call with input but without result', () => { const toolCall: ToolCallInfo = { id: 'tool-1', name: 'Read', input: { file_path: '/path/to/file.md' }, status: 'completed', result: 'File contents here - this should NOT be included', }; const result = formatToolCallForContext(toolCall); // Successful tools show input but no result (Claude can re-execute if needed) expect(result).toBe('[Tool Read input: file_path=/path/to/file.md status=completed]'); expect(result).not.toContain('File contents'); }); it('formats tool call without input', () => { const toolCall: ToolCallInfo = { id: 'tool-1', name: 'Read', input: {}, status: 'completed', }; const result = formatToolCallForContext(toolCall); expect(result).toBe('[Tool Read status=completed]'); }); it('formats failed tool call with input and error message', () => { const toolCall: ToolCallInfo = { id: 'tool-1', name: 'Read', input: { file_path: '/path/to/missing.txt' }, status: 'error', result: 'File not found', }; const result = formatToolCallForContext(toolCall); expect(result).toBe('[Tool Read input: file_path=/path/to/missing.txt status=error] error: File not found'); }); it('formats blocked tool call with input and error message', () => { const toolCall: ToolCallInfo = { id: 'tool-1', name: 'Bash', input: { command: 'rm -rf /' }, status: 'blocked', result: 'Command blocked by security policy', }; const result = formatToolCallForContext(toolCall); expect(result).toBe('[Tool Bash input: command=rm -rf / status=blocked] error: Command blocked by security policy'); }); it('truncates long input values', () => { const longPath = '/very/long/path/' + 'x'.repeat(150); const toolCall: ToolCallInfo = { id: 'tool-1', name: 'Read', input: { file_path: longPath }, status: 'completed', }; const result = formatToolCallForContext(toolCall); // Long values truncated to 100 chars (/very/long/path/ = 16 chars, so 84 x's + ...) expect(result).toContain('file_path=/very/long/path/' + 'x'.repeat(84) + '...'); expect(result).not.toContain(longPath); }); it('truncates long error messages to default 500 chars', () => { const longError = 'x'.repeat(700); const toolCall: ToolCallInfo = { id: 'tool-1', name: 'Bash', input: {}, status: 'error', result: longError, }; const result = formatToolCallForContext(toolCall); expect(result).toContain('x'.repeat(500)); expect(result).toContain('(truncated)'); }); it('truncates to custom max length for errors', () => { const toolCall: ToolCallInfo = { id: 'tool-1', name: 'Bash', input: {}, status: 'error', result: 'x'.repeat(500), }; const result = formatToolCallForContext(toolCall, 100); expect(result).toContain('x'.repeat(100)); expect(result).toContain('(truncated)'); }); it('defaults to "completed" status when status is undefined', () => { const toolCall = { id: 'tool-1', name: 'Write', input: {}, status: 'completed', } as ToolCallInfo; const result = formatToolCallForContext(toolCall); expect(result).toBe('[Tool Write status=completed]'); }); it('handles empty result string for successful tool', () => { const toolCall: ToolCallInfo = { id: 'tool-1', name: 'Edit', input: {}, status: 'completed', result: '', }; const result = formatToolCallForContext(toolCall); expect(result).toBe('[Tool Edit status=completed]'); }); it('handles empty result string for failed tool', () => { const toolCall: ToolCallInfo = { id: 'tool-1', name: 'Edit', input: {}, status: 'error', result: '', }; const result = formatToolCallForContext(toolCall); expect(result).toBe('[Tool Edit status=error]'); }); it('handles whitespace-only result for successful tool', () => { const toolCall: ToolCallInfo = { id: 'tool-1', name: 'Glob', input: {}, status: 'completed', result: ' \n\t ', }; const result = formatToolCallForContext(toolCall); expect(result).toBe('[Tool Glob status=completed]'); }); }); describe('truncateToolResult', () => { it('returns unchanged result when under max length', () => { const result = truncateToolResult('short result', 100); expect(result).toBe('short result'); }); it('returns unchanged result when exactly at max length', () => { const result = truncateToolResult('x'.repeat(500), 500); expect(result).toBe('x'.repeat(500)); }); it('truncates and adds indicator when over max length', () => { const longResult = 'x'.repeat(700); const result = truncateToolResult(longResult, 500); expect(result).toBe('x'.repeat(500) + '... (truncated)'); }); it('uses default max length of 500', () => { const longResult = 'x'.repeat(700); const result = truncateToolResult(longResult); expect(result).toBe('x'.repeat(500) + '... (truncated)'); }); }); describe('formatContextLine', () => { it('returns formatted context line for message with currentNote', () => { const message: ChatMessage = { id: 'msg-1', role: 'user', content: 'Hello', timestamp: Date.now(), currentNote: 'notes/test.md', }; const result = formatContextLine(message); expect(result).toContain('notes/test.md'); }); it('returns null when currentNote is undefined', () => { const message: ChatMessage = { id: 'msg-1', role: 'user', content: 'Hello', timestamp: Date.now(), }; const result = formatContextLine(message); expect(result).toBeNull(); }); it('returns null when currentNote is empty', () => { const message: ChatMessage = { id: 'msg-1', role: 'user', content: 'Hello', timestamp: Date.now(), currentNote: '', }; const result = formatContextLine(message); expect(result).toBeNull(); }); }); describe('buildContextFromHistory', () => { it('builds context from simple user/assistant exchange', () => { const messages: ChatMessage[] = [ { id: 'msg-1', role: 'user', content: 'Hello', timestamp: 1000 }, { id: 'msg-2', role: 'assistant', content: 'Hi there!', timestamp: 2000 }, ]; const result = buildContextFromHistory(messages); expect(result).toContain('User: Hello'); expect(result).toContain('Assistant: Hi there!'); }); it('includes tool calls without results for successful tools', () => { const messages: ChatMessage[] = [ { id: 'msg-1', role: 'user', content: 'Read file', timestamp: 1000 }, { id: 'msg-2', role: 'assistant', content: 'Let me read that file.', timestamp: 2000, toolCalls: [ { id: 'tool-1', name: 'Read', input: {}, status: 'completed', result: 'file contents' }, ], }, ]; const result = buildContextFromHistory(messages); expect(result).toContain('User: Read file'); expect(result).toContain('Assistant: Let me read that file.'); expect(result).toContain('[Tool Read status=completed]'); // Successful tools don't include results (Claude can re-execute if needed) expect(result).not.toContain('file contents'); }); it('includes error messages for failed tool calls', () => { const messages: ChatMessage[] = [ { id: 'msg-1', role: 'user', content: 'Read file', timestamp: 1000 }, { id: 'msg-2', role: 'assistant', content: 'Let me read that file.', timestamp: 2000, toolCalls: [ { id: 'tool-1', name: 'Read', input: {}, status: 'error', result: 'File not found' }, ], }, ]; const result = buildContextFromHistory(messages); expect(result).toContain('[Tool Read status=error] error: File not found'); }); it('includes currentNote context for user messages', () => { const messages: ChatMessage[] = [ { id: 'msg-1', role: 'user', content: 'Analyze this note', timestamp: 1000, currentNote: 'notes/important.md', }, ]; const result = buildContextFromHistory(messages); expect(result).toContain('notes/important.md'); expect(result).toContain('Analyze this note'); }); it('skips non-user/assistant messages', () => { // buildContextFromHistory only processes 'user' and 'assistant' roles const messages: ChatMessage[] = [ { id: 'msg-1', role: 'user', content: 'User message', timestamp: 2000 }, ]; const result = buildContextFromHistory(messages); expect(result).toContain('User: User message'); }); it('skips assistant messages with no content and no tool results', () => { const messages: ChatMessage[] = [ { id: 'msg-1', role: 'user', content: 'Hello', timestamp: 1000 }, { id: 'msg-2', role: 'assistant', content: '', timestamp: 2000 }, { id: 'msg-3', role: 'assistant', content: 'Response', timestamp: 3000 }, ]; const result = buildContextFromHistory(messages); expect(result).toContain('User: Hello'); expect(result).toContain('Assistant: Response'); // Should not have an empty assistant entry expect(result.match(/Assistant:/g)?.length).toBe(1); }); it('includes assistant message with only tool results (no text content)', () => { const messages: ChatMessage[] = [ { id: 'msg-1', role: 'user', content: 'Do something', timestamp: 1000 }, { id: 'msg-2', role: 'assistant', content: '', timestamp: 2000, toolCalls: [ { id: 'tool-1', name: 'Bash', input: {}, status: 'completed', result: 'done' }, ], }, ]; const result = buildContextFromHistory(messages); expect(result).toContain('[Tool Bash status=completed]'); }); it('returns empty string for empty messages array', () => { const result = buildContextFromHistory([]); expect(result).toBe(''); }); it('handles messages with only whitespace content', () => { const messages: ChatMessage[] = [ { id: 'msg-1', role: 'user', content: ' \n ', timestamp: 1000 }, { id: 'msg-2', role: 'assistant', content: ' \t ', timestamp: 2000 }, ]; const result = buildContextFromHistory(messages); // Whitespace content should still be processed (trimmed) expect(result).toContain('User:'); }); it('separates messages with double newlines', () => { const messages: ChatMessage[] = [ { id: 'msg-1', role: 'user', content: 'First', timestamp: 1000 }, { id: 'msg-2', role: 'assistant', content: 'Second', timestamp: 2000 }, { id: 'msg-3', role: 'user', content: 'Third', timestamp: 3000 }, ]; const result = buildContextFromHistory(messages); expect(result).toContain('\n\n'); }); it('shows all tool calls but only error results', () => { const messages: ChatMessage[] = [ { id: 'msg-1', role: 'user', content: 'Test', timestamp: 1000 }, { id: 'msg-2', role: 'assistant', content: 'Response', timestamp: 2000, toolCalls: [ { id: 'tool-1', name: 'Success', input: {}, status: 'completed', result: 'data' }, { id: 'tool-2', name: 'Failed', input: {}, status: 'error', result: 'error msg' }, ], }, ]; const result = buildContextFromHistory(messages); // Successful tool shows status only (no result) expect(result).toContain('[Tool Success status=completed]'); expect(result).not.toContain('data'); // Failed tool shows error message expect(result).toContain('[Tool Failed status=error] error: error msg'); }); it('includes thinking block summary', () => { const messages: ChatMessage[] = [ { id: 'msg-1', role: 'user', content: 'Think about this', timestamp: 1000 }, { id: 'msg-2', role: 'assistant', content: 'Here is my response', timestamp: 2000, contentBlocks: [ { type: 'thinking', content: 'Let me think...', durationSeconds: 5.5 }, { type: 'text', content: 'Here is my response' }, ], }, ]; const result = buildContextFromHistory(messages); expect(result).toContain('[Thinking: 1 block(s), 5.5s total]'); // Thinking content is NOT included (Claude will think anew) expect(result).not.toContain('Let me think'); }); it('includes thinking summary for multiple blocks', () => { const messages: ChatMessage[] = [ { id: 'msg-1', role: 'user', content: 'Complex problem', timestamp: 1000 }, { id: 'msg-2', role: 'assistant', content: 'Response', timestamp: 2000, contentBlocks: [ { type: 'thinking', content: 'First thought', durationSeconds: 3.0 }, { type: 'thinking', content: 'Second thought', durationSeconds: 2.5 }, ], }, ]; const result = buildContextFromHistory(messages); expect(result).toContain('[Thinking: 2 block(s), 5.5s total]'); }); it('includes thinking summary without duration if not available', () => { const messages: ChatMessage[] = [ { id: 'msg-1', role: 'user', content: 'Question', timestamp: 1000 }, { id: 'msg-2', role: 'assistant', content: 'Answer', timestamp: 2000, contentBlocks: [ { type: 'thinking', content: 'Thinking...' }, ], }, ]; const result = buildContextFromHistory(messages); expect(result).toContain('[Thinking: 1 block(s)]'); expect(result).not.toContain('total]'); }); it('includes tool input in history', () => { const messages: ChatMessage[] = [ { id: 'msg-1', role: 'user', content: 'Read my file', timestamp: 1000 }, { id: 'msg-2', role: 'assistant', content: 'Let me read it', timestamp: 2000, toolCalls: [ { id: 'tool-1', name: 'Read', input: { file_path: '/notes/todo.md' }, status: 'completed', result: 'file contents' }, ], }, ]; const result = buildContextFromHistory(messages); expect(result).toContain('[Tool Read input: file_path=/notes/todo.md status=completed]'); }); }); describe('getLastUserMessage', () => { it('returns last user message from history', () => { const messages: ChatMessage[] = [ { id: 'msg-1', role: 'user', content: 'First', timestamp: 1000 }, { id: 'msg-2', role: 'assistant', content: 'Response', timestamp: 2000 }, { id: 'msg-3', role: 'user', content: 'Second', timestamp: 3000 }, { id: 'msg-4', role: 'assistant', content: 'Response 2', timestamp: 4000 }, ]; const result = getLastUserMessage(messages); expect(result?.id).toBe('msg-3'); expect(result?.content).toBe('Second'); }); it('returns undefined when no user messages exist', () => { const messages: ChatMessage[] = [ { id: 'msg-1', role: 'assistant', content: 'Response', timestamp: 1000 }, ]; const result = getLastUserMessage(messages); expect(result).toBeUndefined(); }); it('returns undefined for empty messages array', () => { const result = getLastUserMessage([]); expect(result).toBeUndefined(); }); it('returns the only user message when there is just one', () => { const messages: ChatMessage[] = [ { id: 'msg-1', role: 'assistant', content: 'Welcome', timestamp: 1000 }, { id: 'msg-2', role: 'user', content: 'Only user msg', timestamp: 2000 }, { id: 'msg-3', role: 'assistant', content: 'Response', timestamp: 3000 }, ]; const result = getLastUserMessage(messages); expect(result?.id).toBe('msg-2'); }); it('finds user message among assistant messages', () => { const messages: ChatMessage[] = [ { id: 'msg-1', role: 'assistant', content: 'Welcome', timestamp: 1000 }, { id: 'msg-2', role: 'user', content: 'User', timestamp: 2000 }, { id: 'msg-3', role: 'assistant', content: 'Response', timestamp: 3000 }, ]; const result = getLastUserMessage(messages); expect(result?.id).toBe('msg-2'); }); }); describe('buildPromptWithHistoryContext', () => { it('returns prompt unchanged when historyContext is null', () => { const prompt = '<query>\nhello\n</query>'; const result = buildPromptWithHistoryContext(null, prompt, 'hello', []); expect(result).toBe(prompt); }); it('returns only history when actualPrompt matches last user message', () => { const messages: ChatMessage[] = [ { id: 'msg-1', role: 'user', content: 'hello', timestamp: 1000 }, { id: 'msg-2', role: 'assistant', content: 'hi', timestamp: 2000 }, ]; const historyContext = 'User: hello\n\nAssistant: hi'; const prompt = '<query>\nhello\n</query>'; const actualPrompt = 'hello'; const result = buildPromptWithHistoryContext(historyContext, prompt, actualPrompt, messages); // Should NOT append prompt since actualPrompt matches last user message expect(result).toBe(historyContext); }); it('appends prompt when actualPrompt differs from last user message', () => { const messages: ChatMessage[] = [ { id: 'msg-1', role: 'user', content: 'first message', timestamp: 1000 }, { id: 'msg-2', role: 'assistant', content: 'response', timestamp: 2000 }, ]; const historyContext = 'User: first message\n\nAssistant: response'; const prompt = '<query>\nsecond message\n</query>'; const actualPrompt = 'second message'; const result = buildPromptWithHistoryContext(historyContext, prompt, actualPrompt, messages); expect(result).toContain(historyContext); expect(result).toContain('User: <query>'); expect(result).toContain('second message'); }); it('returns prompt unchanged when history context is empty string', () => { const historyContext = ''; const prompt = '<query>\nhello\n</query>'; const result = buildPromptWithHistoryContext(historyContext, prompt, 'hello', []); // Empty string is falsy, so returns original prompt expect(result).toBe(prompt); }); it('appends prompt when no user messages in history', () => { const messages: ChatMessage[] = [ { id: 'msg-1', role: 'assistant', content: 'welcome', timestamp: 1000 }, ]; const historyContext = 'Assistant: welcome'; const prompt = '<query>\nhello\n</query>'; const actualPrompt = 'hello'; const result = buildPromptWithHistoryContext(historyContext, prompt, actualPrompt, messages); expect(result).toContain(historyContext); expect(result).toContain('User: <query>'); }); it('handles whitespace in comparison', () => { const messages: ChatMessage[] = [ { id: 'msg-1', role: 'user', content: ' hello world ', timestamp: 1000 }, ]; const historyContext = 'User: hello world'; const prompt = '<query>\nhello world\n</query>'; const actualPrompt = 'hello world'; const result = buildPromptWithHistoryContext(historyContext, prompt, actualPrompt, messages); // Should match after trimming expect(result).toBe(historyContext); }); it('avoids duplication when XML-wrapped content matches display content', () => { const prompt = [ '<current_note>', 'notes/file.md', '</current_note>', '', '<editor_selection path="notes/file.md">', 'selected text', '</editor_selection>', '', '<query>', 'Follow up', '</query>', ].join('\n'); const actualPrompt = [ '<editor_selection path="notes/file.md">', 'selected text', '</editor_selection>', '', '<query>', 'Follow up', '</query>', ].join('\n'); const messages: ChatMessage[] = [ { id: 'msg-1', role: 'user', content: prompt, displayContent: 'Follow up', timestamp: 1000, }, ]; const historyContext = 'User: Follow up'; const result = buildPromptWithHistoryContext(historyContext, prompt, actualPrompt, messages); expect(result).toBe(historyContext); }); describe('new format (user content before XML context)', () => { it('avoids duplication when actualPrompt matches last user message', () => { const prompt = 'Explain this\n\n<current_note>\ntest.md\n</current_note>'; const actualPrompt = 'Explain this\n\n<current_note>\ntest.md\n</current_note>'; const messages: ChatMessage[] = [ { id: 'msg-1', role: 'user', content: prompt, displayContent: 'Explain this', timestamp: 1000, }, ]; const historyContext = 'User: Explain this'; const result = buildPromptWithHistoryContext(historyContext, prompt, actualPrompt, messages); expect(result).toBe(historyContext); }); it('appends prompt when actualPrompt differs from last user message', () => { const oldPrompt = 'First question\n\n<current_note>\nold.md\n</current_note>'; const newPrompt = 'Second question\n\n<current_note>\nnew.md\n</current_note>'; const messages: ChatMessage[] = [ { id: 'msg-1', role: 'user', content: oldPrompt, displayContent: 'First question', timestamp: 1000, }, ]; const historyContext = 'User: First question\n\nAssistant: response'; const result = buildPromptWithHistoryContext(historyContext, newPrompt, newPrompt, messages); expect(result).toContain(historyContext); expect(result).toContain('User: Second question'); }); it('extracts user query from editor_selection format', () => { const prompt = 'Refactor this\n\n<editor_selection path="src/main.ts">\ncode here\n</editor_selection>'; const messages: ChatMessage[] = [ { id: 'msg-1', role: 'user', content: prompt, displayContent: 'Refactor this', timestamp: 1000, }, ]; const historyContext = 'User: Refactor this'; const result = buildPromptWithHistoryContext(historyContext, prompt, prompt, messages); expect(result).toBe(historyContext); }); it('extracts user query from content with multiple XML context tags', () => { const prompt = 'Update code\n\n<current_note>\ntest.md\n</current_note>\n\n<editor_selection path="test.md">\nselected\n</editor_selection>'; const messages: ChatMessage[] = [ { id: 'msg-1', role: 'user', content: prompt, displayContent: 'Update code', timestamp: 1000, }, ]; const historyContext = 'User: Update code'; const result = buildPromptWithHistoryContext(historyContext, prompt, prompt, messages); expect(result).toBe(historyContext); }); it('falls back to extractUserQuery when displayContent is not available', () => { const prompt = 'Help me\n\n<current_note>\nfile.md\n</current_note>'; const messages: ChatMessage[] = [ { id: 'msg-1', role: 'user', content: prompt, // No displayContent - should extract from content timestamp: 1000, }, ]; const historyContext = 'User: Help me'; const result = buildPromptWithHistoryContext(historyContext, prompt, prompt, messages); expect(result).toBe(historyContext); }); }); }); describe('formatToolCallForContext edge cases', () => { it('formats tool call with object input value', () => { const toolCall: ToolCallInfo = { id: 'tool-1', name: 'Write', input: { config: { nested: true }, count: 42 }, status: 'completed', }; const result = formatToolCallForContext(toolCall); expect(result).toContain('config=[object]'); expect(result).toContain('count=42'); }); it('skips null and undefined input values', () => { const toolCall: ToolCallInfo = { id: 'tool-1', name: 'Read', input: { path: '/file.md', optional: null, missing: undefined }, status: 'completed', }; const result = formatToolCallForContext(toolCall); expect(result).toContain('path=/file.md'); expect(result).not.toContain('optional'); expect(result).not.toContain('missing'); }); it('truncates long overall input string', () => { const toolCall: ToolCallInfo = { id: 'tool-1', name: 'Bash', input: { a: 'x'.repeat(80), b: 'y'.repeat(80), c: 'z'.repeat(80), }, status: 'completed', }; const result = formatToolCallForContext(toolCall); // Total input string truncated to 200 chars expect(result).toContain('...'); }); it('formats whitespace-only result for failed tool without error detail', () => { const toolCall: ToolCallInfo = { id: 'tool-1', name: 'Bash', input: {}, status: 'error', result: ' \n ', }; const result = formatToolCallForContext(toolCall); expect(result).toBe('[Tool Bash status=error]'); }); }); describe('buildContextFromHistory edge cases', () => { it('skips interrupt messages', () => { const messages: ChatMessage[] = [ { id: 'msg-1', role: 'user', content: 'Start task', timestamp: 1000 }, { id: 'msg-2', role: 'assistant', content: 'Working on it...', timestamp: 2000, }, { id: 'msg-3', role: 'user', content: '', timestamp: 3000, isInterrupt: true, }, { id: 'msg-4', role: 'assistant', content: 'Stopped.', timestamp: 4000, }, ]; const result = buildContextFromHistory(messages); expect(result).toContain('User: Start task'); expect(result).toContain('Assistant: Working on it...'); expect(result).toContain('Assistant: Stopped.'); // Interrupt message should not appear as a user message expect(result.match(/User:/g)?.length).toBe(1); }); it('includes assistant message with only thinking blocks and no text', () => { const messages: ChatMessage[] = [ { id: 'msg-1', role: 'user', content: 'Think hard', timestamp: 1000 }, { id: 'msg-2', role: 'assistant', content: '', timestamp: 2000, contentBlocks: [ { type: 'thinking', content: 'Deep thought...', durationSeconds: 10 }, ], }, ]; const result = buildContextFromHistory(messages); expect(result).toContain('[Thinking: 1 block(s), 10.0s total]'); }); it('handles user message with currentNote but no content', () => { const messages: ChatMessage[] = [ { id: 'msg-1', role: 'user', content: '', timestamp: 1000, currentNote: 'notes/active.md', }, ]; const result = buildContextFromHistory(messages); expect(result).toContain('notes/active.md'); }); it('skips messages with unknown roles', () => { const messages = [ { id: 'msg-1', role: 'user', content: 'Hello', timestamp: 1000 }, { id: 'msg-2', role: 'system' as any, content: 'System msg', timestamp: 1500 }, { id: 'msg-3', role: 'assistant', content: 'Response', timestamp: 2000 }, ] as ChatMessage[]; const result = buildContextFromHistory(messages); expect(result).toContain('User: Hello'); expect(result).toContain('Assistant: Response'); expect(result).not.toContain('System msg'); }); }); }); ================================================ FILE: tests/unit/utils/slashCommand.test.ts ================================================ import { extractFirstParagraph, parseSlashCommandContent, serializeCommand, serializeSlashCommandMarkdown, validateCommandName, yamlString } from '@/utils/slashCommand'; describe('parseSlashCommandContent', () => { describe('basic parsing', () => { it('should parse command with full frontmatter', () => { const content = `--- description: Review code for issues argument-hint: "[file] [focus]" allowed-tools: - Read - Grep model: claude-sonnet-4-5 --- Review this code: $ARGUMENTS`; const parsed = parseSlashCommandContent(content); expect(parsed.description).toBe('Review code for issues'); expect(parsed.argumentHint).toBe('[file] [focus]'); expect(parsed.allowedTools).toEqual(['Read', 'Grep']); expect(parsed.model).toBe('claude-sonnet-4-5'); expect(parsed.promptContent).toBe('Review this code: $ARGUMENTS'); }); it('should parse command with minimal frontmatter', () => { const content = `--- description: Simple command --- Do something`; const parsed = parseSlashCommandContent(content); expect(parsed.description).toBe('Simple command'); expect(parsed.argumentHint).toBeUndefined(); expect(parsed.allowedTools).toBeUndefined(); expect(parsed.model).toBeUndefined(); expect(parsed.promptContent).toBe('Do something'); }); it('should handle content without frontmatter', () => { const content = 'Just a prompt without frontmatter'; const parsed = parseSlashCommandContent(content); expect(parsed.description).toBeUndefined(); expect(parsed.promptContent).toBe('Just a prompt without frontmatter'); }); it('should handle inline array syntax for allowed-tools', () => { const content = `--- allowed-tools: [Read, Write, Bash] --- Prompt`; const parsed = parseSlashCommandContent(content); expect(parsed.allowedTools).toEqual(['Read', 'Write', 'Bash']); }); it('should handle quoted values', () => { const content = `--- description: "Value with: colon" argument-hint: 'Single quoted' --- Prompt`; const parsed = parseSlashCommandContent(content); expect(parsed.description).toBe('Value with: colon'); expect(parsed.argumentHint).toBe('Single quoted'); }); }); describe('block scalar support', () => { it('should parse literal block scalar (|) for description', () => { const content = `--- description: | Records a checkpoint of progress in the daily note. Includes timestamp and current task status. --- Prompt`; const parsed = parseSlashCommandContent(content); expect(parsed.description).toBe('Records a checkpoint of progress in the daily note.\nIncludes timestamp and current task status.'); expect(parsed.promptContent).toBe('Prompt'); }); it('should parse folded block scalar (>) for description', () => { const content = `--- description: > Records a checkpoint of progress in the daily note. --- Prompt`; const parsed = parseSlashCommandContent(content); expect(parsed.description).toBe('Records a checkpoint of progress in the daily note.'); expect(parsed.promptContent).toBe('Prompt'); }); it('should produce different output for | vs > block scalars', () => { const literalContent = `--- description: | Line one Line two --- Prompt`; const foldedContent = `--- description: > Line one Line two --- Prompt`; const literal = parseSlashCommandContent(literalContent); const folded = parseSlashCommandContent(foldedContent); expect(literal.description).toBe('Line one\nLine two'); expect(folded.description).toBe('Line one Line two'); expect(literal.description).not.toBe(folded.description); }); it('should preserve paragraph breaks in folded block scalar (>)', () => { const content = `--- description: > First paragraph here. Second paragraph after empty line. --- Prompt`; const parsed = parseSlashCommandContent(content); expect(parsed.description).toBe('First paragraph here.\n\nSecond paragraph after empty line.'); expect(parsed.promptContent).toBe('Prompt'); }); it('should parse literal block scalar for argument-hint', () => { const content = `--- argument-hint: | [task-name] [optional-notes] --- Prompt`; const parsed = parseSlashCommandContent(content); expect(parsed.argumentHint).toBe('[task-name]\n[optional-notes]'); }); it('should handle empty lines in literal block scalar', () => { const content = `--- description: | First paragraph here. Second paragraph after empty line. --- Prompt`; const parsed = parseSlashCommandContent(content); expect(parsed.description).toBe('First paragraph here.\n\nSecond paragraph after empty line.'); }); it('should parse block scalar with other fields', () => { const content = `--- description: | Multi-line description with multiple lines model: claude-sonnet-4-5 allowed-tools: - Read - Write --- Prompt content`; const parsed = parseSlashCommandContent(content); expect(parsed.description).toBe('Multi-line description\nwith multiple lines'); expect(parsed.model).toBe('claude-sonnet-4-5'); expect(parsed.allowedTools).toEqual(['Read', 'Write']); expect(parsed.promptContent).toBe('Prompt content'); }); it('should handle block scalar at end of frontmatter', () => { const content = `--- model: claude-haiku-4-5 description: | Last field in frontmatter with multiple lines --- Prompt`; const parsed = parseSlashCommandContent(content); expect(parsed.description).toBe('Last field in frontmatter\nwith multiple lines'); expect(parsed.model).toBe('claude-haiku-4-5'); }); it('should preserve indentation within block scalar content', () => { const content = `--- description: | Code example: - Step 1 - Step 2 --- Prompt`; const parsed = parseSlashCommandContent(content); expect(parsed.description).toBe('Code example:\n - Step 1\n - Step 2'); }); it('should handle single-line block scalar', () => { const content = `--- description: | Just one line --- Prompt`; const parsed = parseSlashCommandContent(content); expect(parsed.description).toBe('Just one line'); }); it('should not confuse pipe in quoted string with block scalar', () => { const content = `--- description: "Contains | pipe character" --- Prompt`; const parsed = parseSlashCommandContent(content); expect(parsed.description).toBe('Contains | pipe character'); }); it('should handle multiple block scalars in same frontmatter', () => { const content = `--- description: | First block scalar with multiple lines argument-hint: | Second block scalar also multi-line --- Prompt`; const parsed = parseSlashCommandContent(content); expect(parsed.description).toBe('First block scalar\nwith multiple lines'); expect(parsed.argumentHint).toBe('Second block scalar\nalso multi-line'); }); it('should handle CRLF line endings in block scalar', () => { const content = '---\r\ndescription: |\r\n Line one\r\n Line two\r\n---\r\nPrompt'; const parsed = parseSlashCommandContent(content); expect(parsed.description).toBe('Line one\nLine two'); expect(parsed.promptContent).toBe('Prompt'); }); }); describe('block scalar edge cases', () => { it('should handle empty block scalar followed by another field', () => { const content = `--- description: | model: claude-sonnet-4-5 --- Prompt`; const parsed = parseSlashCommandContent(content); // Empty block scalar yields no description (semantically same as absent) expect(parsed.description).toBeUndefined(); expect(parsed.model).toBe('claude-sonnet-4-5'); }); it('should handle block scalar with only empty lines before next field', () => { const content = `--- description: | model: claude-sonnet-4-5 --- Prompt`; const parsed = parseSlashCommandContent(content); // Empty lines followed by unindented field should end the block scalar expect(parsed.model).toBe('claude-sonnet-4-5'); }); it('should handle strip chomping indicator (|-)', () => { const content = `--- description: |- No trailing newline here --- Prompt`; const parsed = parseSlashCommandContent(content); // Chomping indicator is recognized and parsed as block scalar expect(parsed.description).toBe('No trailing newline here'); }); it('should handle keep chomping indicator (|+)', () => { const content = `--- description: |+ Keep indicator recognized --- Prompt`; const parsed = parseSlashCommandContent(content); // Keep chomping indicator is recognized expect(parsed.description).toBe('Keep indicator recognized'); }); it('should handle folded with strip chomping (>-)', () => { const content = `--- description: >- Folded with strip chomping indicator --- Prompt`; const parsed = parseSlashCommandContent(content); expect(parsed.description).toBe('Folded with strip chomping indicator'); }); it('should not enable block scalar for unsupported keys', () => { const content = `--- notes: | This should not be parsed as block scalar description: Regular description --- Prompt`; const parsed = parseSlashCommandContent(content); // notes is not a supported key, so | is treated as the value // description should be parsed normally expect(parsed.description).toBe('Regular description'); }); it('should handle allowed-tools with block scalar indicator gracefully', () => { const content = `--- allowed-tools: | Read Write description: Test --- Prompt`; const parsed = parseSlashCommandContent(content); // allowed-tools doesn't support block scalar, so | becomes the value // The Read/Write lines are ignored as they're not valid YAML keys expect(parsed.description).toBe('Test'); }); it('should preserve unicode content in block scalar', () => { const content = `--- description: | Hello 世界 Émoji: 🎉 --- Prompt`; const parsed = parseSlashCommandContent(content); expect(parsed.description).toBe('Hello 世界\nÉmoji: 🎉'); }); it('should preserve relative indentation in deeply nested content', () => { const content = `--- description: | Level 1 Level 2 Level 3 Level 4 --- Prompt`; const parsed = parseSlashCommandContent(content); expect(parsed.description).toBe('Level 1\n Level 2\n Level 3\n Level 4'); }); it('should preserve colons in block scalar content', () => { const content = `--- description: | key: value another: pair --- Prompt`; const parsed = parseSlashCommandContent(content); expect(parsed.description).toBe('key: value\nanother: pair'); }); it('should preserve comment-like content (# lines)', () => { const content = `--- description: | # This looks like a YAML comment But it is preserved as content --- Prompt`; const parsed = parseSlashCommandContent(content); expect(parsed.description).toBe('# This looks like a YAML comment\nBut it is preserved as content'); }); it('should preserve trailing whitespace in block scalar lines', () => { // Use explicit string to ensure trailing spaces are preserved const content = '---\ndescription: |\n Line with trailing spaces \n Normal line\n---\nPrompt'; const parsed = parseSlashCommandContent(content); expect(parsed.description).toBe('Line with trailing spaces \nNormal line'); }); it('should preserve leading empty lines in block scalar', () => { const content = `--- description: | Content after empty line --- Prompt`; const parsed = parseSlashCommandContent(content); // Leading empty lines are preserved per YAML spec expect(parsed.description).toBe('\nContent after empty line'); }); }); describe('skill fields', () => { it('should parse kebab-case disable-model-invocation', () => { const content = `--- description: A skill disable-model-invocation: true --- Prompt`; const parsed = parseSlashCommandContent(content); expect(parsed.disableModelInvocation).toBe(true); }); it('should parse kebab-case user-invocable', () => { const content = `--- description: A skill user-invocable: false --- Prompt`; const parsed = parseSlashCommandContent(content); expect(parsed.userInvocable).toBe(false); }); it('should parse camelCase disableModelInvocation (backwards compat)', () => { const content = `--- description: A skill disableModelInvocation: true --- Prompt`; const parsed = parseSlashCommandContent(content); expect(parsed.disableModelInvocation).toBe(true); }); it('should parse camelCase userInvocable (backwards compat)', () => { const content = `--- description: A skill userInvocable: false --- Prompt`; const parsed = parseSlashCommandContent(content); expect(parsed.userInvocable).toBe(false); }); it('should prefer kebab-case over camelCase when both present', () => { const content = `--- disable-model-invocation: true disableModelInvocation: false user-invocable: false userInvocable: true --- Prompt`; const parsed = parseSlashCommandContent(content); expect(parsed.disableModelInvocation).toBe(true); expect(parsed.userInvocable).toBe(false); }); it('should parse context string', () => { const content = `--- description: A skill context: fork --- Prompt`; const parsed = parseSlashCommandContent(content); expect(parsed.context).toBe('fork'); }); it('should parse agent string', () => { const content = `--- description: A skill agent: code-reviewer --- Prompt`; const parsed = parseSlashCommandContent(content); expect(parsed.agent).toBe('code-reviewer'); }); it('should parse all skill fields together', () => { const content = `--- description: Full skill disableModelInvocation: true userInvocable: true context: fork agent: code-reviewer model: sonnet --- Do the thing`; const parsed = parseSlashCommandContent(content); expect(parsed.description).toBe('Full skill'); expect(parsed.disableModelInvocation).toBe(true); expect(parsed.userInvocable).toBe(true); expect(parsed.context).toBe('fork'); expect(parsed.agent).toBe('code-reviewer'); expect(parsed.model).toBe('sonnet'); expect(parsed.promptContent).toBe('Do the thing'); }); it('should return undefined for missing skill fields', () => { const content = `--- description: Simple command --- Prompt`; const parsed = parseSlashCommandContent(content); expect(parsed.disableModelInvocation).toBeUndefined(); expect(parsed.userInvocable).toBeUndefined(); expect(parsed.context).toBeUndefined(); expect(parsed.agent).toBeUndefined(); expect(parsed.hooks).toBeUndefined(); }); }); }); describe('yamlString', () => { it('returns plain value for simple strings', () => { expect(yamlString('hello world')).toBe('hello world'); }); it('quotes strings with colons', () => { expect(yamlString('key: value')).toBe('"key: value"'); }); it('quotes strings with hash', () => { expect(yamlString('has # comment')).toBe('"has # comment"'); }); it('quotes strings with newlines', () => { expect(yamlString('line1\nline2')).toBe('"line1\nline2"'); }); it('quotes strings starting with space', () => { expect(yamlString(' leading')).toBe('" leading"'); }); it('quotes strings ending with space', () => { expect(yamlString('trailing ')).toBe('"trailing "'); }); it('escapes double quotes inside quoted strings', () => { expect(yamlString('has "quotes" inside: yes')).toBe('"has \\"quotes\\" inside: yes"'); }); }); describe('serializeCommand', () => { it('strips frontmatter from content before serializing', () => { const result = serializeCommand({ id: 'cmd-test', name: 'test', description: 'Test', content: '---\ndescription: old\n---\nBody text', }); // Should use the SlashCommand's description, not the one in content frontmatter expect(result).toContain('description: Test'); expect(result).toContain('Body text'); expect(result).not.toContain('description: old'); }); it('handles content without frontmatter', () => { const result = serializeCommand({ id: 'cmd-test', name: 'test', description: 'Simple', content: 'Just a prompt', }); expect(result).toContain('description: Simple'); expect(result).toContain('Just a prompt'); }); }); describe('serializeSlashCommandMarkdown', () => { it('serializes all fields in kebab-case', () => { const result = serializeSlashCommandMarkdown({ name: 'my-skill', description: 'Test command', argumentHint: '[file]', allowedTools: ['Read', 'Grep'], model: 'claude-sonnet-4-5', disableModelInvocation: true, userInvocable: false, context: 'fork', agent: 'code-reviewer', }, 'Do the thing'); expect(result).toContain('name: my-skill'); expect(result).toContain('description: Test command'); expect(result).toContain('argument-hint: "[file]"'); expect(result).toContain('allowed-tools:'); expect(result).toContain(' - Read'); expect(result).toContain(' - Grep'); expect(result).toContain('model: claude-sonnet-4-5'); expect(result).toContain('disable-model-invocation: true'); expect(result).toContain('user-invocable: false'); expect(result).toContain('context: fork'); expect(result).toContain('agent: code-reviewer'); expect(result).toContain('Do the thing'); }); it('omits undefined fields', () => { const result = serializeSlashCommandMarkdown({ description: 'Minimal', }, 'Prompt'); expect(result).toContain('description: Minimal'); expect(result).not.toContain('name'); expect(result).not.toContain('argument-hint'); expect(result).not.toContain('allowed-tools'); expect(result).not.toContain('model'); expect(result).not.toContain('disable-model-invocation'); expect(result).not.toContain('user-invocable'); expect(result).not.toContain('context'); expect(result).not.toContain('agent'); expect(result).not.toContain('hooks'); }); it('serializes hooks as JSON', () => { const hooks = { PreToolUse: [{ matcher: 'Bash' }] }; const result = serializeSlashCommandMarkdown({ hooks }, 'Prompt'); expect(result).toContain(`hooks: ${JSON.stringify(hooks)}`); }); it('produces valid frontmatter when no metadata exists', () => { const result = serializeSlashCommandMarkdown({}, 'Just a prompt'); expect(result).toBe('---\n\n---\nJust a prompt'); }); it('round-trips through parse', () => { const serialized = serializeSlashCommandMarkdown({ description: 'Round trip', disableModelInvocation: true, userInvocable: false, context: 'fork', agent: 'reviewer', }, 'Body text'); const parsed = parseSlashCommandContent(serialized); expect(parsed.description).toBe('Round trip'); expect(parsed.disableModelInvocation).toBe(true); expect(parsed.userInvocable).toBe(false); expect(parsed.context).toBe('fork'); expect(parsed.agent).toBe('reviewer'); expect(parsed.promptContent).toBe('Body text'); }); }); describe('validateCommandName', () => { it('accepts valid lowercase names', () => { expect(validateCommandName('my-command')).toBeNull(); expect(validateCommandName('test')).toBeNull(); expect(validateCommandName('a')).toBeNull(); expect(validateCommandName('abc123')).toBeNull(); expect(validateCommandName('my-cmd-2')).toBeNull(); expect(validateCommandName('a1b2c3')).toBeNull(); }); it('accepts name at exactly 64 characters', () => { const name = 'a'.repeat(64); expect(validateCommandName(name)).toBeNull(); }); it('rejects empty name', () => { expect(validateCommandName('')).not.toBeNull(); }); it('rejects uppercase letters', () => { expect(validateCommandName('MyCommand')).not.toBeNull(); expect(validateCommandName('TEST')).not.toBeNull(); }); it('rejects underscores', () => { expect(validateCommandName('my_command')).not.toBeNull(); }); it('rejects slashes', () => { expect(validateCommandName('my/command')).not.toBeNull(); }); it('rejects spaces', () => { expect(validateCommandName('my command')).not.toBeNull(); }); it('rejects colons', () => { expect(validateCommandName('my:command')).not.toBeNull(); }); it('rejects names exceeding 64 characters', () => { const name = 'a'.repeat(65); expect(validateCommandName(name)).not.toBeNull(); }); it('rejects special characters', () => { expect(validateCommandName('cmd!@#')).not.toBeNull(); expect(validateCommandName('cmd.test')).not.toBeNull(); }); it.each(['true', 'false', 'null', 'yes', 'no', 'on', 'off'])( 'rejects YAML reserved word "%s"', (word) => { expect(validateCommandName(word)).not.toBeNull(); } ); }); describe('extractFirstParagraph', () => { it('returns the first paragraph from multi-paragraph content', () => { expect(extractFirstParagraph('First paragraph.\n\nSecond paragraph.')) .toBe('First paragraph.'); }); it('returns single-line content as-is', () => { expect(extractFirstParagraph('Only one line')).toBe('Only one line'); }); it('collapses multi-line first paragraph into single line', () => { expect(extractFirstParagraph('Line one\nline two\n\nSecond paragraph')) .toBe('Line one line two'); }); it('returns undefined for empty content', () => { expect(extractFirstParagraph('')).toBeUndefined(); }); it('returns undefined for whitespace-only content', () => { expect(extractFirstParagraph(' \n \n ')).toBeUndefined(); }); it('skips leading blank lines', () => { expect(extractFirstParagraph('\n\nActual first paragraph.\n\nSecond.')) .toBe('Actual first paragraph.'); }); }); ================================================ FILE: tests/unit/utils/utils.test.ts ================================================ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { getCurrentModelFromEnvironment, getModelsFromEnvironment, parseEnvironmentVariables } from '@/utils/env'; import { appendMarkdownSnippet } from '@/utils/markdown'; import { expandHomePath, findClaudeCLIPath, getPathAccessType, getVaultPath, isPathInAllowedExportPaths, isPathWithinVault, normalizePathForFilesystem, normalizePathForVault, translateMsysPath, } from '@/utils/path'; describe('utils.ts', () => { describe('getVaultPath', () => { it('should return basePath when adapter has basePath property', () => { const mockApp = { vault: { adapter: { basePath: '/Users/test/my-vault', }, }, } as any; const result = getVaultPath(mockApp); expect(result).toBe('/Users/test/my-vault'); }); it('should return null when adapter does not have basePath', () => { const mockApp = { vault: { adapter: {}, }, } as any; const result = getVaultPath(mockApp); expect(result).toBeNull(); }); it('should return null when adapter is undefined', () => { const mockApp = { vault: { adapter: undefined, }, } as any; // The function will throw because it tries to use 'in' on undefined // This tests error handling - in real usage adapter is always defined expect(() => getVaultPath(mockApp)).toThrow(); }); it('should handle empty string basePath', () => { const mockApp = { vault: { adapter: { basePath: '', }, }, } as any; const result = getVaultPath(mockApp); // Empty string is still a valid basePath value expect(result).toBe(''); }); it('should handle paths with spaces', () => { const mockApp = { vault: { adapter: { basePath: '/Users/test/My Obsidian Vault', }, }, } as any; const result = getVaultPath(mockApp); expect(result).toBe('/Users/test/My Obsidian Vault'); }); it('should handle Windows-style paths', () => { const mockApp = { vault: { adapter: { basePath: 'C:\\Users\\test\\vault', }, }, } as any; const result = getVaultPath(mockApp); expect(result).toBe('C:\\Users\\test\\vault'); }); }); describe('parseEnvironmentVariables', () => { it('should parse simple KEY=VALUE pairs', () => { const input = 'API_KEY=abc123\nDEBUG=true'; const result = parseEnvironmentVariables(input); expect(result).toEqual({ API_KEY: 'abc123', DEBUG: 'true', }); }); it('should skip empty lines', () => { const input = 'KEY1=value1\n\nKEY2=value2\n\n'; const result = parseEnvironmentVariables(input); expect(result).toEqual({ KEY1: 'value1', KEY2: 'value2', }); }); it('should skip comment lines starting with #', () => { const input = '# This is a comment\nKEY=value\n# Another comment'; const result = parseEnvironmentVariables(input); expect(result).toEqual({ KEY: 'value', }); }); it('should handle values with = signs', () => { const input = 'URL=https://example.com?foo=bar&baz=qux'; const result = parseEnvironmentVariables(input); expect(result).toEqual({ URL: 'https://example.com?foo=bar&baz=qux', }); }); it('should trim whitespace from keys and values', () => { const input = ' KEY = value '; const result = parseEnvironmentVariables(input); expect(result).toEqual({ KEY: 'value', }); }); it('should skip lines without = sign', () => { const input = 'VALID=value\nINVALID_LINE\nANOTHER=test'; const result = parseEnvironmentVariables(input); expect(result).toEqual({ VALID: 'value', ANOTHER: 'test', }); }); it('should skip lines with = at start (no key)', () => { const input = '=value\nKEY=valid\n =also-no-key'; const result = parseEnvironmentVariables(input); expect(result).toEqual({ KEY: 'valid', }); }); it('should return empty object for empty input', () => { expect(parseEnvironmentVariables('')).toEqual({}); expect(parseEnvironmentVariables(' ')).toEqual({}); expect(parseEnvironmentVariables('\n\n')).toEqual({}); }); it('should handle values with spaces', () => { const input = 'MESSAGE=Hello World'; const result = parseEnvironmentVariables(input); expect(result).toEqual({ MESSAGE: 'Hello World', }); }); it('should strip surrounding double quotes from values', () => { const input = 'URL="https://api.example.com"\nKEY="secret-key"'; const result = parseEnvironmentVariables(input); expect(result).toEqual({ URL: 'https://api.example.com', KEY: 'secret-key', }); }); it('should strip surrounding single quotes from values', () => { const input = "URL='https://api.example.com'\nKEY='secret-key'"; const result = parseEnvironmentVariables(input); expect(result).toEqual({ URL: 'https://api.example.com', KEY: 'secret-key', }); }); it('should not strip mismatched quotes', () => { const input = 'VAL1="not-closed\nVAL2=\'also-not-closed\nVAL3="mixed\''; const result = parseEnvironmentVariables(input); expect(result).toEqual({ VAL1: '"not-closed', VAL2: "'also-not-closed", VAL3: '"mixed\'', }); }); it('should preserve quotes inside values', () => { const input = 'JSON={"key": "value"}'; const result = parseEnvironmentVariables(input); expect(result).toEqual({ JSON: '{"key": "value"}', }); }); }); describe('expandHomePath', () => { const envKey = 'CLAUDIAN_TEST_PATH'; const envValue = path.join(os.tmpdir(), 'claudian-env'); let originalValue: string | undefined; beforeEach(() => { originalValue = process.env[envKey]; process.env[envKey] = envValue; }); afterEach(() => { if (originalValue === undefined) { delete process.env[envKey]; } else { process.env[envKey] = originalValue; } }); it('should expand percent-style environment variables', () => { expect(expandHomePath(`%${envKey}%`)).toBe(envValue); }); it('should expand dollar-style environment variables', () => { const braceStyle = '${' + envKey + '}'; expect(expandHomePath(`$${envKey}`)).toBe(envValue); expect(expandHomePath(braceStyle)).toBe(envValue); }); it('should handle Windows-specific environment variable formats based on platform', () => { const powerShellStyle = `$env:${envKey}`; const cmdStyle = `!${envKey}!`; // On Windows: expanded; on Unix: unchanged const expectedPowerShell = process.platform === 'win32' ? envValue : powerShellStyle; const expectedCmd = process.platform === 'win32' ? envValue : cmdStyle; expect(expandHomePath(powerShellStyle)).toBe(expectedPowerShell); expect(expandHomePath(cmdStyle)).toBe(expectedCmd); }); it('should leave unknown environment variables untouched', () => { expect(expandHomePath('%CLAUDIAN_MISSING_VAR%')).toBe('%CLAUDIAN_MISSING_VAR%'); expect(expandHomePath('$CLAUDIAN_MISSING_VAR')).toBe('$CLAUDIAN_MISSING_VAR'); }); }); describe('normalizePathForFilesystem', () => { const originalPlatform = process.platform; afterEach(() => { Object.defineProperty(process, 'platform', { value: originalPlatform }); }); it('expands home paths before filesystem use', () => { const expected = path.join(os.homedir(), 'notes/file.md'); expect(normalizePathForFilesystem('~/notes/file.md')).toBe(expected); }); it('expands environment variables before filesystem use', () => { const envKey = 'CLAUDIAN_FS_TEST_PATH'; const originalValue = process.env[envKey]; process.env[envKey] = '/tmp/claudian-test'; try { expect(normalizePathForFilesystem(`$${envKey}/notes/file.md`)).toBe('/tmp/claudian-test/notes/file.md'); } finally { if (originalValue === undefined) { delete process.env[envKey]; } else { process.env[envKey] = originalValue; } } }); it('strips Windows device prefixes when platform is win32', () => { Object.defineProperty(process, 'platform', { value: 'win32' }); expect(normalizePathForFilesystem('\\\\?\\C:\\Users\\test\\file.txt')).toBe('C:\\Users\\test\\file.txt'); expect(normalizePathForFilesystem('\\\\?\\UNC\\server\\share\\file.txt')).toBe('\\\\server\\share\\file.txt'); }); it('translates MSYS paths when platform is win32', () => { Object.defineProperty(process, 'platform', { value: 'win32' }); expect(normalizePathForFilesystem('/c/Users/test/file.txt')).toBe('C:\\Users\\test\\file.txt'); }); it('handles empty string input', () => { expect(normalizePathForFilesystem('')).toBe(''); }); it('handles non-existent environment variables', () => { // Non-existent env vars should be left as-is expect(normalizePathForFilesystem('$NONEXISTENT/path')).toBe('$NONEXISTENT/path'); expect(normalizePathForFilesystem('%NONEXISTENT%/path')).toBe('%NONEXISTENT%/path'); }); it('handles mixed path separators', () => { // Mixed / and \ should be normalized by path operations const result = normalizePathForFilesystem('C:/Users\\test/path.txt'); // On Windows: path module normalizes, on Unix: keeps as-is expect(result).toBeTruthy(); }); it('handles chained home and environment variable expansions', () => { const envKey = 'CLAUDIAN_TEST_SUBDIR'; const originalValue = process.env[envKey]; process.env[envKey] = 'project'; try { const result = normalizePathForFilesystem(`~/$${envKey}/file.md`); const expected = path.join(os.homedir(), 'project', 'file.md'); expect(result).toBe(expected); } finally { if (originalValue === undefined) { delete process.env[envKey]; } else { process.env[envKey] = originalValue; } } }); it('handles Windows env vars with parentheses like ProgramFiles(x86)', () => { const originalPlatform = process.platform; Object.defineProperty(process, 'platform', { value: 'win32' }); const originalPFx86 = process.env['ProgramFiles(x86)']; try { process.env['ProgramFiles(x86)'] = 'C:\\Program Files (x86)'; const result = normalizePathForFilesystem('%ProgramFiles(x86)%/app/file.txt'); expect(result).toBe('C:\\Program Files (x86)\\app\\file.txt'); } finally { if (originalPFx86 === undefined) { delete process.env['ProgramFiles(x86)']; } else { process.env['ProgramFiles(x86)'] = originalPFx86; } Object.defineProperty(process, 'platform', { value: originalPlatform }); } }); }); describe('normalizePathForVault', () => { it('returns vault-relative path for absolute input inside vault', () => { expect(normalizePathForVault('/vault/notes/a.md', '/vault')).toBe('notes/a.md'); }); it('returns vault-relative path for relative input inside vault', () => { expect(normalizePathForVault('notes/a.md', '/vault')).toBe('notes/a.md'); }); it('returns normalized path for external input', () => { expect(normalizePathForVault('/outside/file.md', '/vault')).toBe('/outside/file.md'); }); it('returns null for empty input', () => { expect(normalizePathForVault('', '/vault')).toBeNull(); }); }); describe('appendMarkdownSnippet', () => { it('should append snippet as-is when existing prompt is empty', () => { expect(appendMarkdownSnippet('', ' - Test ')).toBe('- Test'); }); it('should append snippet with a blank line separator by default', () => { const existing = '## Existing\n\n- A'; const snippet = '## New\n\n- B'; expect(appendMarkdownSnippet(existing, snippet)).toBe('## Existing\n\n- A\n\n## New\n\n- B'); }); it('should ensure a blank line separation when existing ends with a newline', () => { const existing = '## Existing\n'; const snippet = '- B'; expect(appendMarkdownSnippet(existing, snippet)).toBe('## Existing\n\n- B'); }); it('should not add extra spacing when existing ends with a blank line', () => { const existing = '## Existing\n\n'; const snippet = '- B'; expect(appendMarkdownSnippet(existing, snippet)).toBe('## Existing\n\n- B'); }); it('should return existing prompt unchanged when snippet is empty', () => { expect(appendMarkdownSnippet('## Existing', ' ')).toBe('## Existing'); }); }); describe('getModelsFromEnvironment', () => { it('should extract model from ANTHROPIC_MODEL', () => { const envVars = { ANTHROPIC_MODEL: 'claude-3-opus' }; const result = getModelsFromEnvironment(envVars); expect(result).toHaveLength(1); expect(result[0].value).toBe('claude-3-opus'); expect(result[0].description).toContain('model'); }); it('should extract models from ANTHROPIC_DEFAULT_*_MODEL variables', () => { const envVars = { ANTHROPIC_DEFAULT_OPUS_MODEL: 'custom-opus', ANTHROPIC_DEFAULT_SONNET_MODEL: 'custom-sonnet', ANTHROPIC_DEFAULT_HAIKU_MODEL: 'custom-haiku', }; const result = getModelsFromEnvironment(envVars); expect(result).toHaveLength(3); expect(result.map(m => m.value)).toContain('custom-opus'); expect(result.map(m => m.value)).toContain('custom-sonnet'); expect(result.map(m => m.value)).toContain('custom-haiku'); }); it('should deduplicate models with same value', () => { const envVars = { ANTHROPIC_MODEL: 'same-model', ANTHROPIC_DEFAULT_OPUS_MODEL: 'same-model', }; const result = getModelsFromEnvironment(envVars); expect(result).toHaveLength(1); expect(result[0].value).toBe('same-model'); expect(result[0].description).toContain('model'); expect(result[0].description).toContain('opus'); }); it('should return empty array when no model variables are set', () => { const envVars = { OTHER_VAR: 'value' }; const result = getModelsFromEnvironment(envVars); expect(result).toEqual([]); }); it('should handle model names with slashes (provider/model format)', () => { const envVars = { ANTHROPIC_MODEL: 'anthropic/claude-3-opus' }; const result = getModelsFromEnvironment(envVars); expect(result).toHaveLength(1); expect(result[0].value).toBe('anthropic/claude-3-opus'); expect(result[0].label).toBe('claude-3-opus'); }); it('should fallback to full value when slash-split yields empty', () => { const envVars = { ANTHROPIC_MODEL: 'trailing-slash/' }; const result = getModelsFromEnvironment(envVars); expect(result).toHaveLength(1); expect(result[0].label).toBe('trailing-slash/'); }); it('should sort models by priority (model > haiku > sonnet > opus)', () => { const envVars = { ANTHROPIC_DEFAULT_OPUS_MODEL: 'opus-model', ANTHROPIC_MODEL: 'main-model', ANTHROPIC_DEFAULT_SONNET_MODEL: 'sonnet-model', }; const result = getModelsFromEnvironment(envVars); expect(result[0].value).toBe('main-model'); expect(result[1].value).toBe('sonnet-model'); expect(result[2].value).toBe('opus-model'); }); }); describe('getCurrentModelFromEnvironment', () => { it('should return ANTHROPIC_MODEL if set', () => { const envVars = { ANTHROPIC_MODEL: 'main-model', ANTHROPIC_DEFAULT_OPUS_MODEL: 'opus-model', }; const result = getCurrentModelFromEnvironment(envVars); expect(result).toBe('main-model'); }); it('should return ANTHROPIC_DEFAULT_HAIKU_MODEL if ANTHROPIC_MODEL not set', () => { const envVars = { ANTHROPIC_DEFAULT_HAIKU_MODEL: 'haiku-model', ANTHROPIC_DEFAULT_SONNET_MODEL: 'sonnet-model', }; const result = getCurrentModelFromEnvironment(envVars); expect(result).toBe('haiku-model'); }); it('should return ANTHROPIC_DEFAULT_SONNET_MODEL if higher priority not set', () => { const envVars = { ANTHROPIC_DEFAULT_SONNET_MODEL: 'sonnet-model', ANTHROPIC_DEFAULT_OPUS_MODEL: 'opus-model', }; const result = getCurrentModelFromEnvironment(envVars); expect(result).toBe('sonnet-model'); }); it('should return ANTHROPIC_DEFAULT_HAIKU_MODEL if only that is set', () => { const envVars = { ANTHROPIC_DEFAULT_HAIKU_MODEL: 'haiku-model', }; const result = getCurrentModelFromEnvironment(envVars); expect(result).toBe('haiku-model'); }); it('should return null if no model variables are set', () => { const envVars = { OTHER_VAR: 'value' }; const result = getCurrentModelFromEnvironment(envVars); expect(result).toBeNull(); }); it('should return null for empty object', () => { const result = getCurrentModelFromEnvironment({}); expect(result).toBeNull(); }); }); describe('findClaudeCLIPath', () => { const originalPlatform = process.platform; let originalEnv: NodeJS.ProcessEnv; beforeEach(() => { originalEnv = { ...process.env }; process.env.PATH = ''; }); afterEach(() => { jest.restoreAllMocks(); Object.defineProperty(process, 'platform', { value: originalPlatform }); process.env = originalEnv; }); describe('on Unix/macOS', () => { beforeEach(() => { Object.defineProperty(process, 'platform', { value: 'darwin' }); }); function mockExistingFile(...paths: string[]) { const pathSet = new Set(paths); jest.spyOn(fs, 'existsSync').mockImplementation((p: any) => pathSet.has(p)); jest.spyOn(fs, 'statSync').mockImplementation((p: any) => ({ isFile: () => pathSet.has(String(p)), }) as fs.Stats); } it('should return first matching Claude CLI path', () => { jest.spyOn(os, 'homedir').mockReturnValue('/home/test'); mockExistingFile('/home/test/.local/bin/claude'); expect(findClaudeCLIPath()).toBe('/home/test/.local/bin/claude'); }); it('should return null when Claude CLI is not found', () => { jest.spyOn(os, 'homedir').mockReturnValue('/home/test'); jest.spyOn(fs, 'existsSync').mockReturnValue(false as any); expect(findClaudeCLIPath()).toBeNull(); }); it('should check cli.js paths as fallback on Unix', () => { jest.spyOn(os, 'homedir').mockReturnValue('/home/test'); mockExistingFile('/usr/local/lib/node_modules/@anthropic-ai/claude-code/cli.js'); expect(findClaudeCLIPath()).toBe('/usr/local/lib/node_modules/@anthropic-ai/claude-code/cli.js'); }); it('should resolve Claude CLI from custom PATH', () => { mockExistingFile('/custom/bin/claude'); const customPath = '/custom/bin:/usr/bin'; expect(findClaudeCLIPath(customPath)).toBe('/custom/bin/claude'); }); it('should expand home directory in custom PATH', () => { jest.spyOn(os, 'homedir').mockReturnValue('/home/test'); mockExistingFile('/home/test/bin/claude'); const customPath = '~/bin:/usr/bin'; expect(findClaudeCLIPath(customPath)).toBe('/home/test/bin/claude'); }); it('should not return a directory path even if it exists', () => { jest.spyOn(os, 'homedir').mockReturnValue('/home/test'); const dirPath = path.join('/home/test', '.local', 'bin', 'claude'); jest.spyOn(fs, 'existsSync').mockImplementation((p: any) => p === dirPath); jest.spyOn(fs, 'statSync').mockImplementation(() => ({ isFile: () => false, }) as fs.Stats); expect(findClaudeCLIPath()).toBeNull(); }); }); describe('on Windows', () => { beforeEach(() => { Object.defineProperty(process, 'platform', { value: 'win32' }); process.env.ProgramFiles = 'C:\\Program Files'; process.env['ProgramFiles(x86)'] = 'C:\\Program Files (x86)'; process.env.APPDATA = 'C:\\Users\\test\\AppData\\Roaming'; }); function mockExistingFile(...paths: string[]) { const pathSet = new Set(paths); jest.spyOn(fs, 'existsSync').mockImplementation((p: any) => pathSet.has(p)); jest.spyOn(fs, 'statSync').mockImplementation((p: any) => ({ isFile: () => pathSet.has(String(p)), }) as fs.Stats); } it('should prefer .exe when both .exe and cli.js exist', () => { jest.spyOn(os, 'homedir').mockReturnValue('C:\\Users\\test'); const exePath = path.join('C:\\Users\\test', '.claude', 'local', 'claude.exe'); const cliJsPath = path.join('C:\\Users\\test', 'AppData', 'Roaming', 'npm', 'node_modules', '@anthropic-ai', 'claude-code', 'cli.js'); mockExistingFile(exePath, cliJsPath); expect(findClaudeCLIPath()).toBe(exePath); }); it('should prioritize cli.js over .cmd files on Windows', () => { jest.spyOn(os, 'homedir').mockReturnValue('C:\\Users\\test'); // Note: path.join uses actual platform separator, so we match against that const cliJsPath = path.join('C:\\Users\\test', 'AppData', 'Roaming', 'npm', 'node_modules', '@anthropic-ai', 'claude-code', 'cli.js'); const cmdPath = path.join('C:\\Users\\test', 'AppData', 'Roaming', 'npm', 'claude.cmd'); // Both .cmd and cli.js exist, but cli.js should be returned (cmd is ignored entirely) mockExistingFile(cmdPath, cliJsPath); // Should return cli.js, not claude.cmd expect(findClaudeCLIPath()).toBe(cliJsPath); }); it('should find cli.js in custom npm global path via npm_config_prefix', () => { jest.spyOn(os, 'homedir').mockReturnValue('C:\\Users\\test'); process.env.npm_config_prefix = 'D:\\nodejs\\node_global'; const expectedPath = path.join('D:\\nodejs\\node_global', 'node_modules', '@anthropic-ai', 'claude-code', 'cli.js'); mockExistingFile(expectedPath); expect(findClaudeCLIPath()).toBe(expectedPath); }); it('should fall back to .exe if cli.js not found', () => { jest.spyOn(os, 'homedir').mockReturnValue('C:\\Users\\test'); const expectedPath = path.join('C:\\Users\\test', '.claude', 'local', 'claude.exe'); mockExistingFile(expectedPath); expect(findClaudeCLIPath()).toBe(expectedPath); }); it('should ignore .cmd fallback on Windows', () => { jest.spyOn(os, 'homedir').mockReturnValue('C:\\Users\\test'); const expectedPath = path.join('C:\\Users\\test', 'AppData', 'Roaming', 'npm', 'claude.cmd'); mockExistingFile(expectedPath); expect(findClaudeCLIPath()).toBeNull(); }); it('should return null when no CLI is found on Windows', () => { jest.spyOn(os, 'homedir').mockReturnValue('C:\\Users\\test'); jest.spyOn(fs, 'existsSync').mockReturnValue(false as any); expect(findClaudeCLIPath()).toBeNull(); }); it('should resolve cli.js from custom PATH npm prefix', () => { const npmBin = 'C:\\Users\\test\\AppData\\Roaming\\npm'; const cliJsPath = path.join(npmBin, 'node_modules', '@anthropic-ai', 'claude-code', 'cli.js'); mockExistingFile(cliJsPath); const customPath = `${npmBin};C:\\Windows\\System32`; expect(findClaudeCLIPath(customPath)).toBe(cliJsPath); }); it('should not return a directory path even if it exists', () => { jest.spyOn(os, 'homedir').mockReturnValue('C:\\Users\\test'); const dirPath = path.join('C:\\Users\\test', '.claude', 'local', 'claude'); // Simulate a directory named 'claude' (exists but isFile returns false) jest.spyOn(fs, 'existsSync').mockImplementation((p: any) => p === dirPath); jest.spyOn(fs, 'statSync').mockImplementation(() => ({ isFile: () => false, }) as fs.Stats); expect(findClaudeCLIPath()).toBeNull(); }); }); }); describe('isPathInAllowedExportPaths', () => { afterEach(() => { jest.restoreAllMocks(); }); it('should return false when allowed export paths is empty', () => { expect(isPathInAllowedExportPaths('/tmp/out.md', [], '/vault')).toBe(false); }); it('should allow candidate path within allowed export directory', () => { const realpathSpy = jest.spyOn(fs, 'realpathSync').mockImplementation((p: any) => path.resolve(String(p)) as any); (fs.realpathSync as any).native = realpathSpy; expect(isPathInAllowedExportPaths('/tmp/out.md', ['/tmp'], '/vault')).toBe(true); expect(isPathInAllowedExportPaths('/var/out.md', ['/tmp'], '/vault')).toBe(false); }); it('should expand tilde for export paths and candidate paths', () => { jest.spyOn(os, 'homedir').mockReturnValue('/home/test'); const realpathSpy = jest.spyOn(fs, 'realpathSync').mockImplementation((p: any) => path.resolve(String(p)) as any); (fs.realpathSync as any).native = realpathSpy; expect(isPathInAllowedExportPaths('~/Desktop/out.md', ['~/Desktop'], '/vault')).toBe(true); expect(isPathInAllowedExportPaths('~/Downloads/out.md', ['~/Desktop'], '/vault')).toBe(false); }); }); describe('getPathAccessType', () => { afterEach(() => { jest.restoreAllMocks(); }); const stubRealpath = () => { const realpathSpy = jest.spyOn(fs, 'realpathSync').mockImplementation((p: any) => path.resolve(String(p)) as any); (fs.realpathSync as any).native = realpathSpy; }; it('should return vault for paths inside vault', () => { stubRealpath(); expect(getPathAccessType('notes/a.md', [], [], '/vault')).toBe('vault'); }); it('should treat exact overlap as read-write', () => { stubRealpath(); expect(getPathAccessType('/tmp/shared/out.md', ['/tmp/shared'], ['/tmp/shared'], '/vault')).toBe('readwrite'); }); it('should prefer context over export for nested paths', () => { stubRealpath(); const allowedExportPaths = ['/tmp']; const allowedContextPaths = ['/tmp/workspace']; expect(getPathAccessType('/tmp/workspace/file.md', allowedContextPaths, allowedExportPaths, '/vault')).toBe('context'); expect(getPathAccessType('/tmp/out.md', allowedContextPaths, allowedExportPaths, '/vault')).toBe('export'); }); it('should let a nested context override a read-write parent', () => { stubRealpath(); const allowedExportPaths = ['/tmp/shared']; const allowedContextPaths = ['/tmp/shared', '/tmp/shared/readonly']; expect(getPathAccessType('/tmp/shared/readonly/file.md', allowedContextPaths, allowedExportPaths, '/vault')).toBe('context'); expect(getPathAccessType('/tmp/shared/file.md', allowedContextPaths, allowedExportPaths, '/vault')).toBe('readwrite'); }); it('should allow vault access to safe ~/.claude/ subdirectories', () => { jest.spyOn(os, 'homedir').mockReturnValue('/home/test'); const realpathSpy = jest.spyOn(fs, 'realpathSync').mockImplementation((p: any) => String(p) as any); (fs.realpathSync as any).native = realpathSpy; expect(getPathAccessType('/home/test/.claude', [], [], '/vault')).toBe('context'); expect(getPathAccessType('/home/test/.claude/settings.json', [], [], '/vault')).toBe('vault'); expect(getPathAccessType('/home/test/.claude/hooks/pre-commit.sh', [], [], '/vault')).toBe('context'); }); it('should allow access to ~/.claude/ via tilde expansion', () => { jest.spyOn(os, 'homedir').mockReturnValue('/home/test'); const realpathSpy = jest.spyOn(fs, 'realpathSync').mockImplementation((p: any) => String(p) as any); (fs.realpathSync as any).native = realpathSpy; expect(getPathAccessType('~/.claude', [], [], '/vault')).toBe('context'); expect(getPathAccessType('~/.claude/sessions/abc.jsonl', [], [], '/vault')).toBe('vault'); }); it('should block other home directory paths', () => { jest.spyOn(os, 'homedir').mockReturnValue('/home/test'); const realpathSpy = jest.spyOn(fs, 'realpathSync').mockImplementation((p: any) => String(p) as any); (fs.realpathSync as any).native = realpathSpy; expect(getPathAccessType('/home/test/.ssh/id_rsa', [], [], '/vault')).toBe('none'); expect(getPathAccessType('~/.ssh/id_rsa', [], [], '/vault')).toBe('none'); expect(getPathAccessType('/home/test/Documents/secret.txt', [], [], '/vault')).toBe('none'); }); }); describe('isPathWithinVault', () => { afterEach(() => { jest.restoreAllMocks(); }); it('should allow relative paths within vault', () => { expect(isPathWithinVault('notes/a.md', '/vault')).toBe(true); }); it('should block path traversal escaping vault', () => { expect(isPathWithinVault('../secrets.txt', '/vault')).toBe(false); }); it('should allow absolute paths inside vault', () => { expect(isPathWithinVault('/vault/notes/a.md', '/vault')).toBe(true); }); it('should block absolute paths outside vault', () => { expect(isPathWithinVault('/etc/passwd', '/vault')).toBe(false); }); it('should expand tilde and still enforce vault boundary', () => { jest.spyOn(os, 'homedir').mockReturnValue('/home/test'); expect(isPathWithinVault('~/vault/notes/a.md', '/vault')).toBe(false); }); it('should allow exact vault path', () => { expect(isPathWithinVault('/vault', '/vault')).toBe(true); expect(isPathWithinVault('.', '/vault')).toBe(true); }); it('should handle non-existent paths via fallback resolution', () => { // When fs.realpathSync throws (file doesn't exist), path.resolve is used jest.spyOn(fs, 'realpathSync').mockImplementation(() => { throw new Error('ENOENT'); }); // Even with mock throwing, function should still work via fallback expect(isPathWithinVault('nonexistent/path.md', '/vault')).toBe(true); }); it('should block symlink escapes for non-existent targets', () => { jest.spyOn(fs, 'existsSync').mockImplementation((p: any) => { const s = String(p); return s === '/' || s === '/vault' || s === '/vault/export'; }); const realpathSpy = jest.spyOn(fs, 'realpathSync').mockImplementation((p: any) => { const s = String(p); if (s === '/') return '/'; if (s === '/vault') return '/vault'; if (s === '/vault/export') return '/tmp/export'; throw new Error('ENOENT'); }); (fs.realpathSync as any).native = realpathSpy; expect(isPathWithinVault('export/newfile.txt', '/vault')).toBe(false); }); }); describe('Windows separator normalization', () => { const originalPlatform = process.platform; const originalSep = path.sep; const originalIsAbsolute = path.isAbsolute; beforeEach(() => { Object.defineProperty(process, 'platform', { value: 'win32' }); // Force Windows-style separator to detect regressions when comparisons rely on path.sep. Object.defineProperty(path, 'sep', { value: '\\', writable: true }); jest.spyOn(path, 'isAbsolute').mockImplementation((p: any) => { const value = String(p); return /^[A-Za-z]:[\\/]/.test(value) || originalIsAbsolute(value); }); const realpathSpy = jest.spyOn(fs, 'realpathSync').mockImplementation((p: any) => String(p) as any); (fs.realpathSync as any).native = realpathSpy; }); afterEach(() => { Object.defineProperty(process, 'platform', { value: originalPlatform }); Object.defineProperty(path, 'sep', { value: originalSep, writable: true }); jest.restoreAllMocks(); }); it('allows vault paths after slash normalization', () => { expect(isPathWithinVault('C:\\Users\\test\\vault\\note.md', 'C:\\Users\\test\\vault')).toBe(true); }); it('allows export paths after slash normalization', () => { expect( isPathInAllowedExportPaths( 'C:\\Users\\test\\export\\out.md', ['C:\\Users\\test\\export'], 'C:\\Users\\test\\vault' ) ).toBe(true); }); it('treats vault paths as vault access after normalization', () => { expect(getPathAccessType( 'C:\\Users\\test\\vault\\note.md', [], [], 'C:\\Users\\test\\vault' )).toBe('vault'); }); it('resolves access type using normalized boundaries', () => { expect(getPathAccessType( 'C:\\Users\\test\\shared\\note.md', ['C:\\Users\\test\\shared'], ['C:\\Users\\test\\shared'], 'C:\\Users\\test\\vault' )).toBe('readwrite'); }); it('treats ~/.claude paths as vault access after normalization', () => { jest.spyOn(os, 'homedir').mockReturnValue('C:\\Users\\test'); expect(getPathAccessType( 'C:\\Users\\test\\.claude\\settings.json', [], [], 'C:\\Users\\test\\vault' )).toBe('vault'); }); }); describe('translateMsysPath', () => { const originalPlatform = process.platform; afterEach(() => { Object.defineProperty(process, 'platform', { value: originalPlatform }); }); describe('on Windows', () => { beforeEach(() => { Object.defineProperty(process, 'platform', { value: 'win32' }); }); it('should translate MSYS drive paths to Windows paths', () => { expect(translateMsysPath('/c/Users/test')).toBe('C:\\Users\\test'); expect(translateMsysPath('/d/Projects/vault')).toBe('D:\\Projects\\vault'); }); it('should handle uppercase drive letters', () => { expect(translateMsysPath('/C/Users/test')).toBe('C:\\Users\\test'); }); it('should handle root drive paths', () => { expect(translateMsysPath('/c')).toBe('C:'); expect(translateMsysPath('/c/')).toBe('C:\\'); }); it('should not translate non-MSYS absolute paths', () => { expect(translateMsysPath('/home/user')).toBe('/home/user'); expect(translateMsysPath('/tmp/file.txt')).toBe('/tmp/file.txt'); }); it('should not translate Windows native paths', () => { expect(translateMsysPath('C:\\Users\\test')).toBe('C:\\Users\\test'); }); it('should not translate relative paths', () => { expect(translateMsysPath('./file.txt')).toBe('./file.txt'); expect(translateMsysPath('../parent/file.txt')).toBe('../parent/file.txt'); }); }); describe('on Unix', () => { beforeEach(() => { Object.defineProperty(process, 'platform', { value: 'darwin' }); }); it('should not translate any paths', () => { expect(translateMsysPath('/c/Users/test')).toBe('/c/Users/test'); expect(translateMsysPath('/home/user')).toBe('/home/user'); }); }); }); describe('Windows path handling', () => { // Note: Full integration tests for Windows path validation require running on Windows // because Node's `path` module behavior is determined at module load time. // These tests verify the translateMsysPath function which is platform-mockable. describe('translateMsysPath behavior', () => { const originalPlatform = process.platform; afterEach(() => { Object.defineProperty(process, 'platform', { value: originalPlatform }); }); it('translates MSYS paths to Windows paths when platform is win32', () => { Object.defineProperty(process, 'platform', { value: 'win32' }); expect(translateMsysPath('/c/Users/test')).toBe('C:\\Users\\test'); expect(translateMsysPath('/d/Projects/vault')).toBe('D:\\Projects\\vault'); expect(translateMsysPath('/c')).toBe('C:'); expect(translateMsysPath('/c/')).toBe('C:\\'); }); it('does not translate non-MSYS paths on Windows', () => { Object.defineProperty(process, 'platform', { value: 'win32' }); // Multi-letter paths after / are not MSYS drive paths expect(translateMsysPath('/home/user')).toBe('/home/user'); expect(translateMsysPath('/tmp/file')).toBe('/tmp/file'); // Already Windows paths expect(translateMsysPath('C:\\Users')).toBe('C:\\Users'); // Relative paths expect(translateMsysPath('./file')).toBe('./file'); }); it('does not translate any paths on non-Windows', () => { Object.defineProperty(process, 'platform', { value: 'darwin' }); expect(translateMsysPath('/c/Users/test')).toBe('/c/Users/test'); expect(translateMsysPath('/home/user')).toBe('/home/user'); }); }); }); }); ================================================ FILE: tsconfig.jest.json ================================================ { "extends": "./tsconfig.json", "compilerOptions": { "module": "CommonJS", "types": ["jest", "node"] } } ================================================ FILE: tsconfig.json ================================================ { "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["src/*"], "@test/*": ["tests/*"], "@modelcontextprotocol/sdk/*": ["node_modules/@modelcontextprotocol/sdk/dist/esm/*"] }, "skipLibCheck": true, "inlineSourceMap": true, "inlineSources": true, "module": "ESNext", "target": "ES6", "allowJs": true, "noImplicitAny": true, "moduleResolution": "node", "importHelpers": true, "isolatedModules": true, "strictNullChecks": true, "resolveJsonModule": true, "lib": ["DOM", "ES6", "ES7"], "outDir": "./dist", "rootDir": "." }, "include": ["src/**/*.ts", "tests/**/*.ts"] } ================================================ FILE: versions.json ================================================ { "1.0.0": "1.0.0", "0.1.0": "1.0.0" }