├── scripts ├── deploytest.js ├── prepare-publish.js └── deploy.js ├── assets ├── gifs │ ├── vscodeagent.gif │ ├── tabbysettings.gif │ ├── .gitkeep │ └── README.md └── images │ ├── tabby-navbar.jpg │ ├── tabby-settings.jpg │ ├── .gitkeep │ └── vscodechat-flow.jpg ├── src ├── utils │ └── escapeShellString.ts ├── tools │ ├── terminal │ │ ├── index.ts │ │ ├── base-tool.ts │ │ ├── vscode-tool.ts │ │ ├── ssh-session-list-tool.ts │ │ ├── open-copilot-tool.ts │ │ ├── get-terminal-buffer-tool.ts │ │ └── get-command-output-tool.ts │ ├── vscode-tool-category.ts │ ├── base-tool-category.ts │ ├── shell-strategy.ts │ └── terminal.ts ├── settings.ts ├── services │ ├── urlOpening.service.ts │ ├── mcpHotkeyProvider.service.ts │ ├── mcpConfigProvider.ts │ ├── mcpLogger.service.ts │ ├── runningCommandsManager.service.ts │ ├── dialog.service.ts │ ├── minimizedDialogManager.service.ts │ ├── commandOutputStorage.service.ts │ ├── mcpHotkey.service.ts │ ├── mcpService.ts │ ├── dialogManager.service.ts │ └── commandHistoryManager.service.ts ├── types.d.ts ├── components │ ├── extensionRecommendationDialog.component.pug │ ├── extensionRecommendationDialog.component.ts │ ├── extensionRecommendationDialog.component.scss │ ├── execCommandButton.component.ts │ ├── confirmCommandDialog.component.pug │ ├── commandDialogs.scss │ ├── commandResultDialog.component.pug │ ├── commandHistoryModal.component.scss │ ├── mcpSettingsTab.component.pug │ ├── runningCommandsDialog.component.ts │ ├── mcpSettingsTab.component.scss │ ├── confirmCommandDialog.component.ts │ ├── minimizedModal.component.ts │ └── commandResultDialog.component.ts ├── styles.scss ├── type │ └── types.ts ├── index.ts └── toolbarButtonProvider.ts ├── tsconfig.typings.json ├── .vscode └── tasks.json ├── .github ├── PULL_REQUEST_TEMPLATE.md └── workflows │ └── test-pr.yml ├── tsconfig.json ├── CHANGELOG.md ├── webpack.config.mjs ├── package.json ├── .gitignore └── README.md /scripts/deploytest.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assets/gifs/vscodeagent.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteffMet/tabby-vscode-agent/HEAD/assets/gifs/vscodeagent.gif -------------------------------------------------------------------------------- /assets/gifs/tabbysettings.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteffMet/tabby-vscode-agent/HEAD/assets/gifs/tabbysettings.gif -------------------------------------------------------------------------------- /assets/images/tabby-navbar.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteffMet/tabby-vscode-agent/HEAD/assets/images/tabby-navbar.jpg -------------------------------------------------------------------------------- /assets/images/tabby-settings.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteffMet/tabby-vscode-agent/HEAD/assets/images/tabby-settings.jpg -------------------------------------------------------------------------------- /assets/images/.gitkeep: -------------------------------------------------------------------------------- 1 | # This file exists to ensure this directory is tracked in Git 2 | # Place your screenshot and image files here -------------------------------------------------------------------------------- /assets/images/vscodechat-flow.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteffMet/tabby-vscode-agent/HEAD/assets/images/vscodechat-flow.jpg -------------------------------------------------------------------------------- /assets/gifs/.gitkeep: -------------------------------------------------------------------------------- 1 | # This file exists to ensure this directory is tracked in Git 2 | # Place your animated GIF demonstration files here -------------------------------------------------------------------------------- /src/utils/escapeShellString.ts: -------------------------------------------------------------------------------- 1 | export function escapeShellString(raw: string): string { 2 | return raw 3 | .replace(/\\/g, '\\\\') 4 | .replace(/"/g, '\\"') 5 | .replace(/\$/g, '\\$') 6 | .replace(/`/g, '\\`') 7 | .replace(/\n/g, '\\n'); 8 | } -------------------------------------------------------------------------------- /src/tools/terminal/index.ts: -------------------------------------------------------------------------------- 1 | export * from './base-tool'; 2 | export * from './ssh-session-list-tool'; 3 | export * from './exec-command-tool'; 4 | export * from './get-terminal-buffer-tool'; 5 | export * from './get-command-output-tool'; 6 | export * from './open-copilot-tool'; 7 | -------------------------------------------------------------------------------- /tsconfig.typings.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "exclude": ["node_modules", "dist", "typings"], 4 | "compilerOptions": { 5 | "baseUrl": "src", 6 | "emitDeclarationOnly": true, 7 | "declaration": true, 8 | "declarationDir": "./typings", 9 | } 10 | } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "type": "shell", 6 | "label": "Build and run tabby-mcp-server", 7 | "command": "npm run build && npm start", 8 | "group": "build", 9 | "problemMatcher": [ 10 | "$tsc" 11 | ] 12 | } 13 | ] 14 | } -------------------------------------------------------------------------------- /src/tools/terminal/base-tool.ts: -------------------------------------------------------------------------------- 1 | import { McpTool } from '../../type/types'; 2 | import { McpLoggerService } from '../../services/mcpLogger.service'; 3 | 4 | /** 5 | * Base class for terminal tools 6 | */ 7 | export abstract class BaseTool { 8 | protected logger: McpLoggerService; 9 | 10 | constructor(logger: McpLoggerService) { 11 | this.logger = logger; 12 | } 13 | /** 14 | * Get the tool definition 15 | */ 16 | abstract getTool(): McpTool; 17 | } 18 | -------------------------------------------------------------------------------- /src/tools/vscode-tool-category.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { ToolCategory } from '../type/types'; 3 | import { VSCodeTool } from './terminal/vscode-tool'; 4 | 5 | @Injectable({ providedIn: 'root' }) 6 | export class VSCodeToolCategory implements ToolCategory { 7 | name: string; 8 | mcpTools: any[]; 9 | 10 | constructor(private vscodeTool: VSCodeTool) { 11 | this.name = 'vscode'; 12 | this.mcpTools = [vscodeTool.getTool()]; 13 | } 14 | } -------------------------------------------------------------------------------- /src/settings.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { SettingsTabProvider } from 'tabby-settings'; 3 | 4 | import { McpSettingsTabComponent } from './components/mcpSettingsTab.component'; 5 | 6 | /** @hidden */ 7 | @Injectable() 8 | export class McpSettingsTabProvider extends SettingsTabProvider { 9 | id = 'copilot'; 10 | icon = 'code'; 11 | title = 'Copilot Agent'; 12 | 13 | getComponentType(): any { 14 | return McpSettingsTabComponent; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/services/urlOpening.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { exec } from 'child_process'; 3 | import { McpLoggerService } from './mcpLogger.service'; 4 | 5 | @Injectable({ providedIn: 'root' }) 6 | export class UrlOpeningService { 7 | constructor(private logger: McpLoggerService) {} 8 | 9 | openUrl(url: string): void { 10 | this.logger.info(`Opening URL: ${url}`); 11 | exec(`start ${url}`, (error) => { 12 | if (error) { 13 | this.logger.error(`Failed to open URL: ${url}. Falling back to window.open.`, error); 14 | window.open(url, '_blank'); 15 | } 16 | }); 17 | } 18 | } -------------------------------------------------------------------------------- /src/types.d.ts: -------------------------------------------------------------------------------- 1 | // External module declarations for Tabby plugin 2 | declare module '@angular/core' { export * from '@angular/core'; } 3 | declare module '@angular/common' { export * from '@angular/common'; } 4 | declare module '@angular/forms' { export * from '@angular/forms'; } 5 | declare module '@ng-bootstrap/ng-bootstrap' { export * from '@ng-bootstrap/ng-bootstrap'; } 6 | declare module 'tabby-core' { export * from 'tabby-core'; } 7 | declare module 'tabby-settings' { export * from 'tabby-settings'; } 8 | declare module 'tabby-terminal' { export * from 'tabby-terminal'; } 9 | declare module 'rxjs' { export * from 'rxjs'; } 10 | declare module 'rxjs/operators' { export * from 'rxjs/operators'; } 11 | -------------------------------------------------------------------------------- /src/components/extensionRecommendationDialog.component.pug: -------------------------------------------------------------------------------- 1 | .modal-header 2 | h4.modal-title 3 | i.fas.fa-puzzle-piece.me-2 4 | | Extension Recommendation 5 | button.btn-close((click)="dismiss()") 6 | 7 | .modal-body 8 | p 9 | | For a more seamless experience, we recommend installing the 10 | strong Tabby Copilot Opener 11 | | extension from the VS Code Marketplace. 12 | 13 | .d-flex.justify-content-center.my-3 14 | button.btn.btn-primary.btn-lg((click)="openMarketplace()") 15 | i.fas.fa-store.me-2 16 | | View in Marketplace 17 | 18 | small.text-muted 19 | | This extension allows Tabby to directly communicate with VS Code, enabling faster and more reliable opening of the Copilot window. 20 | 21 | .modal-footer 22 | button.btn.btn-secondary((click)="dismiss()") 23 | | Dismiss -------------------------------------------------------------------------------- /src/tools/terminal/vscode-tool.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { McpTool, McpResponse, createSuccessResponse } from '../../type/types'; 3 | import { AppService } from 'tabby-core'; 4 | 5 | @Injectable({ providedIn: 'root' }) 6 | export class VSCodeTool { 7 | constructor(private app: AppService) { } 8 | 9 | handler = async () => { 10 | this.app.emit('mcp-run-command', { 11 | command: 'workbench.action.chat.openInNewWindow', 12 | }); 13 | return createSuccessResponse('Command sent to open VSCode chat window'); 14 | } 15 | 16 | getTool(): McpTool { 17 | return { 18 | name: 'open-vscode-chat', 19 | description: 'Opens VSCode chat window', 20 | schema: {}, 21 | handler: this.handler, 22 | }; 23 | } 24 | } -------------------------------------------------------------------------------- /src/components/extensionRecommendationDialog.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; 3 | import { UrlOpeningService } from '../services/urlOpening.service'; 4 | 5 | @Component({ 6 | selector: 'extension-recommendation-dialog', 7 | template: require('./extensionRecommendationDialog.component.pug').default, 8 | styles: [require('./extensionRecommendationDialog.component.scss')], 9 | }) 10 | export class ExtensionRecommendationDialogComponent { 11 | constructor(public modal: NgbActiveModal, private urlOpeningService: UrlOpeningService) {} 12 | 13 | openMarketplace(): void { 14 | this.urlOpeningService.openUrl('https://marketplace.visualstudio.com/items?itemName=TabbyCopilotConnector.tabby-copilot-opener'); 15 | this.modal.close(); 16 | } 17 | 18 | dismiss(): void { 19 | this.modal.dismiss(); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/services/mcpHotkeyProvider.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { HotkeyProvider } from 'tabby-core'; 3 | import type { HotkeyDescription } from 'tabby-core'; 4 | 5 | /** @hidden */ 6 | @Injectable() 7 | export class McpHotkeyProvider extends HotkeyProvider { 8 | async provide(): Promise { 9 | return [ 10 | { 11 | id: 'mcp-abort-command', 12 | name: 'Abort running MCP command', 13 | }, 14 | { 15 | id: 'mcp-show-command-history', 16 | name: 'Show MCP command history', 17 | }, 18 | { 19 | id: 'mcp-show-running-commands', 20 | name: 'Show running MCP commands', 21 | }, 22 | { 23 | id: 'mcp-open-copilot', 24 | name: 'Open Copilot window', 25 | }, 26 | ]; 27 | } 28 | } -------------------------------------------------------------------------------- /src/services/mcpConfigProvider.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { ConfigProvider } from 'tabby-core'; 3 | 4 | /** 5 | * Provider for MCP module configuration defaults 6 | */ 7 | @Injectable() 8 | export class McpConfigProvider extends ConfigProvider { 9 | /** 10 | * Default configuration values 11 | */ 12 | defaults = { 13 | mcp: { 14 | startOnBoot: true, 15 | enabled: true, 16 | port: 3001, 17 | serverUrl: 'http://localhost:3001', 18 | enableDebugLogging: true, 19 | pairProgrammingMode: { 20 | enabled: false, 21 | autoFocusTerminal: true, 22 | showConfirmationDialog: true, 23 | showResultDialog: true 24 | } 25 | }, 26 | hotkeys: { 27 | 'mcp-abort-command': [ 28 | 'Ctrl-Shift-C', // Sử dụng Ctrl-Shift-C để tránh conflict với Ctrl-C thông thường 29 | ], 30 | }, 31 | }; 32 | 33 | /** 34 | * Platform-specific defaults 35 | */ 36 | platformDefaults = { }; 37 | } -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | Please include a summary of the changes and the related issue. Please also include relevant motivation and context. 3 | 4 | Fixes # (issue) 5 | 6 | ## Type of change 7 | Please delete options that are not relevant. 8 | 9 | - [ ] Bug fix (non-breaking change which fixes an issue) 10 | - [ ] New feature (non-breaking change which adds functionality) 11 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) 12 | - [ ] Documentation update 13 | 14 | ## How Has This Been Tested? 15 | Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. 16 | 17 | ## Checklist: 18 | - [ ] My code follows the style guidelines of this project 19 | - [ ] I have performed a self-review of my own code 20 | - [ ] I have commented my code, particularly in hard-to-understand areas 21 | - [ ] I have made corresponding changes to the documentation 22 | - [ ] My changes generate no new warnings 23 | - [ ] I have added tests that prove my fix is effective or that my feature works 24 | - [ ] New and existing unit tests pass locally with my changes 25 | - [ ] Any dependent changes have been merged and published in downstream modules -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "exclude": ["node_modules", "dist", "build", "plugins", "example-ignore"], 3 | "include": ["src/**/*"], 4 | "compilerOptions": { 5 | "target": "es2020", 6 | "module": "commonjs", 7 | "lib": ["es2020", "dom"], 8 | "declaration": true, 9 | "outDir": "./build", 10 | "rootDir": "./src", 11 | "strict": false, 12 | "esModuleInterop": true, 13 | "skipLibCheck": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "experimentalDecorators": true, 16 | "emitDecoratorMetadata": true, 17 | "baseUrl": "src", 18 | "paths": { 19 | "tabby-core": ["../node_modules/tabby-core"], 20 | "tabby-settings": ["../node_modules/tabby-settings"], 21 | "tabby-terminal": ["../node_modules/tabby-terminal"], 22 | "@angular/core": ["../node_modules/@angular/core"], 23 | "@angular/common": ["../node_modules/@angular/common"], 24 | "@angular/forms": ["../node_modules/@angular/forms"], 25 | "@ng-bootstrap/ng-bootstrap": ["../node_modules/@ng-bootstrap/ng-bootstrap"], 26 | "rxjs": ["../node_modules/rxjs"], 27 | "rxjs/operators": ["../node_modules/rxjs/operators"] 28 | }, 29 | "sourceMap": true, 30 | "typeRoots": ["node_modules/@types"], 31 | "resolveJsonModule": true, 32 | "allowSyntheticDefaultImports": true 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/tools/base-tool-category.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { McpTool, ToolCategory } from '../type/types'; 3 | import { McpLoggerService } from '../services/mcpLogger.service'; 4 | 5 | /** 6 | * Base class for all tool categories 7 | * Provides common functionality for tool management 8 | */ 9 | @Injectable() 10 | export abstract class BaseToolCategory implements ToolCategory { 11 | protected logger: McpLoggerService; 12 | 13 | constructor(logger: McpLoggerService) { 14 | this.logger = logger; 15 | } 16 | /** 17 | * The name of the tool category 18 | */ 19 | abstract name: string; 20 | 21 | /** 22 | * List of MCP tools in this category 23 | */ 24 | protected _mcpTools: McpTool[] = []; 25 | 26 | /** 27 | * Get all MCP tools in this category 28 | */ 29 | get mcpTools(): McpTool[] { 30 | return this._mcpTools; 31 | } 32 | 33 | /** 34 | * Register a tool in this category 35 | */ 36 | protected registerTool(tool: McpTool): void { 37 | if (!tool.description) { 38 | this.logger.warn(`Tool ${tool.name} is missing a description`); 39 | // Set a default description to avoid errors 40 | tool.description = `Tool: ${tool.name}`; 41 | } 42 | 43 | this._mcpTools.push(tool); 44 | this.logger.info(`Registered tool: ${tool.name} in category ${this.name}`); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/components/extensionRecommendationDialog.component.scss: -------------------------------------------------------------------------------- 1 | // Import base variables for consistency 2 | @use './mcpSettingsTab.component.scss'; 3 | 4 | :host { 5 | --bg-primary: #1a1d21; 6 | --bg-secondary: #2c3038; 7 | --text-primary: #e6e6e6; 8 | --text-accent: #61afef; 9 | --border-primary: #3a3f4b; 10 | } 11 | 12 | .modal-header { 13 | background-color: var(--bg-secondary); 14 | color: var(--text-primary); 15 | border-bottom: 1px solid var(--border-primary); 16 | 17 | .modal-title { 18 | color: var(--text-accent); 19 | font-weight: 600; 20 | } 21 | 22 | .btn-close { 23 | filter: invert(1) grayscale(100%) brightness(200%); 24 | } 25 | } 26 | 27 | .modal-body { 28 | background-color: var(--bg-primary); 29 | color: var(--text-primary); 30 | text-align: center; 31 | padding: 2rem; 32 | } 33 | 34 | .modal-footer { 35 | background-color: var(--bg-secondary); 36 | border-top: 1px solid var(--border-primary); 37 | justify-content: center; 38 | } 39 | 40 | .btn-primary { 41 | background-color: var(--text-accent); 42 | color: var(--bg-primary); 43 | font-weight: 600; 44 | 45 | &:hover { 46 | filter: brightness(1.1); 47 | } 48 | } 49 | 50 | .btn-secondary { 51 | background-color: var(--bg-tertiary); 52 | color: var(--text-primary); 53 | border: 1px solid var(--border-secondary); 54 | 55 | &:hover { 56 | background-color: var(--border-secondary); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /.github/workflows/test-pr.yml: -------------------------------------------------------------------------------- 1 | name: Test Pull Request 2 | 3 | on: 4 | pull_request: 5 | branches: [ main ] 6 | 7 | jobs: 8 | build-and-test: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout repository 12 | uses: actions/checkout@v4 13 | 14 | - name: Set up Docker Buildx 15 | uses: docker/setup-buildx-action@v3 16 | 17 | - name: Build with Docker 18 | run: | 19 | mkdir -p build 20 | docker build -t tabby-mcp-test . 21 | docker run -v $(pwd)/build:/output tabby-mcp-test 22 | 23 | # Testing the build output 24 | - name: Setup Node.js for testing 25 | uses: actions/setup-node@v4 26 | with: 27 | node-version: '22' 28 | 29 | - name: Check build artifacts 30 | run: | 31 | ls -la build 32 | if [ -d "build/dist" ]; then 33 | echo "✅ Build successful - dist folder exists" 34 | else 35 | echo "❌ Build failed - dist folder missing" 36 | exit 1 37 | fi 38 | 39 | # Additional lint checks on source files 40 | - name: Lint check 41 | run: | 42 | if [ -f ".eslintrc" ] || [ -f ".eslintrc.js" ] || [ -f ".eslintrc.json" ]; then 43 | npm install eslint --no-save 44 | npx eslint src/ --ext .ts,.js,.tsx,.jsx 45 | else 46 | echo "No ESLint configuration found, skipping lint check" 47 | fi -------------------------------------------------------------------------------- /src/components/execCommandButton.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnDestroy, OnInit } from '@angular/core'; 2 | import { Subscription } from 'rxjs'; 3 | import { ExecToolCategory } from '../tools/terminal'; 4 | 5 | @Component({ 6 | selector: 'exec-command-button', 7 | template: ` 8 | 17 | `, 18 | styles: [` 19 | .btn { 20 | margin-right: 5px; 21 | white-space: nowrap; 22 | } 23 | `] 24 | }) 25 | export class ExecCommandButtonComponent implements OnInit, OnDestroy { 26 | isCommandRunning = false; 27 | commandName = ''; 28 | private subscription: Subscription; 29 | 30 | constructor( 31 | private execToolCategory: ExecToolCategory 32 | ) { } 33 | 34 | ngOnInit(): void { 35 | this.subscription = this.execToolCategory.activeCommand$.subscribe(command => { 36 | this.isCommandRunning = !!command; 37 | this.commandName = command?.command || ''; 38 | 39 | // Shorten command name if too long 40 | if (this.commandName.length > 15) { 41 | this.commandName = this.commandName.substring(0, 12) + '...'; 42 | } 43 | }); 44 | } 45 | 46 | ngOnDestroy(): void { 47 | if (this.subscription) { 48 | this.subscription.unsubscribe(); 49 | } 50 | } 51 | 52 | onAbortClick(): void { 53 | if (this.isCommandRunning) { 54 | this.execToolCategory.abortCurrentCommand(); 55 | } 56 | } 57 | } -------------------------------------------------------------------------------- /assets/gifs/README.md: -------------------------------------------------------------------------------- 1 | # Animated GIF Demonstrations 2 | 3 | ## Suggested GIF Demonstrations 4 | 5 | Consider creating the following animated GIFs to showcase features: 6 | 7 | 1. **Terminal Command Execution** 8 | - Show an AI executing a command and the command running in the terminal 9 | - Filename: `ai-command-execution.gif` 10 | 11 | 2. **MCP Server Connection** 12 | - Demonstrate the connection process between AI and terminal 13 | - Filename: `mcp-connection.gif` 14 | 15 | 3. **Terminal Buffer Access** 16 | - Show retrieving and displaying terminal output 17 | - Filename: `terminal-buffer-access.gif` 18 | 19 | 4. **SSH Session Management** 20 | - Demonstrate listing and managing SSH sessions 21 | - Filename: `ssh-session-management.gif` 22 | 23 | 5. **Command Aborting** 24 | - Show safely aborting a running command 25 | - Filename: `abort-command.gif` 26 | 27 | ## GIF Creation Guidelines 28 | 29 | 1. **Keep it focused**: Each GIF should demonstrate one feature clearly 30 | 2. **Keep it short**: 5-15 seconds is ideal 31 | 3. **Resolution**: 1280x720 is recommended (or smaller if needed) 32 | 4. **File size**: Keep under 2MB for web performance 33 | 5. **Framerate**: 15-24 fps is usually sufficient 34 | 6. **Add annotations**: Consider adding text overlays to explain what's happening 35 | 7. **Use screen recording software** like: 36 | - LICEcap (Windows/macOS) 37 | - Gifox (macOS) 38 | - ScreenToGif (Windows) 39 | - Peek (Linux) 40 | 41 | ## Adding to README 42 | 43 | Reference these GIFs in the README.md like this: 44 | 45 | ```markdown 46 | ![AI Command Execution Demo](assets/gifs/ai-command-execution.gif) 47 | ``` 48 | 49 | The main README.md already references a demo.gif file which should be placed in this directory. -------------------------------------------------------------------------------- /src/components/confirmCommandDialog.component.pug: -------------------------------------------------------------------------------- 1 | .modal-header 2 | h5 Confirm Command Execution 3 | .header-actions 4 | button.btn.btn-sm.btn-outline-secondary.me-2((click)='minimize()', title='Minimize dialog') 5 | i.fas.fa-window-minimize 6 | button.btn-close((click)='cancel()') 7 | 8 | .modal-body 9 | .alert.alert-info 10 | i.fas.fa-info-circle.mr-2 11 | span This command will be executed in the terminal. 12 | 13 | .form-group 14 | label Terminal 15 | .form-control-plaintext {{tabTitle || 'Current terminal'}} 16 | 17 | .form-group 18 | label Command 19 | pre.form-control.command-preview {{command}} 20 | 21 | .form-group(*ngIf='commandExplanation') 22 | label Command Explanation 23 | .alert.alert-secondary 24 | i.fas.fa-info-circle.mr-2 25 | pre.command-explanation {{commandExplanation}} 26 | 27 | .form-group(*ngIf='showRejectInput') 28 | label Reason for Rejection 29 | textarea.form-control( 30 | #rejectMessageTextarea 31 | [(ngModel)]='rejectMessage', 32 | rows='3', 33 | placeholder='Enter your reason for rejecting this command...', 34 | (keydown)='onTextareaKeyDown($event)' 35 | ) 36 | 37 | .modal-footer 38 | button.btn.btn-secondary((click)='minimize()') 39 | span Minimize 40 | small.text-muted (Click outside to close) 41 | 42 | button.btn.btn-danger.mr-2(*ngIf='!showRejectInput', (click)='showRejectForm()') 43 | span Reject 44 | small.text-muted (R) 45 | 46 | button.btn.btn-danger.mr-2(*ngIf='showRejectInput', (click)='reject()') 47 | span Confirm Rejection 48 | small.text-muted (Enter) 49 | 50 | button.btn.btn-primary(*ngIf='!showRejectInput', (click)='confirm()') 51 | span Execute 52 | small.text-muted (Enter) 53 | -------------------------------------------------------------------------------- /src/components/commandDialogs.scss: -------------------------------------------------------------------------------- 1 | .command-preview { 2 | font-family: monospace; 3 | background-color: #f5f5f5; 4 | padding: 10px; 5 | border-radius: 4px; 6 | white-space: pre-wrap; 7 | word-break: break-all; 8 | max-height: 150px; 9 | overflow-y: auto; 10 | } 11 | 12 | .output-preview { 13 | font-family: monospace; 14 | background-color: #f5f5f5; 15 | padding: 10px; 16 | border-radius: 4px; 17 | white-space: pre-wrap; 18 | word-break: break-all; 19 | max-height: 300px; 20 | overflow-y: auto; 21 | resize: vertical; 22 | 23 | // Styling for readonly textarea to look more like a text display 24 | &[readonly] { 25 | cursor: text; 26 | 27 | // Remove focus outline for readonly textarea 28 | &:focus { 29 | outline: none; 30 | box-shadow: none; 31 | border-color: inherit; 32 | } 33 | } 34 | } 35 | 36 | .instruction-preview { 37 | font-family: monospace; 38 | background-color: #f5f5f5; 39 | padding: 10px; 40 | border-radius: 4px; 41 | white-space: pre-wrap; 42 | word-break: break-all; 43 | max-height: 150px; 44 | overflow-y: auto; 45 | } 46 | 47 | // Dialog focus styles 48 | .modal-content { 49 | // Default state with subtle border 50 | border: 2px solid rgba(0, 0, 0, 0.2) !important; 51 | transition: border-color 0.2s ease-in-out, box-shadow 0.2s ease-in-out; 52 | outline: none; 53 | 54 | // Focused state with glowing border 55 | &.focused { 56 | border-color: #4fadff !important; 57 | box-shadow: 0 0 8px rgba(79, 173, 255, 0.5) !important; 58 | } 59 | } 60 | 61 | // Make modal backdrop darker and prevent clicks 62 | .modal-backdrop { 63 | opacity: 0.7 !important; 64 | pointer-events: all !important; 65 | } 66 | 67 | // Prevent interaction with elements behind the modal 68 | .modal { 69 | pointer-events: all !important; 70 | } 71 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [1.0.8] - 21-07-2025 4 | 5 | ### Changed 6 | - Refactored URL opening to use a silent PowerShell command via the terminal service, fixing runtime errors. 7 | - Both the GitHub and VSCode Extension links in settings now open silently. 8 | 9 | ### Added 10 | - "VSCode Extension" button in the settings tab to open the marketplace page. 11 | 12 | ## [1.0.7] - 21-07-2025 13 | 14 | # Added 15 | - VSCode Extension Support, listens to http port. 16 | - Fixed URL Opening links 17 | - Increased speed of reading SSH sessions 18 | - Fixed issue with opening VSCode via non extension 19 | - Fixed UI for Command History 20 | 21 | ### Added 22 | - Pair Programming Mode with confirmation dialog 23 | - Command History tracking and management 24 | - JSON syntax errors in README examples 25 | - Terminal buffer retrieval for long outputs 26 | 27 | ### Added 28 | - Initial release of Tabby-MCP 29 | - Enhanced support for VS Code integration 30 | - Copilot agent chat can now be opened directly from the navbar 31 | - New and improved UI elements for a more modern look 32 | - Updated and fixed navbar for better navigation and stability 33 | - Improved command execution speeds and responsiveness 34 | - Quick access to settings and chat from the main interface 35 | - More robust error handling and feedback for users 36 | - Optimized MCP server communication for lower latency 37 | - Improved hotkey support and customization 38 | - Better handling of long-running commands and output 39 | - MCP server implementation for Tabby terminal 40 | - Terminal control capabilities for AI assistants 41 | - Navbar rendering issues in some VS Code versions 42 | - UI glitches in command dialogs and modals 43 | - Minor bugs in command history and output storage 44 | - Various performance and stability improvements 45 | - SSH session management tools 46 | - Terminal buffer access functionality 47 | - Command execution and abortion tools 48 | -------------------------------------------------------------------------------- /scripts/prepare-publish.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | 4 | // Paths 5 | const projectDir = process.cwd(); 6 | const distDir = path.join(projectDir, 'build'); 7 | const srcDir = path.join(projectDir, 'src'); 8 | const pluginsDir = path.join(projectDir, 'plugins'); 9 | const pluginDistDir = path.join(pluginsDir, 'dist'); 10 | 11 | // Helper function to copy files/directories 12 | function copyRecursive(src, dest) { 13 | const stat = fs.statSync(src); 14 | if (stat.isDirectory()) { 15 | if (!fs.existsSync(dest)) { 16 | fs.mkdirSync(dest, { recursive: true }); 17 | } 18 | const items = fs.readdirSync(src); 19 | items.forEach(item => { 20 | copyRecursive(path.join(src, item), path.join(dest, item)); 21 | }); 22 | } else { 23 | fs.copyFileSync(src, dest); 24 | } 25 | } 26 | 27 | function prepare() { 28 | console.log('🚀 Starting publish preparation...'); 29 | 30 | // Clean previous plugins directory 31 | if (fs.existsSync(pluginsDir)) { 32 | console.log('🧹 Cleaning existing plugins directory...'); 33 | fs.rmSync(pluginsDir, { recursive: true, force: true }); 34 | } 35 | 36 | // Create plugins directory structure 37 | console.log('📁 Creating plugins/dist directory...'); 38 | fs.mkdirSync(pluginDistDir, { recursive: true }); 39 | 40 | // Check if dist directory exists (it should be created by `npm run build`) 41 | if (!fs.existsSync(distDir)) { 42 | console.error('❌ build directory not found. Make sure `npm run build` runs before this script.'); 43 | process.exit(1); 44 | } 45 | 46 | // Copy dist folder to plugins/dist 47 | console.log('📦 Copying build folder to plugins/dist...'); 48 | copyRecursive(distDir, pluginDistDir); 49 | 50 | // Copy src folder to plugins/dist 51 | console.log('📂 Copying src folder to plugins/dist...'); 52 | copyRecursive(srcDir, pluginDistDir); 53 | 54 | console.log('✅ Publish preparation completed successfully!'); 55 | } 56 | 57 | prepare(); -------------------------------------------------------------------------------- /webpack.config.mjs: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { fileURLToPath } from 'url'; 3 | 4 | const __filename = fileURLToPath(import.meta.url); 5 | const __dirname = path.dirname(__filename); 6 | 7 | export default { 8 | mode: 'development', 9 | entry: './src/index.ts', 10 | output: { 11 | filename: 'index.js', 12 | path: path.resolve(__dirname, 'build'), 13 | library: { 14 | type: 'commonjs2' 15 | } 16 | }, 17 | resolve: { 18 | extensions: ['.ts', '.js'] 19 | }, 20 | module: { 21 | rules: [ 22 | { 23 | test: /\.ts$/, 24 | use: [ 25 | { 26 | loader: 'ts-loader', 27 | options: { 28 | transpileOnly: true, 29 | compilerOptions: { 30 | noEmit: false, 31 | skipLibCheck: true, 32 | allowSyntheticDefaultImports: true, 33 | }, 34 | }, 35 | }, 36 | 'angular2-template-loader', 37 | ], 38 | exclude: /node_modules/, 39 | }, 40 | { 41 | test: /\.pug$/, 42 | use: ['raw-loader', 'pug-plain-loader'], 43 | }, 44 | { 45 | test: /\.html$/, 46 | use: 'raw-loader', 47 | }, 48 | { 49 | test: /\.scss$/, 50 | use: [ 51 | 'to-string-loader', 52 | 'css-loader', 53 | 'sass-loader', 54 | ], 55 | }, 56 | { 57 | test: /\.css$/, 58 | use: ['raw-loader'], 59 | }, 60 | ], 61 | }, 62 | externals: { 63 | '@angular/core': 'commonjs2 @angular/core', 64 | '@angular/common': 'commonjs2 @angular/common', 65 | '@angular/forms': 'commonjs2 @angular/forms', 66 | '@angular/animations': 'commonjs2 @angular/animations', 67 | '@ng-bootstrap/ng-bootstrap': 'commonjs2 @ng-bootstrap/ng-bootstrap', 68 | 'tabby-core': 'commonjs2 tabby-core', 69 | 'tabby-settings': 'commonjs2 tabby-settings', 70 | 'tabby-terminal': 'commonjs2 tabby-terminal', 71 | rxjs: 'commonjs2 rxjs', 72 | 'rxjs/operators': 'commonjs2 rxjs/operators', 73 | vscode: 'commonjs2 vscode', 74 | }, 75 | devtool: 'source-map', 76 | target: 'node' 77 | }; 78 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tabby-vscode-agent", 3 | "version": "1.0.8", 4 | "description": "Seamlessly integrate AI-powered terminal control and automation within VS Code. Ask AI for your Terminal Prompts", 5 | "homepage": "https://github.com/SteffMet/tabby-vscode-agent", 6 | "keywords": [ 7 | "tabby-plugin", 8 | "tabby-vscode", 9 | "copilot", 10 | "mcp" 11 | ], 12 | "main": "plugins/dist/index.js", 13 | "typings": "typings/index.d.ts", 14 | "scripts": { 15 | "build": "webpack --progress --color", 16 | "watch": "webpack --progress --color --watch", 17 | "clean": "rimraf build dist plugins", 18 | "deploy": "npm run build && node scripts/deploy.js", 19 | "prepublishOnly": "npm run build && node scripts/prepare-publish.js" 20 | }, 21 | "files": [ 22 | "plugins", 23 | "typings" 24 | ], 25 | "author": "Steff", 26 | "license": "MIT", 27 | "dependencies": { 28 | "@modelcontextprotocol/sdk": "^1.8.0", 29 | "@xterm/addon-serialize": "^0.12.0", 30 | "cors": "^2.8.5", 31 | "express": "^4.18.2", 32 | "tabby-mcp-stdio": "^1.0.4", 33 | "zod": "^3.22.4" 34 | }, 35 | "peerDependencies": { 36 | "@angular/animations": "*", 37 | "@angular/common": "*", 38 | "@angular/core": "*", 39 | "@angular/forms": "*", 40 | "@ng-bootstrap/ng-bootstrap": "*", 41 | "rxjs": "*", 42 | "tabby-core": "*", 43 | "tabby-settings": "*", 44 | "tabby-terminal": "*" 45 | }, 46 | "devDependencies": { 47 | "@angular/animations": "~15.2.10", 48 | "@angular/common": "~15.2.10", 49 | "@angular/core": "~15.2.10", 50 | "@angular/forms": "~15.2.10", 51 | "@ng-bootstrap/ng-bootstrap": "~14.2.0", 52 | "angular2-template-loader": "^0.6.2", 53 | "css-loader": "^7.1.2", 54 | "pug": "^3.0.3", 55 | "pug-plain-loader": "^1.1.0", 56 | "raw-loader": "^4.0.2", 57 | "rimraf": "^6.0.1", 58 | "rxjs": "*", 59 | "sass": "^1.89.2", 60 | "sass-loader": "^16.0.5", 61 | "strip-ansi": "^7.1.0", 62 | "tabby-core": "*", 63 | "tabby-settings": "*", 64 | "tabby-terminal": "*", 65 | "to-string-loader": "^1.2.0", 66 | "ts-loader": "^9.5.2", 67 | "typescript": "~4.9.5", 68 | "webpack": "^5.100.1", 69 | "webpack-cli": "^6.0.1" 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/services/mcpLogger.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { ConfigService } from 'tabby-core'; 3 | 4 | /** 5 | * Logger service for MCP module 6 | * Provides logging capabilities with debug mode toggle 7 | */ 8 | @Injectable({ providedIn: 'root' }) 9 | export class McpLoggerService { 10 | private debugEnabled = false; 11 | 12 | constructor(private config: ConfigService) { 13 | // Initialize debug enabled from config in a safe way 14 | try { 15 | this.debugEnabled = !!(this.config.store && this.config.store.mcp && this.config.store.mcp.enableDebugLogging); 16 | } catch (err) { 17 | // Default to false if config is not available yet 18 | this.debugEnabled = false; 19 | console.log('[MCP Logger] Config not fully initialized, defaulting debug to false'); 20 | } 21 | } 22 | 23 | /** 24 | * Set debug mode enabled/disabled 25 | */ 26 | setDebugEnabled(enabled: boolean): void { 27 | this.debugEnabled = enabled; 28 | } 29 | 30 | /** 31 | * Log an informational message 32 | */ 33 | info(message: string): void { 34 | console.log(`[MCP] ${message}`); 35 | } 36 | 37 | /** 38 | * Log a debug message (only shown when debug logging is enabled) 39 | */ 40 | debug(message: string, data?: any): void { 41 | if (!this.debugEnabled) { 42 | return; 43 | } 44 | 45 | if (data) { 46 | console.log(`[MCP DEBUG] ${message}`, data); 47 | } else { 48 | console.log(`[MCP DEBUG] ${message}`); 49 | } 50 | } 51 | 52 | /** 53 | * Log an error message 54 | */ 55 | error(message: string, error?: any): void { 56 | if (error) { 57 | console.error(`[MCP ERROR] ${message}`, error); 58 | } else { 59 | console.error(`[MCP ERROR] ${message}`); 60 | } 61 | } 62 | 63 | /** 64 | * Log a warning message 65 | */ 66 | warn(message: string, data?: any): void { 67 | if (data) { 68 | console.warn(`[MCP WARNING] ${message}`, data); 69 | } else { 70 | console.warn(`[MCP WARNING] ${message}`); 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/services/runningCommandsManager.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { BehaviorSubject, Observable } from 'rxjs'; 3 | 4 | export interface RunningCommand { 5 | tabId: string; 6 | command: string; 7 | startTime: number; 8 | } 9 | 10 | @Injectable({ providedIn: 'root' }) 11 | export class RunningCommandsManagerService { 12 | private runningCommandsSubject = new BehaviorSubject([]); 13 | public runningCommands$ = this.runningCommandsSubject.asObservable(); 14 | 15 | private runningCommands = new Map(); 16 | 17 | constructor() {} 18 | 19 | /** 20 | * Get current running commands as observable 21 | */ 22 | getRunningCommands(): Observable { 23 | return this.runningCommands$; 24 | } 25 | 26 | /** 27 | * Get current running commands count 28 | */ 29 | getRunningCommandsCount(): number { 30 | return this.runningCommands.size; 31 | } 32 | 33 | /** 34 | * Start tracking a command 35 | */ 36 | startCommand(tabId: string, command: string): void { 37 | const runningCommand: RunningCommand = { 38 | tabId, 39 | command, 40 | startTime: Date.now() 41 | }; 42 | 43 | this.runningCommands.set(tabId, runningCommand); 44 | this.updateSubject(); 45 | } 46 | 47 | /** 48 | * Stop tracking a command 49 | */ 50 | endCommand(tabId: string): void { 51 | this.runningCommands.delete(tabId); 52 | this.updateSubject(); 53 | } 54 | 55 | /** 56 | * Get all running commands as array 57 | */ 58 | getAllRunningCommands(): RunningCommand[] { 59 | return Array.from(this.runningCommands.values()); 60 | } 61 | 62 | /** 63 | * Check if a command is running in a specific tab 64 | */ 65 | isCommandRunning(tabId: string): boolean { 66 | return this.runningCommands.has(tabId); 67 | } 68 | 69 | /** 70 | * Get running command for a specific tab 71 | */ 72 | getRunningCommand(tabId: string): RunningCommand | undefined { 73 | return this.runningCommands.get(tabId); 74 | } 75 | 76 | /** 77 | * Clear all running commands (useful for cleanup) 78 | */ 79 | clearAll(): void { 80 | this.runningCommands.clear(); 81 | this.updateSubject(); 82 | } 83 | 84 | private updateSubject(): void { 85 | this.runningCommandsSubject.next(this.getAllRunningCommands()); 86 | } 87 | } -------------------------------------------------------------------------------- /src/services/dialog.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { CommandResultDialogComponent } from '../components/commandResultDialog.component'; 3 | import { ConfirmCommandDialogComponent } from '../components/confirmCommandDialog.component'; 4 | import { DialogManagerService } from './dialogManager.service'; 5 | 6 | /** 7 | * Service to manage dialogs in the application 8 | * Uses DialogManagerService to ensure only one dialog is displayed at a time 9 | */ 10 | @Injectable({ providedIn: 'root' }) 11 | export class DialogService { 12 | constructor(private dialogManager: DialogManagerService) {} 13 | 14 | /** 15 | * Show command confirmation dialog 16 | * @param command Command to execute 17 | * @param tabId Tab ID 18 | * @param tabTitle Tab title 19 | * @param commandExplanation Optional explanation of what the command does 20 | * @returns Promise with dialog result 21 | */ 22 | async showConfirmCommandDialog( 23 | command: string, 24 | tabId: number, 25 | tabTitle: string, 26 | commandExplanation?: string 27 | ): Promise { 28 | return this.dialogManager.openDialog( 29 | ConfirmCommandDialogComponent, 30 | { backdrop: 'static' }, 31 | { 32 | command, 33 | tabId, 34 | tabTitle, 35 | commandExplanation 36 | } 37 | ); 38 | } 39 | 40 | /** 41 | * Show command result dialog 42 | * @param command Command executed 43 | * @param output Command output 44 | * @param exitCode Exit code 45 | * @param aborted Whether the command was aborted 46 | * @param originalInstruction Original instruction 47 | * @returns Promise with dialog result 48 | */ 49 | async showCommandResultDialog( 50 | command: string, 51 | output: string, 52 | exitCode: number | null, 53 | aborted: boolean, 54 | originalInstruction: string = '' 55 | ): Promise { 56 | return this.dialogManager.openDialog( 57 | CommandResultDialogComponent, 58 | { 59 | backdrop: 'static', 60 | size: 'lg' 61 | }, 62 | { 63 | command, 64 | output, 65 | exitCode, 66 | aborted, 67 | originalInstruction 68 | } 69 | ); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/tools/terminal/ssh-session-list-tool.ts: -------------------------------------------------------------------------------- 1 | import * as z from 'zod'; 2 | import { createJsonResponse } from '../../type/types'; 3 | import { BaseTool } from './base-tool'; 4 | import { ExecToolCategory } from '../terminal'; 5 | import { McpLoggerService } from '../../services/mcpLogger.service'; 6 | 7 | /** 8 | * Tool for getting a list of all terminal sessions (SSH and local) 9 | * 10 | * This tool returns information about all available terminal sessions 11 | * that can be used with other terminal tools. 12 | */ 13 | export class SshSessionListTool extends BaseTool { 14 | constructor(private execToolCategory: ExecToolCategory, logger: McpLoggerService) { 15 | super(logger); 16 | } 17 | 18 | getTool() { 19 | return { 20 | name: 'get_ssh_session_list', 21 | description: `Returns a list of all available terminal sessions (SSH and local) with their IDs and status information. 22 | 23 | USE CASES: 24 | - Find available terminal sessions before executing commands 25 | - Determine which terminal is currently focused 26 | - Check terminal activity status 27 | 28 | RETURNS: 29 | An array of terminal session objects with the following structure: 30 | [ 31 | { 32 | "id": "0", // Unique ID to use with other terminal tools 33 | "title": "bash", // Terminal title 34 | "customTitle": "My Terminal", // User-defined title if set 35 | "hasActivity": false, // Whether there is activity in the terminal 36 | "hasFocus": true // Whether this terminal is currently focused 37 | }, 38 | ... 39 | ] 40 | 41 | RELATED TOOLS: 42 | - exec_command: Execute commands in a terminal session 43 | - get_terminal_buffer: Get the current content of a terminal 44 | 45 | EXAMPLE USAGE: 46 | 1. Get available sessions: get_ssh_session_list() 47 | 2. Use the returned ID with exec_command: exec_command({ command: "ls", tabId: "0" }) 48 | 49 | NOTES: 50 | - If no terminal sessions are available, an empty array will be returned 51 | - Always check for available sessions before executing commands`, 52 | schema: {}, 53 | handler: async (_, extra) => { 54 | const serializedSessions = this.execToolCategory.findAndSerializeTerminalSessions().map(session => ({ 55 | id: session.id, 56 | title: session.tab.title, 57 | customTitle: session.tab.customTitle, 58 | hasActivity: session.tab.hasActivity, 59 | hasFocus: session.tab.hasFocus, 60 | })); 61 | 62 | return createJsonResponse(serializedSessions); 63 | } 64 | }; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/tools/terminal/open-copilot-tool.ts: -------------------------------------------------------------------------------- 1 | import { exec } from 'child_process'; 2 | import * as net from 'net'; 3 | import { createSuccessResponse } from '../../type/types'; 4 | import { BaseTool } from './base-tool'; 5 | import { McpLoggerService } from '../../services/mcpLogger.service'; 6 | import { AppService } from 'tabby-core'; 7 | 8 | /** 9 | * Tool for opening the VSCode Copilot chat window. 10 | */ 11 | export class OpenCopilotTool extends BaseTool { 12 | constructor( 13 | private app: AppService, 14 | logger: McpLoggerService, 15 | ) { 16 | super(logger); 17 | } 18 | 19 | private openCopilotWithTcp(): Promise { 20 | return new Promise((resolve) => { 21 | const client = new net.Socket(); 22 | client.connect(6789, '127.0.0.1', () => { 23 | this.logger.info('Connected to tabby-copilot-opener. Opening Copilot window.'); 24 | client.end(); 25 | resolve(true); 26 | }); 27 | 28 | client.on('error', (err) => { 29 | this.logger.info('Could not connect to tabby-copilot-opener. Falling back to shell command.'); 30 | resolve(false); 31 | }); 32 | }); 33 | } 34 | 35 | private openCopilotWithShell(): void { 36 | this.logger.info('Opening Copilot window via shell command'); 37 | exec('code --command workbench.action.chat.openInNewWindow', (error, stdout, stderr) => { 38 | if (error) { 39 | this.logger.error('Error running VS Code Copilot command:', error); 40 | } else { 41 | this.logger.info(`VS Code Copilot command executed: ${stdout}`); 42 | } 43 | }); 44 | this.app.emit('mcp-show-notification', { 45 | message: 'Recommend using tabby-copilot-opener extension in vscode for a seamless integration.', 46 | }); 47 | } 48 | 49 | getTool() { 50 | return { 51 | name: 'open_copilot', 52 | description: 'Opens the VSCode Copilot chat window.', 53 | schema: {}, 54 | handler: async () => { 55 | try { 56 | this.logger.info('Attempting to open Copilot window'); 57 | const openedWithTcp = await this.openCopilotWithTcp(); 58 | if (!openedWithTcp) { 59 | this.openCopilotWithShell(); 60 | return createSuccessResponse('Command sent to open Copilot window using shell command.'); 61 | } 62 | return createSuccessResponse('Command sent to open Copilot window via TCP.'); 63 | } catch (error) { 64 | this.logger.error('Failed to open Copilot window:', error); 65 | throw error; 66 | } 67 | }, 68 | }; 69 | } 70 | } -------------------------------------------------------------------------------- /src/components/commandResultDialog.component.pug: -------------------------------------------------------------------------------- 1 | .modal-header 2 | h5 Command Execution Result 3 | .header-actions 4 | button.btn.btn-sm.btn-outline-secondary.me-2((click)='minimize()', title='Minimize dialog') 5 | i.fas.fa-window-minimize 6 | button.btn-close((click)='cancel()') 7 | 8 | .modal-body 9 | .form-group 10 | label Command 11 | pre.form-control.command-preview {{command}} 12 | 13 | .form-group 14 | label Result 15 | .alert.alert-success(*ngIf='exitCode === 0') 16 | i.fas.fa-check-circle.mr-2 17 | span Command completed successfully 18 | .alert.alert-danger(*ngIf='exitCode !== 0 && exitCode !== null') 19 | i.fas.fa-exclamation-circle.mr-2 20 | span Command failed with exit code {{exitCode}} 21 | .alert.alert-warning(*ngIf='aborted') 22 | i.fas.fa-exclamation-triangle.mr-2 23 | span Command execution was aborted 24 | 25 | .form-group 26 | label Output 27 | textarea.form-control.output-preview( 28 | #outputTextarea 29 | readonly, 30 | rows='10', 31 | [ngModel]='output' 32 | ) 33 | 34 | .form-group(*ngIf='originalInstruction') 35 | label Original AI Instruction 36 | pre.form-control.instruction-preview {{originalInstruction}} 37 | 38 | .form-group 39 | label(style='display: {{isRejectMode ? "none" : "block"}}') Your Message/Instructions 40 | label.text-danger(style='display: {{!isRejectMode ? "none" : "block"}}') Reason for Rejection 41 | textarea.form-control( 42 | #messageTextarea 43 | [(ngModel)]='userMessage', 44 | rows='4', 45 | placeholder='{{isRejectMode ? "Enter your reason for rejecting this command..." : "Enter your message or instructions here..."}}', 46 | class='{{isRejectMode ? "border-danger" : ""}}', 47 | (keydown)='onTextareaKeyDown($event)' 48 | ) 49 | 50 | .modal-footer 51 | button.btn.btn-secondary((click)='minimize()') 52 | span Minimize 53 | small.text-muted (Click outside to close) 54 | 55 | button.btn.btn-secondary((click)='cancel()') 56 | span Cancel 57 | small.text-muted (Esc) 58 | 59 | button.btn.btn-outline-secondary.mr-2((click)='toggleRejectMode()', style='display: {{isRejectMode ? "inline-block" : "none"}}') 60 | span Cancel Rejection 61 | 62 | button.btn.btn-danger.mr-2((click)='reject()') 63 | span(style='display: {{!isRejectMode ? "inline" : "none"}}') Reject 64 | span(style='display: {{isRejectMode ? "inline" : "none"}}') Confirm Rejection 65 | small.text-muted (R) 66 | 67 | button.btn.btn-primary((click)='accept()') 68 | span Accept 69 | small.text-muted (Enter) 70 | -------------------------------------------------------------------------------- /src/styles.scss: -------------------------------------------------------------------------------- 1 | // Command dialog styles 2 | .command-preview { 3 | font-family: monospace; 4 | background-color: #f5f5f5; 5 | padding: 10px; 6 | border-radius: 4px; 7 | white-space: pre-wrap; 8 | word-break: break-all; 9 | max-height: 150px; 10 | overflow-y: auto; 11 | } 12 | 13 | .command-explanation { 14 | font-family: monospace; 15 | margin: 0; 16 | padding: 0; 17 | white-space: pre-wrap; 18 | word-break: break-word; 19 | background-color: transparent; 20 | border: none; 21 | color: inherit; 22 | font-size: 0.9rem; 23 | line-height: 1.5; 24 | max-height: 200px; 25 | overflow-y: auto; 26 | } 27 | 28 | .output-preview { 29 | font-family: monospace; 30 | background-color: #f5f5f5; 31 | padding: 10px; 32 | border-radius: 4px; 33 | white-space: pre-wrap; 34 | word-break: break-all; 35 | max-height: 300px; 36 | overflow-y: auto; 37 | resize: vertical; 38 | 39 | // Styling for readonly textarea to look more like a text display 40 | &[readonly] { 41 | cursor: text; 42 | 43 | // Remove focus outline for readonly textarea 44 | &:focus { 45 | outline: none; 46 | box-shadow: none; 47 | border-color: inherit; 48 | } 49 | } 50 | } 51 | 52 | .instruction-preview { 53 | font-family: monospace; 54 | background-color: #f5f5f5; 55 | padding: 10px; 56 | border-radius: 4px; 57 | white-space: pre-wrap; 58 | word-break: break-all; 59 | max-height: 150px; 60 | overflow-y: auto; 61 | } 62 | 63 | // Dialog focus styles 64 | .modal-content { 65 | // Default state with subtle border 66 | border: 2px solid rgba(0, 0, 0, 0.2) !important; 67 | transition: border-color 0.2s ease-in-out, box-shadow 0.2s ease-in-out; 68 | outline: none; 69 | 70 | // Focused state with glowing border 71 | &.focused { 72 | border-color: #4fadff !important; 73 | box-shadow: 0 0 8px rgba(79, 173, 255, 0.5) !important; 74 | } 75 | } 76 | 77 | // Make modal backdrop darker and prevent clicks 78 | .modal-backdrop { 79 | opacity: 0.7 !important; 80 | pointer-events: all !important; 81 | } 82 | 83 | // Prevent interaction with elements behind the modal 84 | .modal { 85 | pointer-events: all !important; 86 | } 87 | 88 | // Special class for modals that need to force focus 89 | .force-focus-modal { 90 | .modal-dialog { 91 | // Ensure the modal is always on top 92 | z-index: 1100 !important; 93 | } 94 | 95 | // Prevent any interaction with elements outside the modal 96 | ~ .modal-backdrop { 97 | opacity: 0.8 !important; 98 | pointer-events: all !important; 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/type/types.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | /** 4 | * Standard MCP response content types 5 | * Must match the MCP SDK expected format 6 | */ 7 | export type McpTextContent = { 8 | [x: string]: unknown; 9 | type: "text"; 10 | text: string; 11 | }; 12 | 13 | export type McpImageContent = { 14 | [x: string]: unknown; 15 | type: "image"; 16 | data: string; 17 | mimeType: string; 18 | }; 19 | 20 | export type McpResourceContent = { 21 | [x: string]: unknown; 22 | type: "resource"; 23 | resource: { 24 | [x: string]: unknown; 25 | text: string; 26 | uri: string; 27 | mimeType?: string; 28 | } | { 29 | [x: string]: unknown; 30 | uri: string; 31 | blob: string; 32 | mimeType?: string; 33 | }; 34 | }; 35 | 36 | export type McpContent = McpTextContent | McpImageContent | McpResourceContent; 37 | 38 | /** 39 | * Standard MCP response format 40 | * Must match the MCP SDK expected format 41 | */ 42 | export interface McpResponse { 43 | [x: string]: unknown; 44 | content: McpContent[]; 45 | isError?: boolean; 46 | _meta?: Record; 47 | } 48 | 49 | /** 50 | * Success response helper 51 | */ 52 | export function createSuccessResponse(text: string, metadata?: Record): McpResponse { 53 | return { 54 | content: [{ 55 | type: "text", 56 | text 57 | }], 58 | _meta: metadata 59 | }; 60 | } 61 | 62 | /** 63 | * JSON response helper 64 | */ 65 | export function createJsonResponse(data: any): McpResponse { 66 | return { 67 | content: [{ 68 | type: "text", 69 | text: JSON.stringify(data, null, 2) 70 | }] 71 | }; 72 | } 73 | 74 | /** 75 | * Error response helper 76 | */ 77 | export function createErrorResponse(errorMessage: string): McpResponse { 78 | return { 79 | content: [{ 80 | type: "text", 81 | text: errorMessage 82 | }], 83 | isError: true 84 | }; 85 | } 86 | 87 | /** 88 | * A generic MCP tool definition 89 | */ 90 | export interface McpTool { 91 | /** 92 | * The name of the tool 93 | */ 94 | name: string; 95 | 96 | /** 97 | * The description of the tool 98 | */ 99 | description: string; 100 | 101 | /** 102 | * The Zod schema for validating tool arguments 103 | * This should be a record of Zod validators 104 | * For tools with no parameters, use {} (empty object) 105 | */ 106 | schema: Record> | undefined; 107 | 108 | /** 109 | * The handler function for the tool 110 | */ 111 | handler: (args: T, extra: any) => Promise; 112 | } 113 | 114 | /** 115 | * Base interface for tool categories 116 | */ 117 | export interface ToolCategory { 118 | /** 119 | * The name of the category 120 | */ 121 | name: string; 122 | 123 | /** 124 | * List of MCP tools in this category 125 | */ 126 | readonly mcpTools: McpTool[]; 127 | } -------------------------------------------------------------------------------- /src/services/minimizedDialogManager.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { BehaviorSubject, Observable } from 'rxjs'; 3 | 4 | /** 5 | * Interface for minimized dialog data 6 | */ 7 | export interface MinimizedDialog { 8 | id: string; 9 | title: string; 10 | component: any; 11 | instance: any; 12 | modalRef: any; 13 | timestamp: number; 14 | promiseResolver?: { 15 | resolve: (value: any) => void; 16 | reject: (reason: any) => void; 17 | }; 18 | } 19 | 20 | /** 21 | * Service to manage minimized dialogs 22 | */ 23 | @Injectable({ providedIn: 'root' }) 24 | export class MinimizedDialogManagerService { 25 | private minimizedDialogs = new BehaviorSubject([]); 26 | 27 | /** Observable for minimized dialogs */ 28 | get minimizedDialogs$(): Observable { 29 | return this.minimizedDialogs.asObservable(); 30 | } 31 | 32 | /** Get current minimized dialogs */ 33 | get dialogs(): MinimizedDialog[] { 34 | return this.minimizedDialogs.value; 35 | } 36 | 37 | /** 38 | * Minimize a dialog 39 | */ 40 | minimizeDialog(dialog: MinimizedDialog): void { 41 | const current = this.minimizedDialogs.value; 42 | const existingIndex = current.findIndex(d => d.id === dialog.id); 43 | 44 | if (existingIndex >= 0) { 45 | // Update existing dialog 46 | const updated = [...current]; 47 | updated[existingIndex] = dialog; 48 | this.minimizedDialogs.next(updated); 49 | } else { 50 | // Add new dialog 51 | const updated = [...current, dialog]; 52 | this.minimizedDialogs.next(updated); 53 | } 54 | } 55 | 56 | /** 57 | * Restore a minimized dialog 58 | */ 59 | restoreDialog(dialogId: string): MinimizedDialog | null { 60 | const current = this.minimizedDialogs.value; 61 | const dialog = current.find(d => d.id === dialogId); 62 | 63 | if (dialog) { 64 | const updated = current.filter(d => d.id !== dialogId); 65 | this.minimizedDialogs.next(updated); 66 | return dialog; 67 | } 68 | 69 | return null; 70 | } 71 | 72 | /** 73 | * Close a minimized dialog completely 74 | */ 75 | closeMinimizedDialog(dialogId: string): void { 76 | const current = this.minimizedDialogs.value; 77 | const updated = current.filter(d => d.id !== dialogId); 78 | this.minimizedDialogs.next(updated); 79 | } 80 | 81 | /** 82 | * Check if a dialog is minimized 83 | */ 84 | isDialogMinimized(dialogId: string): boolean { 85 | return this.minimizedDialogs.value.some(d => d.id === dialogId); 86 | } 87 | 88 | /** 89 | * Clear all minimized dialogs 90 | */ 91 | clearAll(): void { 92 | this.minimizedDialogs.next([]); 93 | } 94 | 95 | /** 96 | * Generate unique dialog ID 97 | */ 98 | generateDialogId(): string { 99 | return `dialog_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; 100 | } 101 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # vitepress build output 108 | **/.vitepress/dist 109 | 110 | # vitepress cache directory 111 | **/.vitepress/cache 112 | 113 | # Docusaurus cache and generated files 114 | .docusaurus 115 | 116 | # Serverless directories 117 | .serverless/ 118 | 119 | # FuseBox cache 120 | .fusebox/ 121 | 122 | # DynamoDB Local files 123 | .dynamodb/ 124 | 125 | # TernJS port file 126 | .tern-port 127 | 128 | # Stores VSCode versions used for testing VSCode extensions 129 | .vscode-test 130 | 131 | # yarn v2 132 | .yarn/cache 133 | .yarn/unplugged 134 | .yarn/build-state.yml 135 | .yarn/install-state.gz 136 | .pnp.* 137 | 138 | # Tabby-MCP 139 | mcp_llm.txt 140 | package-lock.json 141 | build-and-copy.sh 142 | build/ 143 | example-ignore/ 144 | plugins/ 145 | tabby-mcp-server/ 146 | -------------------------------------------------------------------------------- /scripts/deploy.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const os = require('os'); 4 | 5 | // Paths 6 | const projectDir = process.cwd(); 7 | const buildDir = path.join(projectDir, 'build'); 8 | const srcDir = path.join(projectDir, 'src'); 9 | const packageJsonPath = path.join(projectDir, 'package.json'); 10 | const readmePath = path.join(projectDir, 'README.md'); 11 | const changelogPath = path.join(projectDir, 'CHANGELOG.md'); 12 | 13 | // New Target plugin directory 14 | const pluginDir = path.join(os.homedir(), 'AppData', 'Roaming', 'tabby', 'plugins', 'node_modules', 'tabby-copilot'); 15 | const pluginDistDir = path.join(pluginDir, 'plugins', 'dist'); 16 | 17 | // Helper function to copy files/directories 18 | function copyRecursive(src, dest) { 19 | const stat = fs.statSync(src); 20 | if (stat.isDirectory()) { 21 | if (!fs.existsSync(dest)) { 22 | fs.mkdirSync(dest, { recursive: true }); 23 | } 24 | const items = fs.readdirSync(src); 25 | items.forEach(item => { 26 | copyRecursive(path.join(src, item), path.join(dest, item)); 27 | }); 28 | } else { 29 | fs.copyFileSync(src, dest); 30 | } 31 | } 32 | 33 | // Main deployment function 34 | function deploy() { 35 | console.log('🚀 Starting deployment...'); 36 | 37 | // Check if build directory exists 38 | if (!fs.existsSync(buildDir)) { 39 | console.error('❌ build directory not found. Please run npm run build first.'); 40 | process.exit(1); 41 | } 42 | 43 | // Remove existing plugin directory 44 | if (fs.existsSync(pluginDir)) { 45 | console.log('🧹 Cleaning existing plugin directory...'); 46 | fs.rmSync(pluginDir, { recursive: true, force: true }); 47 | } 48 | 49 | // Create plugin directory and dist subdirectory 50 | console.log('📁 Creating plugin directory structure...'); 51 | fs.mkdirSync(pluginDistDir, { recursive: true }); 52 | 53 | // Copy build folder contents to plugin/dist 54 | console.log('📦 Copying build folder to dist...'); 55 | copyRecursive(buildDir, pluginDistDir); 56 | 57 | // Copy src folder contents to plugin/dist 58 | console.log('📂 Copying src folder to dist...'); 59 | if (fs.existsSync(srcDir)) { 60 | copyRecursive(srcDir, pluginDistDir); 61 | } 62 | 63 | // Copy package.json 64 | console.log('📄 Copying package.json...'); 65 | fs.copyFileSync(packageJsonPath, path.join(pluginDir, 'package.json')); 66 | 67 | // Copy README.md if it exists 68 | if (fs.existsSync(readmePath)) { 69 | console.log('📄 Copying README.md...'); 70 | fs.copyFileSync(readmePath, path.join(pluginDir, 'README.md')); 71 | } 72 | 73 | // Copy CHANGELOG.md if it exists 74 | if (fs.existsSync(changelogPath)) { 75 | console.log('📄 Copying CHANGELOG.md...'); 76 | fs.copyFileSync(changelogPath, path.join(pluginDir, 'CHANGELOG.md')); 77 | } 78 | 79 | console.log('✅ Deployment completed successfully!'); 80 | console.log(`📍 Plugin installed at: ${pluginDir}`); 81 | console.log(''); 82 | console.log('🔄 Please restart Tabby to load the plugin.'); 83 | } 84 | 85 | // Run deployment 86 | deploy(); 87 | -------------------------------------------------------------------------------- /src/services/commandOutputStorage.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { McpLoggerService } from './mcpLogger.service'; 3 | 4 | /** 5 | * Interface for stored command output 6 | */ 7 | export interface StoredCommandOutput { 8 | id: string; 9 | command: string; 10 | output: string; 11 | promptShell: string | null; 12 | exitCode: number | null; 13 | timestamp: number; 14 | aborted: boolean; 15 | tabId: number; 16 | } 17 | 18 | /** 19 | * Service for storing and retrieving command outputs 20 | * Uses in-memory storage for simplicity, but could be extended to use a database 21 | */ 22 | @Injectable({ providedIn: 'root' }) 23 | export class CommandOutputStorageService { 24 | // In-memory storage for command outputs 25 | private outputs: Map = new Map(); 26 | 27 | constructor(private logger: McpLoggerService) { 28 | this.logger.info('CommandOutputStorageService initialized'); 29 | } 30 | 31 | /** 32 | * Store a command output 33 | * @param data Command output data 34 | * @returns The ID of the stored output 35 | */ 36 | storeOutput(data: Omit): string { 37 | // Generate a unique ID based on timestamp and random string 38 | const id = `cmd_${Date.now()}_${Math.random().toString(36).substring(2, 10)}`; 39 | 40 | // Store the output with the generated ID 41 | this.outputs.set(id, { ...data, id }); 42 | 43 | this.logger.info(`Stored command output with ID: ${id}, command: ${data.command}, output length: ${data.output.length}`); 44 | 45 | // Clean up old outputs if there are too many (keep the 100 most recent) 46 | if (this.outputs.size > 100) { 47 | const oldestKeys = Array.from(this.outputs.keys()).sort((a, b) => { 48 | return this.outputs.get(a)!.timestamp - this.outputs.get(b)!.timestamp; 49 | }).slice(0, this.outputs.size - 100); 50 | 51 | oldestKeys.forEach(key => this.outputs.delete(key)); 52 | this.logger.info(`Cleaned up ${oldestKeys.length} old command outputs`); 53 | } 54 | 55 | return id; 56 | } 57 | 58 | /** 59 | * Get a stored command output 60 | * @param id The ID of the stored output 61 | * @returns The stored output or null if not found 62 | */ 63 | getOutput(id: string): StoredCommandOutput | null { 64 | const output = this.outputs.get(id); 65 | if (!output) { 66 | this.logger.warn(`Command output with ID ${id} not found`); 67 | return null; 68 | } 69 | return output; 70 | } 71 | 72 | /** 73 | * Get a paginated portion of a stored command output 74 | * @param id The ID of the stored output 75 | * @param startLine The starting line number (1-based) 76 | * @param maxLines The maximum number of lines to return 77 | * @returns The paginated output data or null if not found 78 | */ 79 | getPaginatedOutput(id: string, startLine: number = 1, maxLines: number = 250): { 80 | lines: string[]; 81 | totalLines: number; 82 | part: number; 83 | totalParts: number; 84 | command: string; 85 | exitCode: number | null; 86 | promptShell: string | null; 87 | aborted: boolean; 88 | } | null { 89 | const output = this.getOutput(id); 90 | if (!output) { 91 | return null; 92 | } 93 | 94 | // Split the output into lines 95 | const lines = output.output.split('\n'); 96 | const totalLines = lines.length; 97 | 98 | // Calculate total parts 99 | const totalParts = Math.ceil(totalLines / maxLines); 100 | 101 | // Calculate the part number based on the starting line 102 | const part = Math.ceil(startLine / maxLines); 103 | 104 | // Calculate start and end indices 105 | const startIdx = Math.max(0, startLine - 1); 106 | const endIdx = Math.min(startIdx + maxLines, totalLines); 107 | 108 | // Extract the requested lines 109 | const paginatedLines = lines.slice(startIdx, endIdx); 110 | 111 | return { 112 | lines: paginatedLines, 113 | totalLines, 114 | part, 115 | totalParts, 116 | command: output.command, 117 | exitCode: output.exitCode, 118 | promptShell: output.promptShell, 119 | aborted: output.aborted 120 | }; 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/tools/terminal/get-terminal-buffer-tool.ts: -------------------------------------------------------------------------------- 1 | import * as z from 'zod'; 2 | import stripAnsi from 'strip-ansi'; 3 | import { createErrorResponse, createJsonResponse } from '../../type/types'; 4 | import { BaseTool } from './base-tool'; 5 | import { ExecToolCategory } from '../terminal'; 6 | import { McpLoggerService } from '../../services/mcpLogger.service'; 7 | 8 | /** 9 | * Tool for retrieving the current content (text buffer) of a terminal session 10 | * 11 | * This tool allows retrieving the text content of a terminal with options 12 | * to specify line ranges, useful for analyzing command output or terminal state. 13 | */ 14 | export class GetTerminalBufferTool extends BaseTool { 15 | private readonly MAX_LINES = 200; 16 | 17 | constructor(private execToolCategory: ExecToolCategory, logger: McpLoggerService) { 18 | super(logger); 19 | } 20 | 21 | getTool() { 22 | return { 23 | name: 'get_terminal_buffer', 24 | description: `Retrieves the current content (text buffer) of a terminal session with options to specify line ranges. 25 | 26 | USE CASES: 27 | - Check the current state of a terminal 28 | - Analyze command output after execution 29 | - Verify if a command completed successfully 30 | - Extract information from the terminal display 31 | 32 | LIMITATIONS: 33 | - Maximum of 200 lines can be retrieved at once 34 | - ANSI color codes and formatting are stripped from the output 35 | - Very long lines may be wrapped or truncated by the terminal 36 | 37 | RETURNS: 38 | { 39 | "lines": ["line1", "line2", ...], // Array of text lines from the terminal 40 | "totalLines": 500, // Total number of lines in the buffer 41 | "startLine": 1, // The starting line number requested 42 | "endLine": 200 // The ending line number returned 43 | } 44 | 45 | RELATED TOOLS: 46 | - get_ssh_session_list: Find available terminal sessions 47 | - exec_command: Execute commands in a terminal 48 | 49 | EXAMPLE USAGE: 50 | 1. Get all terminal sessions: get_ssh_session_list() 51 | 2. Get the last 50 lines: get_terminal_buffer({ tabId: "0", startLine: 1, endLine: 50 }) 52 | 3. Get lines 100-200: get_terminal_buffer({ tabId: "0", startLine: 100, endLine: 200 }) 53 | 54 | POSSIBLE ERRORS: 55 | - "No terminal session found with ID {tabId}" - Use get_ssh_session_list to find valid IDs 56 | - "Failed to get terminal buffer" - The terminal may be in an invalid state`, 57 | schema: { 58 | tabId: z.string().describe('The ID of the terminal tab to get the buffer from. Get available IDs by calling get_ssh_session_list first. Example: "0" or "1".'), 59 | 60 | startLine: z.number().int().min(1).optional().default(1) 61 | .describe('The starting line number from the bottom of the terminal buffer (1-based, default: 1). Line 1 is the most recent line at the bottom of the terminal. Example: 1 for the last line, 10 to start from the 10th line from the bottom.'), 62 | 63 | endLine: z.number().int().optional().default(-1) 64 | .describe('The ending line number from the bottom of the terminal buffer (1-based, default: -1 for all lines up to the maximum of 200). Must be greater than or equal to startLine. Example: 50 to get 50 lines, -1 to get all available lines up to the maximum.') 65 | }, 66 | handler: async (params, extra) => { 67 | try { 68 | const { tabId, startLine, endLine } = params; 69 | 70 | // Find all terminal sessions 71 | const sessions = this.execToolCategory.findAndSerializeTerminalSessions(); 72 | 73 | // Find the requested session 74 | const session = sessions.find(s => s.id.toString() === tabId); 75 | if (!session) { 76 | return createErrorResponse(`No terminal session found with ID ${tabId}`); 77 | } 78 | 79 | // Get terminal buffer 80 | const text = this.execToolCategory.getTerminalBufferText(session); 81 | if (!text) { 82 | return createErrorResponse('Failed to get terminal buffer text'); 83 | } 84 | 85 | // Split into lines and remove empty lines 86 | const lines = stripAnsi(text).split('\n').filter(line => line.trim().length > 0); 87 | 88 | // Validate line ranges 89 | if (startLine < 1) { 90 | return createErrorResponse(`Invalid startLine: ${startLine}. Must be >= 1`); 91 | } 92 | 93 | if (endLine !== -1 && endLine < startLine) { 94 | return createErrorResponse(`Invalid endLine: ${endLine}. Must be >= startLine or -1`); 95 | } 96 | 97 | const totalLines = lines.length; 98 | 99 | // Calculate line indices from the bottom 100 | const start = Math.max(0, totalLines - startLine); 101 | const end = endLine === -1 102 | ? Math.max(start - this.MAX_LINES, 0) 103 | : Math.max(0, start - endLine); 104 | 105 | // Extract the requested lines 106 | const requestedLines = lines.slice(end, start); 107 | 108 | return createJsonResponse({ 109 | lines: requestedLines, 110 | totalLines, 111 | startLine, 112 | endLine: endLine === -1 ? Math.min(startLine + this.MAX_LINES - 1, totalLines) : endLine 113 | }); 114 | } catch (err) { 115 | this.logger.error(`Error getting terminal buffer:`, err); 116 | return createErrorResponse(`Failed to get terminal buffer: ${err.message || err}`); 117 | } 118 | } 119 | }; 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/services/mcpHotkey.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { AppService, HotkeysService } from 'tabby-core'; 3 | import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; 4 | import { ExecToolCategory } from '../tools/terminal'; 5 | import { McpLoggerService } from './mcpLogger.service'; 6 | import { CommandHistoryModalComponent } from '../components/commandHistoryModal.component'; 7 | import { RunningCommandsDialogComponent } from '../components/runningCommandsDialog.component'; 8 | import { ExtensionRecommendationDialogComponent } from '../components/extensionRecommendationDialog.component'; 9 | import { exec } from 'child_process'; 10 | import * as net from 'net'; 11 | 12 | /** 13 | * Service for handling MCP-related hotkeys 14 | */ 15 | @Injectable({ providedIn: 'root' }) 16 | export class McpHotkeyService { 17 | constructor( 18 | private hotkeysService: HotkeysService, 19 | private execToolCategory: ExecToolCategory, 20 | private logger: McpLoggerService, 21 | private modal: NgbModal, 22 | private app: AppService, 23 | ) { 24 | this.logger.info('McpHotkeyService initialized'); 25 | this.initializeHotkeys(); 26 | } 27 | 28 | private initializeHotkeys(): void { 29 | // Subscribe to hotkey events 30 | this.hotkeysService.matchedHotkey.subscribe(async (hotkey) => { 31 | if (hotkey === 'mcp-abort-command') { 32 | this.abortFocusedCommand(); 33 | } else if (hotkey === 'mcp-show-command-history') { 34 | this.showCommandHistory(); 35 | } else if (hotkey === 'mcp-show-running-commands') { 36 | this.showRunningCommands(); 37 | } else if (hotkey === 'mcp-open-copilot') { 38 | this.openCopilot(); 39 | } 40 | }); 41 | } 42 | 43 | /** 44 | * Abort command in the currently focused terminal session 45 | */ 46 | private abortFocusedCommand(): void { 47 | try { 48 | // Find all terminal sessions 49 | const sessions = this.execToolCategory.findAndSerializeTerminalSessions(); 50 | 51 | // Find the focused session 52 | const focusedSession = sessions.find(s => s.tab.hasFocus); 53 | 54 | if (!focusedSession) { 55 | this.logger.warn('No focused terminal session found for abort command'); 56 | return; 57 | } 58 | 59 | // Check if there's an active command in the focused session 60 | const activeCommand = this.execToolCategory.getActiveCommand(focusedSession.id); 61 | 62 | if (!activeCommand) { 63 | this.logger.info(`No active command to abort in focused session ${focusedSession.id}`); 64 | return; 65 | } 66 | 67 | this.logger.info(`Aborting command in focused session ${focusedSession.id}: ${activeCommand.command}`); 68 | 69 | // Abort the command 70 | this.execToolCategory.abortCommand(focusedSession.id); 71 | 72 | } catch (error) { 73 | this.logger.error('Error aborting focused command:', error); 74 | } 75 | } 76 | 77 | /** 78 | * Show command history dialog 79 | */ 80 | private showCommandHistory(): void { 81 | try { 82 | this.logger.info('Showing command history via hotkey'); 83 | this.modal.open(CommandHistoryModalComponent, { 84 | size: 'xl', 85 | backdrop: true, 86 | keyboard: true 87 | }); 88 | } catch (error) { 89 | this.logger.error('Error showing command history:', error); 90 | } 91 | } 92 | 93 | /** 94 | * Show running commands dialog 95 | */ 96 | private showRunningCommands(): void { 97 | try { 98 | this.logger.info('Showing running commands via hotkey'); 99 | this.modal.open(RunningCommandsDialogComponent, { 100 | size: 'lg', 101 | backdrop: true, 102 | keyboard: true 103 | }); 104 | } catch (error) { 105 | this.logger.error('Error showing running commands:', error); 106 | } 107 | } 108 | 109 | /** 110 | * Open Copilot window 111 | */ 112 | public openCopilot(): void { 113 | try { 114 | this.logger.info('Attempting to open Copilot window'); 115 | this.openCopilotViaTCP(); 116 | } catch (error) { 117 | this.logger.error('Error opening Copilot window:', error); 118 | } 119 | } 120 | 121 | private openCopilotViaTCP(): void { 122 | const port = 6789; 123 | const host = '127.0.0.1'; 124 | const client = new net.Socket(); 125 | 126 | this.logger.info(`Attempting to connect to VS Code extension on port ${port}`); 127 | 128 | client.connect(port, host, () => { 129 | this.logger.info('Successfully connected to VS Code extension. Requesting to open Copilot.'); 130 | client.end(); 131 | }); 132 | 133 | client.on('error', (err) => { 134 | this.logger.warn('Failed to connect to VS Code extension via TCP, falling back to PowerShell.', err.message); 135 | this.openCopilotWithPowerShell(); 136 | }); 137 | } 138 | 139 | private openCopilotWithPowerShell(): void { 140 | this.logger.info('Opening Copilot window via PowerShell'); 141 | 142 | // Show recommendation dialog 143 | this.modal.open(ExtensionRecommendationDialogComponent, { 144 | size: 'md', 145 | backdrop: true, 146 | keyboard: true 147 | }); 148 | 149 | exec('pwsh.exe -Command "code --command workbench.action.chat.openInNewWindow"', (error, stdout, stderr) => { 150 | if (error) { 151 | this.logger.error('Error running VS Code Copilot command:', error); 152 | } else { 153 | this.logger.info(`VS Code Copilot command executed: ${stdout}`); 154 | } 155 | }); 156 | } 157 | } -------------------------------------------------------------------------------- /src/components/commandHistoryModal.component.scss: -------------------------------------------------------------------------------- 1 | // 1. Base Variables from mcpSettingsTab.component.scss 2 | :host { 3 | --bg-primary: #1a1d21; 4 | --bg-secondary: #2c3038; 5 | --bg-tertiary: #24272c; 6 | --text-primary: #e6e6e6; 7 | --text-secondary: #b3b3b3; 8 | --text-accent: #61afef; 9 | --border-primary: #3a3f4b; 10 | --border-secondary: #505663; 11 | --shadow-color: rgba(0, 0, 0, 0.3); 12 | --success-color: #98c379; 13 | --danger-color: #e06c75; 14 | --warning-color: #e5c07b; // From Atom One Dark 15 | --info-color: #56b6c2; 16 | } 17 | 18 | .modal-header { 19 | background-color: var(--bg-secondary); 20 | color: var(--text-primary); 21 | border-bottom: 1px solid var(--border-primary); 22 | 23 | .modal-title { 24 | font-weight: 600; 25 | color: var(--text-accent); 26 | } 27 | 28 | .btn-close { 29 | filter: invert(1) grayscale(100%) brightness(200%); 30 | } 31 | } 32 | 33 | .modal-body { 34 | background-color: var(--bg-primary); 35 | color: var(--text-primary); 36 | max-height: 70vh; 37 | overflow-y: auto; 38 | } 39 | 40 | .modal-footer { 41 | background-color: var(--bg-secondary); 42 | border-top: 1px solid var(--border-primary); 43 | } 44 | 45 | // Search and Filter 46 | .input-group-text { 47 | background-color: var(--bg-tertiary); 48 | border: 1px solid var(--border-secondary); 49 | color: var(--text-secondary); 50 | } 51 | 52 | .form-control, .form-select { 53 | background-color: var(--bg-tertiary); 54 | color: var(--text-primary); 55 | border: 1px solid var(--border-secondary); 56 | border-radius: 8px; 57 | 58 | &:focus { 59 | border-color: var(--text-accent); 60 | box-shadow: 0 0 0 3px rgba(97, 175, 239, 0.2); 61 | outline: none; 62 | background-color: var(--bg-tertiary); // Keep bg color on focus 63 | } 64 | } 65 | 66 | // History List 67 | .history-list { 68 | max-height: 60vh; 69 | overflow-y: auto; 70 | } 71 | 72 | .history-entry { 73 | background-color: var(--bg-secondary); 74 | border: 1px solid var(--border-primary); 75 | border-radius: 12px; 76 | box-shadow: 0 4px 12px var(--shadow-color); 77 | transition: all 0.2s ease-in-out; 78 | 79 | &:hover { 80 | border-color: var(--text-accent); 81 | transform: translateY(-2px); 82 | } 83 | } 84 | 85 | // Command and Output sections 86 | .command-section { 87 | strong { 88 | color: var(--text-accent); 89 | } 90 | code { 91 | background-color: var(--bg-primary) !important; 92 | color: var(--text-primary) !important; 93 | border: 1px solid var(--border-primary); 94 | border-radius: 8px; 95 | font-size: 0.9rem; 96 | word-break: break-all; 97 | } 98 | } 99 | 100 | .output-section { 101 | strong { 102 | color: var(--info-color); 103 | } 104 | pre.output-text { 105 | background-color: var(--bg-primary) !important; 106 | color: var(--text-secondary) !important; 107 | border: 1px solid var(--border-primary); 108 | border-radius: 8px; 109 | max-height: 300px; 110 | overflow-y: auto; 111 | font-size: 0.85rem; 112 | } 113 | } 114 | 115 | // Badges 116 | .badge { 117 | font-weight: 600; 118 | font-size: 0.75rem; 119 | &.bg-success { 120 | background-color: var(--success-color) !important; 121 | color: var(--bg-primary) !important; 122 | } 123 | &.bg-danger { 124 | background-color: var(--danger-color) !important; 125 | color: var(--bg-primary) !important; 126 | } 127 | &.bg-warning { 128 | background-color: var(--warning-color) !important; 129 | color: var(--bg-primary) !important; 130 | } 131 | } 132 | 133 | // Buttons 134 | .btn { 135 | border: none; 136 | border-radius: 8px; 137 | padding: 0.6rem 1.2rem; 138 | font-weight: 600; 139 | cursor: pointer; 140 | transition: all 0.2s ease-in-out; 141 | display: inline-flex; 142 | align-items: center; 143 | justify-content: center; 144 | gap: 0.5rem; 145 | 146 | &.btn-outline-primary, &.btn-outline-secondary, &.btn-outline-danger, &.btn-outline-success { 147 | background-color: var(--bg-tertiary); 148 | color: var(--text-primary); 149 | border: 1px solid var(--border-secondary); 150 | 151 | &:hover { 152 | background-color: var(--border-secondary); 153 | border-color: var(--text-accent); 154 | } 155 | } 156 | 157 | &.btn-secondary { 158 | background-color: var(--bg-tertiary); 159 | color: var(--text-primary); 160 | border: 1px solid var(--border-secondary); 161 | 162 | &:hover { 163 | background-color: var(--border-secondary); 164 | } 165 | } 166 | } 167 | 168 | // Empty State 169 | .text-center.text-muted { 170 | color: var(--text-secondary) !important; 171 | .fas { 172 | color: var(--text-secondary); 173 | } 174 | } 175 | 176 | // Scrollbar 177 | ::-webkit-scrollbar { 178 | width: 8px; 179 | } 180 | ::-webkit-scrollbar-track { 181 | background: var(--bg-primary); 182 | } 183 | ::-webkit-scrollbar-thumb { 184 | background: var(--border-secondary); 185 | border-radius: 4px; 186 | } 187 | ::-webkit-scrollbar-thumb:hover { 188 | background: var(--text-accent); 189 | } 190 | 191 | // Dropdown 192 | .dropdown-menu { 193 | background-color: var(--bg-secondary); 194 | border: 1px solid var(--border-primary); 195 | box-shadow: 0 4px 8px var(--shadow-color); 196 | border-radius: 0.5rem; 197 | min-width: 320px; 198 | } 199 | .dropdown-header { 200 | font-weight: 600; 201 | color: var(--text-accent); 202 | background-color: var(--bg-tertiary); 203 | } 204 | .dropdown-item { 205 | color: var(--text-primary); 206 | &:hover { 207 | background-color: var(--bg-tertiary); 208 | color: var(--text-accent); 209 | } 210 | } 211 | .dropdown-divider { 212 | border-top: 1px solid var(--border-primary); 213 | } -------------------------------------------------------------------------------- /src/tools/terminal/get-command-output-tool.ts: -------------------------------------------------------------------------------- 1 | import * as z from 'zod'; 2 | import { createErrorResponse, createJsonResponse } from '../../type/types'; 3 | import { BaseTool } from './base-tool'; 4 | import { McpLoggerService } from '../../services/mcpLogger.service'; 5 | import { CommandOutputStorageService } from '../../services/commandOutputStorage.service'; 6 | 7 | /** 8 | * Tool for retrieving the full or paginated output of previously executed commands 9 | * 10 | * This tool allows accessing the complete output of commands that may have been 11 | * truncated in the initial response due to length limitations, with pagination 12 | * support for very long outputs. 13 | */ 14 | export class GetCommandOutputTool extends BaseTool { 15 | private readonly MAX_LINES_PER_RESPONSE = 250; 16 | private outputStorage: CommandOutputStorageService; 17 | 18 | constructor(logger: McpLoggerService, outputStorage?: CommandOutputStorageService) { 19 | super(logger); 20 | // If outputStorage is not provided, create a new instance 21 | this.outputStorage = outputStorage || new CommandOutputStorageService(logger); 22 | } 23 | 24 | getTool() { 25 | return { 26 | name: 'get_command_output', 27 | description: `Retrieves the full or paginated output of a previously executed command using its outputId. 28 | 29 | USE CASES: 30 | - Get the complete output of a command that was truncated 31 | - Retrieve specific portions of a long command output 32 | - Access historical command results 33 | 34 | LIMITATIONS: 35 | - The outputId must be from a recent command execution 36 | - Maximum of 250 lines can be retrieved in a single request 37 | - Command output is stored temporarily and may be lost after session restart 38 | 39 | RETURNS: 40 | { 41 | "command": "original command text", 42 | "output": "paginated command output text", 43 | "promptShell": "shell prompt (e.g., user@host:~$)", 44 | "exitCode": 0, // Command exit code 45 | "aborted": false, // Whether the command was aborted 46 | "pagination": { 47 | "startLine": 1, // Starting line of this page 48 | "endLine": 250, // Ending line of this page 49 | "totalLines": 1000, // Total lines in the complete output 50 | "part": 1, // Current page number 51 | "totalParts": 4, // Total number of pages 52 | "maxLines": 250 // Maximum lines per page 53 | } 54 | } 55 | 56 | RELATED TOOLS: 57 | - exec_command: Execute commands and get outputId 58 | - get_ssh_session_list: Find available terminal sessions 59 | 60 | EXAMPLE USAGE: 61 | 1. Execute a command: result = exec_command({ command: "find / -name '*.log'" }) 62 | 2. If output is truncated, get the outputId from the result 63 | 3. Get the first page: get_command_output({ outputId: "abc123", startLine: 1, maxLines: 250 }) 64 | 4. Get the next page: get_command_output({ outputId: "abc123", startLine: 251, maxLines: 250 }) 65 | 66 | POSSIBLE ERRORS: 67 | - "Command output with ID {outputId} not found" - The outputId is invalid or expired 68 | - "Failed to retrieve command output" - An error occurred while retrieving the output`, 69 | schema: { 70 | outputId: z.string().describe('The unique ID of the stored command output to retrieve. This ID is returned by the exec_command tool when a command has been executed. Example: "cmd_1234567890".'), 71 | 72 | startLine: z.number().int().min(1).optional().default(1) 73 | .describe('The line number to start retrieving output from (1-based, default: 1). Use this for pagination to retrieve different portions of long outputs. Example: 1 for the first page, 251 for the second page when using default maxLines.'), 74 | 75 | maxLines: z.number().int().optional().default(250) 76 | .describe('The maximum number of lines to return in a single response (default: 250, maximum: 1000). Adjust this value to control the amount of data returned. Example: 100 for smaller chunks, 500 for larger chunks.') 77 | }, 78 | handler: async (params: any) => { 79 | try { 80 | const { outputId, startLine, maxLines } = params; 81 | 82 | // Get the paginated output 83 | const paginatedOutput = this.outputStorage.getPaginatedOutput( 84 | outputId, 85 | startLine, 86 | maxLines || this.MAX_LINES_PER_RESPONSE 87 | ); 88 | 89 | if (!paginatedOutput) { 90 | return createErrorResponse(`Command output with ID ${outputId} not found`); 91 | } 92 | 93 | // Format the output 94 | const { lines, totalLines, part, totalParts, command, exitCode, promptShell, aborted } = paginatedOutput; 95 | 96 | // Add pagination info to the output if there are multiple parts 97 | let outputText = lines.join('\n'); 98 | if (totalParts > 1) { 99 | outputText += `\n\n[Showing part ${part}/${totalParts} (lines ${startLine}-${Math.min(startLine + lines.length - 1, totalLines)} of ${totalLines})]`; 100 | outputText += `\nTo see other parts, use startLine parameter (e.g., startLine: ${startLine + maxLines})`; 101 | } 102 | 103 | return createJsonResponse({ 104 | command, 105 | output: outputText, 106 | promptShell, 107 | exitCode, 108 | aborted, 109 | pagination: { 110 | startLine, 111 | endLine: Math.min(startLine + lines.length - 1, totalLines), 112 | totalLines, 113 | part, 114 | totalParts, 115 | maxLines 116 | } 117 | }); 118 | } catch (err) { 119 | this.logger.error(`Error retrieving command output:`, err); 120 | return createErrorResponse(`Failed to retrieve command output: ${err.message || err}`); 121 | } 122 | } 123 | }; 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/components/mcpSettingsTab.component.pug: -------------------------------------------------------------------------------- 1 | .content 2 | .settings-header 3 | h1.tabby-header 4 | i.bi.bi-gear-wide-connected.mr-2 5 | | 🚀 Copilot VSCode Agent ⚙️ 6 | .links-container 7 | button.btn.btn-secondary.github-btn((click)="openGitHub()") 8 | i.fab.fa-github.mr-1 9 | span View GitHub 10 | button.btn.btn-secondary.vscode-btn((click)="openVSCodeExtension()") 11 | i.fab.fa-vscode.mr-1 12 | span VSCode Extension 13 | 14 | .settings-columns 15 | .settings-left 16 | .header 17 | .title 18 | i.bi.bi-gear-wide-connected.mr-2 19 | | 🚀 Copilot VSCode Agent ⚙️ 20 | .description Configure settings for the Copilot integration 21 | //- Copilot Settings Section 22 | .form-group 23 | button.btn.btn-secondary((click)='toggleInstructions()') 24 | i.fas.fa-info-circle.mr-1 25 | span {{ instructionsVisible ? 'Hide' : 'Show' }} Instructions 26 | .card.instructions-card(*ngIf='instructionsVisible') 27 | .card-body 28 | h5.card-title How to Integrate with VS Code 29 | p To connect Tabby with VS Code, you need to configure the MCP server in your VS Code settings. Open your settings.json and add one of the following configurations: 30 | p If you have issues with npx, install tabby-mcp-stdio globally: 31 | code npm install -g tabby-mcp-stdio 32 | .mcp-config-options 33 | .mcp-config-option 34 | h6 HTTP/SSE MCP 35 | .d-flex.align-items-center 36 | pre.mcp-config-snippet 37 | code([innerText]='vscodeSettingsJson') 38 | button.btn.btn-sm.btn-outline-secondary.ml-2((click)='copyConfigJson("http")', title='Copy HTTP/SSE MCP config') 39 | i.fas.fa-copy 40 | span.ml-1 Copy 41 | .mcp-config-option.mt-3 42 | h6 STDIO MCP 43 | .d-flex.align-items-center 44 | pre.mcp-config-snippet 45 | code([innerText]='stdioSettingsJson') 46 | button.btn.btn-sm.btn-outline-secondary.ml-2((click)='copyConfigJson("stdio")', title='Copy STDIO MCP config') 47 | i.fas.fa-copy 48 | span.ml-1 Copy 49 | p This will allow Tabby to send commands to VS Code, enabling features like opening the chat window directly from the toolbar 50 | .form-group 51 | label Server URL 52 | .d-flex 53 | input.form-control.mr-2(type='text', [(ngModel)]='serverUrl') 54 | button.btn.btn-secondary((click)='saveServerUrl()') 55 | i.fas.fa-save.mr-1 56 | span Save 57 | .text-muted The URL of the MCP server to connect to 58 | .form-group 59 | label Port 60 | .d-flex 61 | input.form-control.mr-2(type='number', [(ngModel)]='port') 62 | button.btn.btn-secondary((click)='savePort()') 63 | i.fas.fa-save.mr-1 64 | span Save 65 | .text-muted The port number for the MCP server 66 | .form-group 67 | label Start on Boot 68 | .d-flex 69 | toggle([(ngModel)]='startOnBoot') 70 | button.btn.btn-secondary.ml-2((click)='toggleStartOnBoot()') 71 | i.fas.fa-save.mr-1 72 | span Save 73 | .text-muted Automatically start the MCP server when Tabby starts 74 | .form-group 75 | label Server Control 76 | .d-flex 77 | button.btn.btn-primary.mr-2(*ngIf='!isServerRunning', (click)='startServer()') 78 | i.fas.fa-play.mr-2 79 | span Start Server 80 | button.btn.btn-danger(*ngIf='isServerRunning', (click)='stopServer()') 81 | i.fas.fa-stop.mr-2 82 | span Stop Server 83 | .text-muted.mt-2(*ngIf='isServerRunning') Server is currently running 84 | .text-muted.mt-2(*ngIf='!isServerRunning') Server is currently stopped 85 | .form-group 86 | label Debug Logging 87 | .d-flex 88 | toggle([(ngModel)]='enableDebugLogging') 89 | button.btn.btn-secondary.ml-2((click)='toggleDebugLogging()') 90 | i.fas.fa-save.mr-1 91 | span Save 92 | .text-muted Enable detailed logging for MCP operations 93 | 94 | .settings-right 95 | .header 96 | .title Pair Programming Mode 97 | .description Manage collaborative AI features 98 | //- Pair Programming Mode Section 99 | .form-group 100 | label Enable Pair Programming Mode 101 | .d-flex 102 | toggle([(ngModel)]='pairProgrammingEnabled') 103 | button.btn.btn-secondary.ml-2((click)='togglePairProgrammingMode()') 104 | i.fas.fa-save.mr-1 105 | span Save 106 | .text-muted Enable collaborative features for pair programming with AI 107 | .form-group(*ngIf='pairProgrammingEnabled') 108 | label Auto Focus Terminal 109 | .d-flex 110 | toggle([(ngModel)]='autoFocusTerminal') 111 | button.btn.btn-secondary.ml-2((click)='toggleAutoFocusTerminal()') 112 | i.fas.fa-save.mr-1 113 | span Save 114 | .text-muted Automatically focus the terminal window when executing commands 115 | .form-group(*ngIf='pairProgrammingEnabled') 116 | label Show Confirmation Dialog 117 | .d-flex 118 | toggle([(ngModel)]='showConfirmationDialog') 119 | button.btn.btn-secondary.ml-2((click)='toggleShowConfirmationDialog()') 120 | i.fas.fa-save.mr-1 121 | span Save 122 | .text-muted Show a confirmation dialog before executing commands 123 | .form-group(*ngIf='pairProgrammingEnabled') 124 | label Show Result Dialog 125 | .d-flex 126 | toggle([(ngModel)]='showResultDialog') 127 | button.btn.btn-secondary.ml-2((click)='toggleShowResultDialog()') 128 | i.fas.fa-save.mr-1 129 | span Save 130 | .text-muted Show a dialog with command results after execution 131 | 132 | .github-link 133 | a(href='https://github.com/SteffMet', target='_blank') Created by SteffMet 134 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { CommonModule } from '@angular/common'; 3 | import { FormsModule } from '@angular/forms'; 4 | import TabbyCoreModule, { AppService, ConfigProvider, ConfigService, ToolbarButtonProvider, HostWindowService, HotkeyProvider } from 'tabby-core'; 5 | import { McpService } from './services/mcpService'; 6 | import { McpLoggerService } from './services/mcpLogger.service'; 7 | import { ExecToolCategory } from './tools/terminal'; 8 | import { VSCodeToolCategory } from './tools/vscode-tool-category'; 9 | import { ExecCommandButtonComponent } from './components/execCommandButton.component'; 10 | import { MinimizedDialogsModalComponent } from './components/minimizedModal.component'; 11 | import { CommandHistoryModalComponent } from './components/commandHistoryModal.component'; 12 | import { McpToolbarButtonProvider } from './toolbarButtonProvider'; 13 | import { McpSettingsTabProvider } from './settings'; 14 | import { McpSettingsTabComponent } from './components/mcpSettingsTab.component'; 15 | import { SettingsTabProvider } from 'tabby-settings'; 16 | import { McpConfigProvider } from './services/mcpConfigProvider'; 17 | import { ConfirmCommandDialogModule } from './components/confirmCommandDialog.component'; 18 | import { CommandResultDialogModule } from './components/commandResultDialog.component'; 19 | import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; 20 | import { DialogService } from './services/dialog.service'; 21 | import { DialogManagerService } from './services/dialogManager.service'; 22 | import { MinimizedDialogManagerService } from './services/minimizedDialogManager.service'; 23 | import { CommandHistoryManagerService } from './services/commandHistoryManager.service'; 24 | import { RunningCommandsManagerService } from './services/runningCommandsManager.service'; 25 | import { RunningCommandsDialogComponent } from './components/runningCommandsDialog.component'; 26 | import { McpHotkeyService } from './services/mcpHotkey.service'; 27 | import { McpHotkeyProvider } from './services/mcpHotkeyProvider.service'; 28 | import { ExtensionRecommendationDialogComponent } from './components/extensionRecommendationDialog.component'; 29 | import { UrlOpeningService } from './services/urlOpening.service'; 30 | 31 | // Import global styles 32 | import './styles.scss'; 33 | 34 | /** 35 | * Module for the MCP server integration 36 | */ 37 | @NgModule({ 38 | imports: [ 39 | CommonModule, 40 | FormsModule, 41 | TabbyCoreModule, 42 | NgbModule, 43 | CommandResultDialogModule, 44 | ConfirmCommandDialogModule 45 | ], 46 | // Xóa styleUrls ở đây 47 | providers: [ 48 | McpService, 49 | McpLoggerService, 50 | ExecToolCategory, 51 | VSCodeToolCategory, 52 | DialogService, 53 | DialogManagerService, 54 | MinimizedDialogManagerService, 55 | CommandHistoryManagerService, 56 | RunningCommandsManagerService, 57 | McpHotkeyService, 58 | UrlOpeningService, 59 | { provide: ToolbarButtonProvider, useClass: McpToolbarButtonProvider, multi: true }, 60 | { provide: SettingsTabProvider, useClass: McpSettingsTabProvider, multi: true }, 61 | { provide: ConfigProvider, useClass: McpConfigProvider, multi: true }, 62 | { provide: HotkeyProvider, useClass: McpHotkeyProvider, multi: true }, 63 | ], 64 | declarations: [ 65 | ExecCommandButtonComponent, 66 | MinimizedDialogsModalComponent, 67 | CommandHistoryModalComponent, 68 | RunningCommandsDialogComponent, 69 | McpSettingsTabComponent, 70 | ExtensionRecommendationDialogComponent 71 | ], 72 | entryComponents: [ 73 | ExecCommandButtonComponent, 74 | MinimizedDialogsModalComponent, 75 | CommandHistoryModalComponent, 76 | RunningCommandsDialogComponent, 77 | McpSettingsTabComponent, 78 | ExtensionRecommendationDialogComponent 79 | ], 80 | exports: [ 81 | ExecCommandButtonComponent 82 | ] 83 | }) 84 | export default class McpModule { 85 | /** 86 | * Simple constructor for module initialization 87 | * Server initialization is handled by the toolbar button provider 88 | */ 89 | private constructor( 90 | private app: AppService, 91 | private config: ConfigService, 92 | private mcpService: McpService, 93 | private logger: McpLoggerService, 94 | private hostWindow: HostWindowService, 95 | private mcpHotkeyService: McpHotkeyService 96 | ) { 97 | console.log('[McpModule] Module initialized'); 98 | 99 | // Initialize the server properly after app and config are ready 100 | this.app.ready$.subscribe(() => { 101 | this.config.ready$.toPromise().then(() => { 102 | this.initServerOnBoot(); 103 | }); 104 | }); 105 | } 106 | 107 | /** 108 | * Initialize server on boot based on configuration 109 | */ 110 | private async initServerOnBoot(): Promise { 111 | try { 112 | this.logger.info('Checking if MCP server should start on boot'); 113 | 114 | // Ensure config is available (should be guaranteed by config.ready$) 115 | if (!this.config.store.mcp) { 116 | this.logger.warn('MCP config not found, using default settings'); 117 | return; 118 | } 119 | 120 | // Check if startOnBoot is enabled 121 | const startOnBoot = this.config.store.mcp.startOnBoot !== false; // Default to true 122 | 123 | if (startOnBoot) { 124 | this.logger.info('Starting MCP server (start on boot enabled)'); 125 | await this.mcpService.startServer(this.config.store.mcp.port); 126 | } else { 127 | this.logger.info('MCP server not starting automatically (start on boot disabled)'); 128 | } 129 | } catch (error) { 130 | this.logger.error('Error starting MCP server on boot:', error); 131 | } 132 | } 133 | } 134 | 135 | export * from './services/mcpService'; 136 | export * from './services/mcpLogger.service'; 137 | export * from './type/types'; 138 | export * from './services/mcpConfigProvider'; 139 | export * from './services/dialog.service'; 140 | export * from './services/dialogManager.service'; 141 | export * from './services/commandHistoryManager.service'; 142 | export * from './services/runningCommandsManager.service'; 143 | export * from './services/mcpHotkey.service'; 144 | export * from './services/mcpHotkeyProvider.service'; 145 | -------------------------------------------------------------------------------- /src/toolbarButtonProvider.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { ToolbarButtonProvider, ToolbarButton } from 'tabby-core'; 3 | import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; 4 | import { ExecToolCategory } from './tools/terminal'; 5 | import { MinimizedDialogsModalComponent } from './components/minimizedModal.component'; 6 | import { MinimizedDialogManagerService } from './services/minimizedDialogManager.service'; 7 | import { CommandHistoryModalComponent } from './components/commandHistoryModal.component'; 8 | import { CommandHistoryManagerService } from './services/commandHistoryManager.service'; 9 | import { RunningCommandsDialogComponent } from './components/runningCommandsDialog.component'; 10 | import { RunningCommandsManagerService } from './services/runningCommandsManager.service'; 11 | import { McpHotkeyService } from './services/mcpHotkey.service'; 12 | 13 | @Injectable() 14 | export class McpToolbarButtonProvider extends ToolbarButtonProvider { 15 | private activeCommandsCount = 0; 16 | private minimizedDialogsCount = 0; 17 | private commandHistoryCount = 0; 18 | 19 | constructor( 20 | private execToolCategory: ExecToolCategory, 21 | private modal: NgbModal, 22 | private minimizedDialogManager: MinimizedDialogManagerService, 23 | private commandHistoryManager: CommandHistoryManagerService, 24 | private runningCommandsManager: RunningCommandsManagerService, 25 | private mcpHotkeyService: McpHotkeyService 26 | ) { 27 | super(); 28 | 29 | // Subscribe to changes in running commands 30 | this.runningCommandsManager.runningCommands$.subscribe(commands => { 31 | this.activeCommandsCount = commands.length; 32 | }); 33 | 34 | // Subscribe to minimized dialogs changes 35 | this.minimizedDialogManager.minimizedDialogs$.subscribe(dialogs => { 36 | this.minimizedDialogsCount = dialogs.length; 37 | }); 38 | 39 | // Subscribe to command history changes 40 | this.commandHistoryManager.commandHistory$.subscribe(history => { 41 | this.commandHistoryCount = history.length; 42 | }); 43 | } 44 | 45 | provide(): ToolbarButton[] { 46 | const runningIcon = ``; 47 | const minimizedIcon = ``; 48 | const historyIcon = ``; 49 | const copilotIcon = ` 50 | 51 | 52 | 53 | `; 54 | 55 | return [ 56 | { 57 | icon: runningIcon, 58 | weight: 5, 59 | title: this.activeCommandsCount > 0 ? `Running Commands (${this.activeCommandsCount})` : 'Running Commands', 60 | click: () => { 61 | this.showRunningCommandsModal(); 62 | } 63 | }, 64 | { 65 | icon: minimizedIcon, 66 | weight: 6, 67 | title: this.minimizedDialogsCount > 0 ? `Minimized Dialogs (${this.minimizedDialogsCount})` : 'Minimized Dialogs', 68 | click: () => { 69 | this.showMinimizedDialogsModal(); 70 | } 71 | }, 72 | { 73 | icon: historyIcon, 74 | weight: 7, 75 | title: this.commandHistoryCount > 0 ? `Command History (${this.commandHistoryCount})` : 'Command History', 76 | click: () => { 77 | this.showCommandHistoryModal(); 78 | } 79 | }, 80 | { 81 | icon: copilotIcon, 82 | weight: 8, 83 | title: 'Open Copilot Chat', 84 | click: () => { 85 | this.mcpHotkeyService.openCopilot(); 86 | } 87 | } 88 | ]; 89 | } 90 | 91 | private showMinimizedDialogsModal(): void { 92 | this.modal.open(MinimizedDialogsModalComponent, { 93 | size: 'lg', 94 | backdrop: true, 95 | keyboard: true 96 | }); 97 | } 98 | 99 | private showCommandHistoryModal(): void { 100 | this.modal.open(CommandHistoryModalComponent, { 101 | size: 'xl', 102 | backdrop: true, 103 | keyboard: true 104 | }); 105 | } 106 | 107 | private showRunningCommandsModal(): void { 108 | this.modal.open(RunningCommandsDialogComponent, { 109 | size: 'lg', 110 | backdrop: true, 111 | keyboard: true 112 | }); 113 | } 114 | } -------------------------------------------------------------------------------- /src/components/runningCommandsDialog.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, OnDestroy } from '@angular/core'; 2 | import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; 3 | import { Subscription, interval } from 'rxjs'; 4 | import { RunningCommandsManagerService, RunningCommand as RunningCommandBase } from '../services/runningCommandsManager.service'; 5 | import { ExecToolCategory } from '../tools/terminal'; 6 | 7 | export interface RunningCommand extends RunningCommandBase { 8 | duration: string; 9 | } 10 | 11 | @Component({ 12 | selector: 'running-commands-modal', 13 | template: ` 14 | 21 | 22 | 64 | 65 | 82 | `, 83 | styles: [` 84 | .list-group-item { 85 | border-left: 3px solid #28a745; 86 | } 87 | 88 | .modal-body { 89 | max-height: 500px; 90 | overflow-y: auto; 91 | } 92 | 93 | code { 94 | font-size: 0.85em; 95 | word-break: break-all; 96 | } 97 | 98 | .running-indicator { 99 | animation: pulse 2s infinite; 100 | } 101 | 102 | @keyframes pulse { 103 | 0% { opacity: 1; } 104 | 50% { opacity: 0.5; } 105 | 100% { opacity: 1; } 106 | } 107 | `] 108 | }) 109 | export class RunningCommandsDialogComponent implements OnInit, OnDestroy { 110 | runningCommands: RunningCommand[] = []; 111 | private refreshSubscription: Subscription; 112 | 113 | constructor( 114 | private activeModal: NgbActiveModal, 115 | private runningCommandsManager: RunningCommandsManagerService, 116 | private execToolCategory: ExecToolCategory 117 | ) { } 118 | 119 | ngOnInit(): void { 120 | this.refreshRunningCommands(); 121 | 122 | // Auto-refresh every 2 seconds 123 | this.refreshSubscription = interval(2000).subscribe(() => { 124 | this.refreshRunningCommands(); 125 | }); 126 | 127 | // Subscribe to running commands changes 128 | this.runningCommandsManager.runningCommands$.subscribe(commands => { 129 | this.refreshRunningCommands(); 130 | }); 131 | } 132 | 133 | ngOnDestroy(): void { 134 | if (this.refreshSubscription) { 135 | this.refreshSubscription.unsubscribe(); 136 | } 137 | } 138 | 139 | close(): void { 140 | this.activeModal.dismiss(); 141 | } 142 | 143 | private refreshRunningCommands(): void { 144 | const commands: RunningCommand[] = []; 145 | const now = Date.now(); 146 | 147 | const runningCommands = this.runningCommandsManager.getAllRunningCommands(); 148 | runningCommands.forEach((cmdInfo) => { 149 | const duration = this.formatDuration(now - cmdInfo.startTime); 150 | commands.push({ 151 | tabId: cmdInfo.tabId, 152 | command: cmdInfo.command, 153 | startTime: cmdInfo.startTime, 154 | duration 155 | }); 156 | }); 157 | 158 | this.runningCommands = commands.sort((a, b) => b.startTime - a.startTime); 159 | } 160 | 161 | private formatDuration(ms: number): string { 162 | const seconds = Math.floor(ms / 1000); 163 | const minutes = Math.floor(seconds / 60); 164 | const hours = Math.floor(minutes / 60); 165 | 166 | if (hours > 0) { 167 | return `${hours}h ${minutes % 60}m ${seconds % 60}s`; 168 | } else if (minutes > 0) { 169 | return `${minutes}m ${seconds % 60}s`; 170 | } else { 171 | return `${seconds}s`; 172 | } 173 | } 174 | 175 | getCommandPreview(command: string): string { 176 | if (command.length <= 50) { 177 | return command; 178 | } 179 | return command.substring(0, 47) + '...'; 180 | } 181 | 182 | async stopCommand(tabId: string): Promise { 183 | try { 184 | console.log(`Stopping command in terminal ${tabId}`); 185 | // Use the exec tool category to abort the command 186 | this.execToolCategory.abortCommand(parseInt(tabId)); 187 | 188 | console.log(`Command in terminal ${tabId} stopped`); 189 | } catch (error) { 190 | console.error(`Error stopping command in terminal ${tabId}:`, error); 191 | } 192 | } 193 | 194 | async stopAllCommands(): Promise { 195 | const stopPromises = this.runningCommands.map(cmd => this.stopCommand(cmd.tabId)); 196 | await Promise.all(stopPromises); 197 | } 198 | } -------------------------------------------------------------------------------- /src/components/mcpSettingsTab.component.scss: -------------------------------------------------------------------------------- 1 | // 11. Override any dynamic Angular host attribute max-width for parent containers 2 | [class^="_nghost-"], [class*="_nghost-"] { 3 | max-width: 100vw !important; 4 | width: 100vw !important; 5 | min-width: 0 !important; 6 | box-sizing: border-box !important; 7 | } 8 | // 10. Force full width for plugin page (highest specificity) 9 | settings-tab-body, [_nghost-qqi-c80], .content, .content:host, .content[ng-version], .content[ng-reflect-ng-if] { 10 | max-width: 100% !important; 11 | width: 100% !important; 12 | min-width: 0 !important; 13 | box-sizing: border-box !important; 14 | margin-left: auto !important; 15 | margin-right: auto !important; 16 | } 17 | .github-profile-link { 18 | display: flex; 19 | justify-content: center; 20 | margin-top: 1rem; 21 | } 22 | .github-btn { 23 | display: inline-flex; 24 | align-items: center; 25 | gap: 0.5rem; 26 | font-size: 1.1rem; 27 | font-weight: 600; 28 | background-color: var(--bg-tertiary); 29 | color: var(--text-accent); 30 | border: 1px solid var(--border-secondary); 31 | border-radius: 8px; 32 | padding: 0.5rem 1.2rem; 33 | box-shadow: 0 2px 8px var(--shadow-color); 34 | transition: background 0.2s, color 0.2s, box-shadow 0.2s; 35 | } 36 | .github-btn:hover { 37 | background-color: var(--border-secondary); 38 | color: #fff; 39 | box-shadow: 0 4px 16px var(--shadow-color); 40 | } 41 | // 9. Override restrictive max-width for plugin page only 42 | settings-tab-body, [_nghost-qqi-c80] { 43 | max-width: 100% !important; 44 | width: 100% !important; 45 | padding: 0 !important; 46 | } 47 | // 1. Base Variables 48 | :host { 49 | --bg-primary: #1a1d21; 50 | --bg-secondary: #2c3038; 51 | --bg-tertiary: #24272c; 52 | --text-primary: #e6e6e6; 53 | --text-secondary: #b3b3b3; 54 | --text-accent: #61afef; 55 | --border-primary: #3a3f4b; 56 | --border-secondary: #505663; 57 | --shadow-color: rgba(0, 0, 0, 0.3); 58 | --success-color: #98c379; 59 | --danger-color: #e06c75; 60 | --info-color: #56b6c2; 61 | } 62 | 63 | // 2. Main Content Layout 64 | .content { 65 | background-color: var(--bg-primary); 66 | color: var(--text-primary); 67 | padding: 2rem; 68 | width: 900px !important; 69 | max-width: 1200px !important; // Override restrictive max-width 70 | height: 100%; 71 | position: relative; 72 | display: flex; 73 | flex-direction: column; 74 | gap: 2rem; 75 | } 76 | 77 | // 3. Header 78 | .settings-header { 79 | padding-bottom: 1.5rem; 80 | border-bottom: 1px solid var(--border-primary); 81 | text-align: center; 82 | 83 | .links-container { 84 | display: flex; 85 | justify-content: center; 86 | gap: 1rem; 87 | margin-top: 1rem; 88 | } 89 | 90 | .tabby-header { 91 | font-size: 2.25rem; 92 | font-weight: 700; 93 | color: var(--text-accent); 94 | letter-spacing: 1px; 95 | text-shadow: 1px 1px 4px var(--shadow-color); 96 | } 97 | } 98 | 99 | .vscode-btn { 100 | display: inline-flex; 101 | align-items: center; 102 | gap: 0.5rem; 103 | font-size: 1.1rem; 104 | font-weight: 600; 105 | background-color: var(--bg-tertiary); 106 | color: var(--text-accent); 107 | border: 1px solid var(--border-secondary); 108 | border-radius: 8px; 109 | padding: 0.5rem 1.2rem; 110 | box-shadow: 0 2px 8px var(--shadow-color); 111 | transition: background 0.2s, color 0.2s, box-shadow 0.2s; 112 | } 113 | 114 | .vscode-btn:hover { 115 | background-color: var(--border-secondary); 116 | color: #fff; 117 | box-shadow: 0 4px 16px var(--shadow-color); 118 | } 119 | 120 | // 4. Two-Column Layout 121 | .settings-columns { 122 | display: grid; 123 | grid-template-columns: 1fr 1fr; 124 | gap: 2.5rem; 125 | flex-grow: 1; 126 | } 127 | 128 | .settings-left, 129 | .settings-right { 130 | background-color: var(--bg-secondary); 131 | padding: 2rem; 132 | border-radius: 12px; 133 | box-shadow: 0 4px 12px var(--shadow-color); 134 | display: flex; 135 | flex-direction: column; 136 | gap: 1.5rem; 137 | 138 | .header { 139 | margin-bottom: 1rem; 140 | .title { 141 | font-size: 1.5rem; 142 | font-weight: 600; 143 | color: var(--text-primary); 144 | } 145 | .description { 146 | font-size: 0.95rem; 147 | color: var(--text-secondary); 148 | } 149 | } 150 | } 151 | 152 | // 5. Form Elements 153 | .form-group { 154 | display: flex; 155 | flex-direction: column; 156 | gap: 0.5rem; 157 | 158 | label { 159 | font-weight: 500; 160 | color: var(--text-secondary); 161 | } 162 | 163 | .d-flex { 164 | display: flex; 165 | align-items: center; 166 | gap: 1rem; 167 | } 168 | 169 | input.form-control { 170 | background-color: var(--bg-tertiary); 171 | color: var(--text-primary); 172 | border: 1px solid var(--border-secondary); 173 | border-radius: 8px; 174 | padding: 0.6rem 1rem; 175 | flex-grow: 1; 176 | 177 | &:focus { 178 | border-color: var(--text-accent); 179 | box-shadow: 0 0 0 3px rgba(97, 175, 239, 0.2); 180 | outline: none; 181 | } 182 | } 183 | 184 | .text-muted { 185 | font-size: 0.85rem; 186 | color: var(--text-secondary); 187 | opacity: 0.8; 188 | } 189 | } 190 | 191 | // 6. Buttons 192 | button.btn { 193 | border: none; 194 | border-radius: 8px; 195 | padding: 0.6rem 1.2rem; 196 | font-weight: 600; 197 | cursor: pointer; 198 | transition: all 0.2s ease-in-out; 199 | display: inline-flex; 200 | align-items: center; 201 | justify-content: center; 202 | gap: 0.5rem; 203 | 204 | &.btn-secondary { 205 | background-color: var(--bg-tertiary); 206 | color: var(--text-primary); 207 | border: 1px solid var(--border-secondary); 208 | 209 | &:hover { 210 | background-color: var(--border-secondary); 211 | border-color: var(--text-accent); 212 | } 213 | } 214 | 215 | &.btn-primary { 216 | background-color: var(--text-accent); 217 | color: var(--bg-primary); 218 | 219 | &:hover { 220 | filter: brightness(1.1); 221 | } 222 | } 223 | 224 | &.btn-danger { 225 | background-color: var(--danger-color); 226 | color: var(--bg-primary); 227 | 228 | &:hover { 229 | filter: brightness(1.1); 230 | } 231 | } 232 | 233 | &.btn-sm { 234 | padding: 0.4rem 0.8rem; 235 | font-size: 0.9rem; 236 | } 237 | } 238 | 239 | // 7. Instructions Card 240 | .instructions-card { 241 | background-color: var(--bg-tertiary); 242 | border: 1px solid var(--border-primary); 243 | border-radius: 10px; 244 | padding: 1.5rem; 245 | margin-top: 1rem; 246 | 247 | .card-title { 248 | color: var(--info-color); 249 | font-weight: 600; 250 | } 251 | 252 | code { 253 | background-color: var(--bg-primary); 254 | padding: 0.2rem 0.4rem; 255 | border-radius: 4px; 256 | color: var(--success-color); 257 | } 258 | } 259 | 260 | .mcp-config-snippet { 261 | background-color: var(--bg-primary); 262 | border: 1px solid var(--border-secondary); 263 | border-radius: 8px; 264 | padding: 1rem; 265 | font-family: 'Fira Mono', monospace; 266 | white-space: pre-wrap; 267 | flex-grow: 1; 268 | } 269 | 270 | // 8. Footer Link 271 | .github-link { 272 | margin-top: auto; 273 | padding-top: 2rem; 274 | text-align: center; 275 | color: var(--text-secondary); 276 | opacity: 0.7; 277 | 278 | a { 279 | color: var(--text-secondary); 280 | text-decoration: none; 281 | &:hover { 282 | color: var(--text-accent); 283 | } 284 | } 285 | } -------------------------------------------------------------------------------- /src/tools/shell-strategy.ts: -------------------------------------------------------------------------------- 1 | import stripAnsi from 'strip-ansi'; 2 | 3 | /** 4 | * Interface for shell strategy 5 | * Defines the contract for different shell implementations 6 | */ 7 | export interface ShellStrategy { 8 | /** 9 | * Get the shell type identifier 10 | */ 11 | getShellType(): string; 12 | 13 | /** 14 | * Get the setup script for this shell type 15 | * @param startMarker The start marker for command tracking 16 | * @param endMarker The end marker for command tracking 17 | */ 18 | getSetupScript(startMarker: string, endMarker: string): string; 19 | 20 | /** 21 | * Get the command prefix for this shell type 22 | */ 23 | getCommandPrefix(): string; 24 | 25 | /** 26 | * Get the cleanup script for this shell type 27 | */ 28 | getCleanupScript(): string; 29 | } 30 | 31 | /** 32 | * Base abstract class for shell strategies 33 | */ 34 | export abstract class BaseShellStrategy implements ShellStrategy { 35 | abstract getShellType(): string; 36 | abstract getSetupScript(startMarker: string, endMarker: string): string; 37 | abstract getCleanupScript(): string; 38 | 39 | /** 40 | * Default command prefix is empty 41 | */ 42 | getCommandPrefix(): string { 43 | return ''; 44 | } 45 | } 46 | 47 | /** 48 | * Bash shell strategy 49 | */ 50 | export class BashShellStrategy extends BaseShellStrategy { 51 | getShellType(): string { 52 | return 'bash'; 53 | } 54 | 55 | getCleanupScript(): string { 56 | return `unset PROMPT_COMMAND; unset __tpc; unset __TM;`; 57 | } 58 | 59 | getSetupScript(startMarker: string, endMarker: string): string { 60 | const cleanup = this.getCleanupScript(); 61 | return `__TM=0; function __tc() { ${cleanup} }; function __tpc() { if [[ $__TM -eq 0 ]]; then local e=$?; local c=$(HISTTIMEFORMAT='' history 1 | awk '{$1=""; print substr($0,2)}'); if [[ "$c" == *"${startMarker}"* ]]; then __TM=1; echo "${endMarker}"; echo "exit_code: $e"; __tc; fi; fi }; trap - DEBUG 2>/dev/null; PROMPT_COMMAND=$(echo "$PROMPT_COMMAND" | sed 's/__tpc;//g'); PROMPT_COMMAND="__tpc;$PROMPT_COMMAND"`; 62 | } 63 | } 64 | 65 | /** 66 | * Zsh shell strategy 67 | */ 68 | export class ZshShellStrategy extends BaseShellStrategy { 69 | getShellType(): string { 70 | return 'zsh'; 71 | } 72 | 73 | getCleanupScript(): string { 74 | return `precmd_functions=(); unset __tpc; unset __TM;`; 75 | } 76 | 77 | getSetupScript(startMarker: string, endMarker: string): string { 78 | const cleanup = this.getCleanupScript(); 79 | return `__TM=0;function __tc(){${cleanup}};function __tpc(){if [[ $__TM -eq 0 ]];then local e=$?;local c=$(fc -ln -1);if [[ "$c" == *"${startMarker}"* ]];then __TM=1;echo "${endMarker}";echo "exit_code: $e";__tc;fi;fi};precmd_functions=(__tpc)`; 80 | } 81 | } 82 | 83 | /** 84 | * POSIX sh shell strategy 85 | */ 86 | export class ShShellStrategy extends BaseShellStrategy { 87 | getShellType(): string { 88 | return 'sh'; 89 | } 90 | 91 | getCleanupScript(): string { 92 | return `if [ -n "$OLD_PS1" ]; then PS1="$OLD_PS1"; unset OLD_PS1; fi; unset __tpc; rm -f "$__TF" 2>/dev/null; unset __TF;`; 93 | } 94 | 95 | getSetupScript(startMarker: string, endMarker: string): string { 96 | const cleanup = this.getCleanupScript(); 97 | return `__TF="/tmp/tabby_cmd_$$"; function __tc() { ${cleanup} }; __tpc() { local e=$?; if [[ -f "$__TF" ]]; then echo "${endMarker}"; echo "exit_code: $e"; rm -f "$__TF" 2>/dev/null; __tc; fi }; trap 'if [[ -f "$__TF" ]]; then echo "${endMarker}"; echo "exit_code: $?"; rm -f "$__TF" 2>/dev/null; __tc; fi' EXIT; OLD_PS1="$PS1"; PS1='$(__tpc)'$PS1`; 98 | } 99 | 100 | getCommandPrefix(): string { 101 | return 'touch "$__TF"; '; 102 | } 103 | } 104 | 105 | /** 106 | * Unknown shell strategy - fallback to sh 107 | */ 108 | export class UnknownShellStrategy extends ShShellStrategy { 109 | getShellType(): string { 110 | return 'unknown'; 111 | } 112 | } 113 | 114 | /** 115 | * Shell context class that manages shell strategies 116 | */ 117 | export class ShellContext { 118 | private strategies: Map = new Map(); 119 | private defaultStrategy: ShellStrategy; 120 | 121 | constructor() { 122 | // Register built-in strategies 123 | const bashStrategy = new BashShellStrategy(); 124 | const zshStrategy = new ZshShellStrategy(); 125 | const shStrategy = new ShShellStrategy(); 126 | const unknownStrategy = new UnknownShellStrategy(); 127 | 128 | this.registerStrategy(bashStrategy); 129 | this.registerStrategy(zshStrategy); 130 | this.registerStrategy(shStrategy); 131 | this.registerStrategy(unknownStrategy); 132 | 133 | // Set default strategy 134 | this.defaultStrategy = unknownStrategy; 135 | } 136 | 137 | /** 138 | * Register a new shell strategy 139 | * @param strategy The shell strategy to register 140 | */ 141 | registerStrategy(strategy: ShellStrategy): void { 142 | this.strategies.set(strategy.getShellType(), strategy); 143 | } 144 | 145 | /** 146 | * Get a shell strategy by type 147 | * @param shellType The shell type to get 148 | * @returns The shell strategy for the given type, or the default strategy if not found 149 | */ 150 | getStrategy(shellType: string): ShellStrategy { 151 | const normalizedType = shellType.trim().toLowerCase(); 152 | return this.strategies.get(normalizedType) || this.defaultStrategy; 153 | } 154 | 155 | /** 156 | * Generate shell detection script 157 | * @returns Shell detection script 158 | */ 159 | getShellDetectionScript(): string { 160 | const bashType = new BashShellStrategy().getShellType(); 161 | const zshType = new ZshShellStrategy().getShellType(); 162 | const shType = new ShShellStrategy().getShellType(); 163 | const unknownType = new UnknownShellStrategy().getShellType(); 164 | 165 | return `if [ -n "$BASH_VERSION" ]; then echo "SHELL_TYPE=${bashType}"; elif [ -n "$ZSH_VERSION" ]; then echo "SHELL_TYPE=${zshType}"; elif [ "$(basename "$0")" = "sh" ] || [ "$0" = "-sh" ] || [ "$0" = "/bin/sh" ] || [ -n "$PS1" ]; then echo "SHELL_TYPE=${shType}"; else echo "SHELL_TYPE=${unknownType}"; fi`; 166 | } 167 | 168 | /** 169 | * Detect shell type from terminal output 170 | * @param terminalOutput The terminal output containing shell type 171 | * @returns The detected shell type 172 | */ 173 | detectShellType(terminalOutput: string): string | null { 174 | try { 175 | if (!terminalOutput || typeof terminalOutput !== 'string') { 176 | console.warn('[DEBUG] Invalid terminal output provided for shell detection'); 177 | return null; 178 | } 179 | 180 | const lines = stripAnsi(terminalOutput).split('\n'); 181 | 182 | if (!lines || lines.length === 0) { 183 | console.warn('[DEBUG] No lines found in terminal output'); 184 | return null; 185 | } 186 | 187 | // Check the last 3 lines for SHELL_TYPE= pattern 188 | for (let i = Math.max(0, lines.length - 3); i < lines.length; i++) { 189 | const line = lines[i]; 190 | if (line && line.startsWith('SHELL_TYPE=')) { 191 | const parts = line.split('='); 192 | if (parts.length >= 2) { 193 | const shellType = parts[1].trim(); 194 | if (shellType) { 195 | console.log(`[DEBUG] Raw detected shell type: "${shellType}"`); 196 | return shellType; 197 | } 198 | } 199 | } 200 | } 201 | 202 | console.warn('[DEBUG] No SHELL_TYPE= pattern found in terminal output'); 203 | return null; 204 | } catch (error) { 205 | console.error('[DEBUG] Error detecting shell type:', error); 206 | return null; 207 | } 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /src/services/mcpService.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | 3 | import { ExecToolCategory } from '../tools/terminal'; 4 | import { ToolCategory } from '../type/types'; 5 | import { VSCodeToolCategory } from '../tools/vscode-tool-category'; 6 | 7 | import express, { Request, Response } from "express"; 8 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 9 | import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; 10 | import { z } from 'zod'; 11 | import { IncomingMessage, ServerResponse } from 'http'; 12 | import { ConfigService } from 'tabby-core'; 13 | import * as http from 'http'; 14 | import { McpLoggerService } from './mcpLogger.service'; 15 | import { log } from 'console'; 16 | 17 | /** 18 | * The main MCP server service for Tabby 19 | * Combines both MCP and HTTP server functionality 20 | */ 21 | @Injectable({ providedIn: 'root' }) 22 | export class McpService { 23 | private server: McpServer; 24 | private transports: { [sessionId: string]: SSEServerTransport } = {}; 25 | private app: express.Application; 26 | private isRunning = false; 27 | private toolCategories: ToolCategory[] = []; 28 | private httpServer: http.Server; 29 | 30 | constructor( 31 | public config: ConfigService, 32 | private execToolCategory: ExecToolCategory, 33 | private vscodeToolCategory: VSCodeToolCategory, 34 | private logger: McpLoggerService 35 | ) { 36 | // Initialize MCP Server 37 | this.server = new McpServer({ 38 | name: "Tabby", 39 | version: "1.0.0" 40 | }); 41 | 42 | // Register tool categories 43 | // this.registerToolCategory(this.tabToolCategory); 44 | this.registerToolCategory(this.execToolCategory); 45 | this.registerToolCategory(this.vscodeToolCategory); 46 | 47 | // Configure Express 48 | this.configureExpress(); 49 | } 50 | 51 | /** 52 | * Register a tool category with the MCP server 53 | */ 54 | private registerToolCategory(category: ToolCategory): void { 55 | this.toolCategories.push(category); 56 | 57 | // Register all tools from the category 58 | category.mcpTools.forEach(tool => { 59 | // For tools with empty schemas, we keep the schema as-is 60 | // MCP SDK will handle it appropriately 61 | this.server.tool( 62 | tool.name, 63 | tool.description, 64 | tool.schema as z.ZodRawShape, 65 | tool.handler 66 | ); 67 | this.logger.info(`Registered tool: ${tool.name} from category: ${category.name} with schema: ${JSON.stringify(tool.schema)}`); 68 | }); 69 | } 70 | 71 | /** 72 | * Configure Express server 73 | */ 74 | private configureExpress(): void { 75 | this.app = express(); 76 | // this.app.use(cors()); 77 | // DO NOT ENABLE express.json() - MCP server handles JSON parsing 78 | // IT WILL CAUSE PROBLEMS : MCP: Failed to reload client: Error POSTing to endpoint (HTTP 400): InternalServerError: stream is not readable 79 | // this.app.use(express.json()); 80 | 81 | // Health check endpoint 82 | this.app.get('/health', (_, res) => { 83 | res.status(200).send('OK'); 84 | }); 85 | 86 | this.app.get("/sse", async (req: Request, res: Response) => { 87 | this.logger.info("Establishing new SSE connection"); 88 | const transport = new SSEServerTransport( 89 | "/messages", 90 | res as unknown as ServerResponse, 91 | ); 92 | this.logger.info(`New SSE connection established for sessionId ${transport.sessionId}`); 93 | 94 | this.transports[transport.sessionId] = transport; 95 | res.on("close", () => { 96 | delete this.transports[transport.sessionId]; 97 | }); 98 | 99 | await this.server.connect(transport); 100 | }); 101 | 102 | this.app.post("/messages", async (req: Request, res: Response) => { 103 | const sessionId = req.query.sessionId as string; 104 | if (!this.transports[sessionId]) { 105 | res.status(400).send(`No transport found for sessionId ${sessionId}`); 106 | return; 107 | } 108 | this.logger.info(`Received message for sessionId ${sessionId}`); 109 | await this.transports[sessionId].handlePostMessage(req, res); 110 | }); 111 | 112 | // Configure API endpoints for tool access via HTTP 113 | this.configureToolEndpoints(); 114 | } 115 | 116 | /** 117 | * Configure API endpoints for tool access via HTTP 118 | */ 119 | private configureToolEndpoints(): void { 120 | console.log('Configuring tool endpoints...'); 121 | // Add API endpoints for each tool for direct HTTP access 122 | this.toolCategories.forEach(category => { 123 | category.mcpTools.forEach(tool => { 124 | console.log(`Configuring endpoint for tool: ${tool.name}`); 125 | this.app.post(`/api/tool/${tool.name}`, express.json(), async (req: Request, res: Response) => { 126 | try { 127 | // Explicitly cast the body to any to match the handler's expected parameter type 128 | const params: any = req.body; 129 | this.logger.info(`Executing tool ${tool.name} with params: ${JSON.stringify(params)}`); 130 | const result = await tool.handler(params, {}); 131 | res.json(result); 132 | } catch (error) { 133 | this.logger.error(`Error executing tool ${tool.name}:`, error); 134 | res.status(500).json({ error: error.message }); 135 | } 136 | }); 137 | }); 138 | }); 139 | } 140 | 141 | /** 142 | * Initialize the MCP service 143 | */ 144 | public initialize(port: number): Promise { 145 | return new Promise((resolve, reject) => { 146 | try { 147 | // Create and start the HTTP server 148 | const httpServer = http.createServer(this.app); 149 | 150 | // Start the server 151 | httpServer.listen(port, () => { 152 | this.logger.info(`[MCP Service] MCP server listening on port ${port}`); 153 | this.isRunning = true; 154 | this.httpServer = httpServer; 155 | resolve(); 156 | }); 157 | 158 | // Handle server errors 159 | httpServer.on('error', (err) => { 160 | this.logger.error('[MCP Service] MCP server error:', err); 161 | this.isRunning = false; 162 | reject(err); 163 | }); 164 | } catch (err) { 165 | this.logger.error('[MCP Service] Failed to initialize MCP server:', err); 166 | this.isRunning = false; 167 | reject(err); 168 | } 169 | }); 170 | } 171 | 172 | /** 173 | * Start the MCP server 174 | * This is a convenience method for the UI 175 | */ 176 | public async startServer(port: number): Promise { 177 | return this.initialize(port); 178 | } 179 | 180 | /** 181 | * Stop the MCP service 182 | */ 183 | public async stop(): Promise { 184 | if (!this.isRunning) { 185 | this.logger.info('[MCP Service] Not running'); 186 | return; 187 | } 188 | 189 | try { 190 | // Close all active transports 191 | Object.values(this.transports).forEach(transport => { 192 | transport.close(); 193 | }); 194 | 195 | if (this.httpServer) { 196 | this.httpServer.close(); 197 | } 198 | 199 | this.isRunning = false; 200 | this.logger.info('[MCP Service] MCP server stopped'); 201 | } catch (err) { 202 | this.logger.error('[MCP Service] Failed to stop MCP server:', err); 203 | throw err; 204 | } 205 | } 206 | 207 | /** 208 | * Stop the MCP server 209 | * This is a convenience method for the UI 210 | */ 211 | public async stopServer(): Promise { 212 | return this.stop(); 213 | } 214 | 215 | /** 216 | * Check if the MCP server is running 217 | * @returns true if the server is running, false otherwise 218 | */ 219 | public isServerRunning(): boolean { 220 | return this.isRunning; 221 | } 222 | } 223 | 224 | // Export types for tools 225 | export * from '../type/types'; -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🚀 Tabby VSCode Agent 2 | 3 | > The Tabby VSCode Agent is an advanced plugin for [Tabby Terminal](https://github.com/Eugeny/tabby), designed to seamlessly integrate AI-powered terminal control and automation directly within VS Code. Building upon the foundations of the original [Tabby MCP Server](https://github.com/thuanpham582002/tabby-mcp-server), this improved version offers enhanced functionality, quicker speeds, and a more refined user experience tailored for VS Code users. 4 | 5 | ## 🆕 What's New in 1.0.8 (21-07-2025) 6 | 7 | ### Added 8 | - VSCode Extension Support, listens to http port. 9 | - Fixed URL Opening links 10 | - Increased speed of reading SSH sessions 11 | - Fixed issue with opening VSCode via non extension 12 | - Fixed UI for Command History 13 | - Enhanced support for VS Code integration 14 | - Added support for VSCode Extension 15 | - Copilot agent chat can now be opened directly from the navbar 16 | - New and improved UI elements for a more modern look 17 | - Updated and fixed navbar for better navigation and stability 18 | - Improved command execution speeds and responsiveness 19 | - Quick access to settings and chat from the main interface 20 | - More robust error handling and feedback for users 21 | - Optimized MCP server communication for lower latency 22 | - Improved hotkey support and customization 23 | - Better handling of long-running commands and output 24 | 25 | ### Fixed 26 | - Navbar rendering issues in some VS Code versions 27 | - UI glitches in command dialogs and modals 28 | - Minor bugs in command history and output storage 29 | - Various performance and stability improvements 30 | 31 | ## 📹 Video Demonstrations 32 | 33 | Witness the Tabby VSCode Agent in action with these comprehensive video demonstrations: 34 | 35 | ### VSCode Agent in Action 36 | [![Tabby VSCode Agent Demo](assets/gifs/vscodeagent.gif)](assets/gifs/vscodeagent.gif) 37 | 38 | ### Tabby Settings Overview 39 | [![Tabby Settings Demo](assets/gifs/tabbysettings.gif)](assets/gifs/tabbysettings.gif) 40 | 41 | ## ✨ Key Features 42 | 43 | - 🤖 **AI Integration**: Effortlessly connect AI assistants to your terminal for intelligent command execution and automation. 44 | - 🔌 **Built-in MCP Server**: Features a robust Model Context Protocol (MCP) server implementation for reliable communication. 45 | - 🚀 **Deep VS Code Integration**: 46 | - **Open Chat from Navbar**: Instantly open the Copilot chat window directly from the Tabby navigation bar within VS Code. 47 | - **Optimised Settings & Logins**: Enjoy a redesigned settings interface and streamlined login processes for a smoother workflow. 48 | - **Enhanced Speed**: Experience significantly quicker response times and overall performance. 49 | - 📦 **Bundled Stdio Server**: Includes `tabby-mcp-stdio` for a stable and efficient connection with VS Code. 50 | - 🖥️ **Terminal Control**: Empower AI to execute commands, read terminal output, and manage your sessions. 51 | - 🔍 **Session Management**: View and manage your SSH sessions directly from the plugin. 52 | - 🚫 **Command Abort**: Safely abort any running commands with ease. 53 | - 📋 **Buffer Access**: Retrieve terminal buffer content with flexible line range options. 54 | - 🔒 **Pair Programming Mode**: An optional safety feature that requires confirmation before AI executes commands, ensuring you maintain control. 55 | - 📊 **Command History**: Keep track of and review all previously executed commands. 56 | - 🔄 **Command Output Storage**: Access complete command outputs with convenient pagination. 57 | 58 | ## 🔧 Installation 59 | 60 | ### Install from Tabby Plugin Store 61 | 62 | 1. Open Tabby settings and navigate to **Plugins → MCP**. 63 | 2. Locate and click "Install" on the **Tabby VSCode Agent** plugin. 64 | 3. Restart Tabby to finalise the installation. 65 | 4. Proceed to configure VS Code with the MCP Server as detailed below. 66 | 67 | ## 🚀 Quick Start 68 | 69 | 1. Ensure the plugin is installed using one of the methods above. 70 | 2. Launch Tabby and go to **Settings → Copilot**. 71 | 3. Within the settings page, you'll find a collapsible **"Instructions"** section. This provides detailed, step-by-step guidance on how to integrate the agent with VS Code. 72 | 4. Configure the MCP server port (default: `3001`). 73 | 5. Toggle "Start on Boot" if you wish for the server to automatically launch with Tabby. 74 | 6. Connect your preferred AI client to the MCP server. A list of supported clients can be found at [https://modelcontextprotocol.io/clients](https://modelcontextprotocol.io/clients). 75 | 76 | ## 💻 Usage Examples 77 | 78 | ### Connecting an AI to Control Your Terminal 79 | 80 | 1. Start Tabby with the Tabby VSCode Agent plugin enabled. 81 | 2. Configure your AI client to connect to the MCP server (refer to [Connecting to MCP](#-connecting-to-mcp) for details). 82 | 3. Instruct your AI assistant to run commands or manage your terminal sessions. 83 | 84 | Example AI prompt: 85 | ``` 86 | Connect to my Tabby VSCode Agent and list all available terminal sessions. 87 | Then execute the command "ls -la" in the first available terminal. 88 | ``` 89 | 90 | ## 🔗 Connecting to MCP 91 | 92 | ### HTTP Server (Recommended for most clients) 93 | 94 | To configure AI clients to use your MCP server, add the following to your `mcp.json` file: 95 | 96 | ```json 97 | { 98 | "servers": { 99 | "tabby": { 100 | "url": "http://localhost:3001/sse", 101 | "type": "http" 102 | } 103 | } 104 | } 105 | ``` 106 | 107 | ### Stdio Server (for VS Code) 108 | 109 | For a more robust integration with VS Code, you can utilise the bundled `stdio` server. 110 | 111 | 1. Navigate to **Settings → Copilot** in Tabby. 112 | 2. Under the **Stdio Server** section, you will find the full path to the server script. 113 | 3. Click the "Copy" button to copy the path to your clipboard. 114 | 4. In your VS Code `mcp.json`, configure the server as shown below, pasting the copied path: 115 | 116 | ```json 117 | { 118 | "servers": { 119 | "tabby-stdio": { 120 | "type": "stdio", 121 | "command": "node", 122 | "args": [""] 123 | } 124 | } 125 | } 126 | ``` 127 | 128 | ### Pair Programming Mode 129 | 130 | The plugin includes a "Pair Programming Mode" to enhance safety when AI assistants control your terminal: 131 | 132 | - **Confirmation Dialogue**: Prompts the user for confirmation before executing commands. 133 | - **Auto Focus Terminal**: Automatically focuses the terminal when commands are executed. 134 | - **Command Rejection**: Provides the ability to reject commands with feedback. 135 | 136 | To enable Pair Programming Mode: 137 | 138 | 1. Go to Tabby settings → Copilot. 139 | 2. Toggle "Enable Pair Programming Mode". 140 | 3. Configure additional safety options as required. 141 | 142 | ## 📚 API Reference 143 | 144 | ### Available Tools 145 | 146 | | Tool | Description | Parameters | 147 | |------|-------------|------------| 148 | | `open-vscode-chat` | Opens the VSCode chat window. | None | 149 | | `get_ssh_session_list` | Get list of all terminal sessions | None | 150 | | `exec_command` | Execute a command in terminal | `command`, `tabId`, `commandExplanation` | 151 | | `get_terminal_buffer` | Get terminal content | `tabId`, `startLine`, `endLine` | 152 | | `get_command_output` | Retrieve complete command output | `outputId`, `startLine`, `maxLines` | 153 | 154 | ## 🤝 Contributing 155 | 156 | Contributions are highly encouraged! Here's how you can contribute to the Tabby VSCode Agent: 157 | 158 | 1. Fork the repository. 159 | 2. Create a new feature branch (`git checkout -b feature/your-feature`). 160 | 3. Commit your changes (`git commit -m 'Add your feature'`). 161 | 4. Push your branch to the origin (`git push origin feature/your-feature`). 162 | 5. Open a Pull Request. 163 | 164 | Please refer to the [contributing guidelines](CONTRIBUTING.md) for more detailed information. 165 | 166 | ### Development Workflow 167 | 168 | 1. Clone the repository and install dependencies: 169 | ```bash 170 | git clone https://github.com/SteffMet/tabby-vscode-agent.git 171 | cd tabby-vscode-agent 172 | npm install 173 | ``` 174 | 175 | 2. Make your desired changes to the codebase. 176 | 177 | 3. Build the plugin: 178 | ```bash 179 | npm run build 180 | ``` 181 | 182 | 4. Test the plugin with Tabby: 183 | ```bash 184 | npm run deploy 185 | ``` 186 | 187 | ## 📝 Licence 188 | 189 | This project is licensed under the MIT Licence - see the [LICENCE](LICENCE) file for details. 190 | 191 | --- 192 | 193 |

194 | Made with ❤️ by SteffMet 195 |

-------------------------------------------------------------------------------- /src/components/confirmCommandDialog.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input, NgModule, AfterViewInit, HostListener, OnDestroy, ViewChild, ElementRef } from '@angular/core'; 2 | import { FormsModule } from '@angular/forms'; 3 | import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'; 4 | import { CommonModule } from '@angular/common'; 5 | import { HotkeysService } from 'tabby-core'; 6 | import { MinimizedDialogManagerService } from '../services/minimizedDialogManager.service'; 7 | 8 | /** 9 | * Dialog component for confirming command execution 10 | */ 11 | @Component({ 12 | template: require('./confirmCommandDialog.component.pug').default, 13 | }) 14 | export class ConfirmCommandDialogComponent implements AfterViewInit, OnDestroy { 15 | @Input() command: string; 16 | @Input() tabId: number; 17 | @Input() tabTitle: string; 18 | @Input() commandExplanation: string; 19 | 20 | // Flag to show/hide reject input form 21 | showRejectInput = false; 22 | 23 | // Rejection message 24 | rejectMessage: string = ''; 25 | 26 | // Reference to the reject message textarea 27 | @ViewChild('rejectMessageTextarea') rejectMessageTextareaRef: ElementRef; 28 | 29 | // Track if hotkeys are paused 30 | private hotkeysPaused = false; 31 | 32 | // Dialog ID for minimize/restore functionality 33 | public dialogId: string = ''; 34 | 35 | constructor( 36 | public modal: NgbActiveModal, 37 | private hotkeysService: HotkeysService, 38 | private minimizedDialogManager: MinimizedDialogManagerService 39 | ) { 40 | this.dialogId = this.minimizedDialogManager.generateDialogId(); 41 | } 42 | 43 | /** 44 | * After view init, pause hotkeys and set up focus management 45 | */ 46 | ngAfterViewInit(): void { 47 | setTimeout(() => { 48 | // Pause hotkeys while dialog is open 49 | this.pauseHotkeys(); 50 | 51 | // Focus the dialog element to capture keyboard events 52 | if (this.modal) { 53 | const modalElement = document.querySelector('.modal-content') as HTMLElement; 54 | if (modalElement) { 55 | // Add tabindex to make the modal focusable 56 | if (!modalElement.hasAttribute('tabindex')) { 57 | modalElement.setAttribute('tabindex', '-1'); 58 | } 59 | 60 | // Add focused class for visual indication 61 | modalElement.classList.add('focused'); 62 | 63 | // Focus the modal 64 | modalElement.focus(); 65 | 66 | // Add event listener to prevent focus from leaving the modal 67 | document.addEventListener('focusin', this.keepFocusInModal); 68 | } 69 | } 70 | }, 100); 71 | } 72 | 73 | /** 74 | * Event handler to keep focus inside the modal 75 | */ 76 | private keepFocusInModal = (event: FocusEvent) => { 77 | const modalElement = document.querySelector('.modal-content') as HTMLElement; 78 | if (modalElement && !modalElement.contains(event.target as Node)) { 79 | // If focus is outside the modal, bring it back 80 | modalElement.focus(); 81 | } 82 | } 83 | 84 | /** 85 | * Pause hotkeys when the dialog is focused 86 | */ 87 | pauseHotkeys(): void { 88 | if (!this.hotkeysPaused) { 89 | this.hotkeysService.disable(); 90 | this.hotkeysPaused = true; 91 | } 92 | } 93 | 94 | /** 95 | * Restore hotkeys when the dialog is closed 96 | */ 97 | resumeHotkeys(): void { 98 | if (this.hotkeysPaused) { 99 | this.hotkeysService.enable(); 100 | this.hotkeysPaused = false; 101 | } 102 | } 103 | 104 | /** 105 | * Handle escape key to close dialog 106 | */ 107 | @HostListener('document:keydown.escape') 108 | onEscapePressed(): void { 109 | const modalElement = document.querySelector('.modal-content') as HTMLElement; 110 | if (modalElement) { 111 | if (document.activeElement !== modalElement) { 112 | modalElement.focus(); 113 | return; 114 | } 115 | } 116 | this.cancel(); 117 | } 118 | 119 | /** 120 | * Handle enter key to confirm 121 | */ 122 | @HostListener('document:keydown.enter', ['$event']) 123 | onEnterPressed(event: KeyboardEvent): void { 124 | // Only handle if not in textarea 125 | if (!(event.target instanceof HTMLTextAreaElement)) { 126 | if (this.showRejectInput) { 127 | // If reject form is shown, confirm rejection 128 | if (this.rejectMessage.trim()) { 129 | this.reject(); 130 | } 131 | } else { 132 | // Otherwise confirm execution 133 | this.confirm(); 134 | } 135 | } 136 | } 137 | 138 | /** 139 | * Handle 'r' key to show reject form 140 | */ 141 | @HostListener('document:keydown.r', ['$event']) 142 | onRKeyPressed(event: KeyboardEvent): void { 143 | // Only handle if not in textarea and reject form not shown 144 | if (!(event.target instanceof HTMLTextAreaElement) && !this.showRejectInput) { 145 | this.showRejectForm(); 146 | } 147 | } 148 | 149 | /** 150 | * Handle keydown events in the textarea 151 | * @param event Keyboard event 152 | */ 153 | onTextareaKeyDown(event: KeyboardEvent): void { 154 | // Handle Shift+Enter to add a new line 155 | if (event.key === 'Enter' && event.shiftKey) { 156 | // Let the default behavior happen (add a new line) 157 | return; 158 | } 159 | 160 | // Handle Enter to submit the form 161 | if (event.key === 'Enter' && !event.shiftKey) { 162 | event.preventDefault(); 163 | this.reject(); 164 | } 165 | } 166 | 167 | /** 168 | * Confirm command execution 169 | */ 170 | confirm(): void { 171 | this.resumeHotkeys(); 172 | this.modal.close({ confirmed: true }); 173 | } 174 | 175 | /** 176 | * Show the reject form 177 | */ 178 | showRejectForm(): void { 179 | this.showRejectInput = true; 180 | 181 | // Focus the textarea after it's shown 182 | setTimeout(() => { 183 | if (this.rejectMessageTextareaRef?.nativeElement) { 184 | this.rejectMessageTextareaRef.nativeElement.focus(); 185 | } 186 | }, 100); 187 | } 188 | 189 | /** 190 | * Reject command execution with a reason 191 | */ 192 | reject(): void { 193 | if (!this.rejectMessage.trim()) { 194 | // If no reason provided, ask for one 195 | alert('Please provide a reason for rejection.'); 196 | return; 197 | } 198 | 199 | this.resumeHotkeys(); 200 | this.modal.close({ 201 | confirmed: false, 202 | rejected: true, 203 | rejectMessage: this.rejectMessage 204 | }); 205 | } 206 | 207 | /** 208 | * Minimize the dialog 209 | */ 210 | minimize(): void { 211 | console.log('Minimizing confirm command dialog'); 212 | 213 | // We need to get the promise resolver from the DialogManagerService before dismissing 214 | // Since we can't access it directly, we'll use a different approach 215 | // Store a temporary reference that the DialogManagerService can access 216 | (this.modal as any)._mcpPromiseResolver = null; // Will be set by DialogManagerService 217 | 218 | // Create minimized dialog object 219 | const minimizedDialog = { 220 | id: this.dialogId, 221 | title: `Command: ${this.command.length > 40 ? this.command.substring(0, 40) + '...' : this.command}`, 222 | component: ConfirmCommandDialogComponent, 223 | instance: this, 224 | modalRef: this.modal, 225 | timestamp: Date.now() 226 | // promiseResolver will be set by DialogManagerService 227 | }; 228 | 229 | // Add to minimized dialogs 230 | this.minimizedDialogManager.minimizeDialog(minimizedDialog); 231 | 232 | // Dismiss the modal with 'minimized' reason 233 | this.resumeHotkeys(); 234 | this.modal.dismiss('minimized'); 235 | } 236 | 237 | /** 238 | * Cancel command execution 239 | */ 240 | cancel(): void { 241 | this.resumeHotkeys(); 242 | this.modal.close({ confirmed: false }); 243 | } 244 | 245 | /** 246 | * Clean up when component is destroyed 247 | */ 248 | ngOnDestroy(): void { 249 | this.resumeHotkeys(); 250 | 251 | // Remove the focus event listener 252 | document.removeEventListener('focusin', this.keepFocusInModal); 253 | 254 | // Remove focused class from modal if it exists 255 | const modalElement = document.querySelector('.modal-content') as HTMLElement; 256 | if (modalElement) { 257 | modalElement.classList.remove('focused'); 258 | } 259 | } 260 | } 261 | 262 | /** 263 | * Module for ConfirmCommandDialogComponent 264 | * This allows the component to be used with NgModel 265 | */ 266 | @NgModule({ 267 | imports: [ 268 | CommonModule, 269 | FormsModule, 270 | NgbModule 271 | ], 272 | declarations: [ 273 | ConfirmCommandDialogComponent 274 | ], 275 | exports: [ 276 | ConfirmCommandDialogComponent 277 | ], 278 | // HotkeysService is provided at the root level 279 | }) 280 | export class ConfirmCommandDialogModule { } 281 | -------------------------------------------------------------------------------- /src/tools/terminal.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { AppService, BaseTabComponent, ConfigService, HostWindowService, SplitTabComponent } from 'tabby-core'; 3 | import { BaseTerminalTabComponent, XTermFrontend } from 'tabby-terminal'; 4 | import { BaseToolCategory } from './base-tool-category'; 5 | import { SerializeAddon } from '@xterm/addon-serialize'; 6 | import { BehaviorSubject } from 'rxjs'; 7 | import { map } from 'rxjs/operators'; 8 | import { ShellContext } from './shell-strategy'; 9 | import { McpLoggerService } from '../services/mcpLogger.service'; 10 | import { CommandOutputStorageService } from '../services/commandOutputStorage.service'; 11 | import { CommandHistoryManagerService } from '../services/commandHistoryManager.service'; 12 | import { DialogService } from '../services/dialog.service'; 13 | import { RunningCommandsManagerService } from '../services/runningCommandsManager.service'; 14 | import { 15 | SshSessionListTool, 16 | ExecCommandTool, 17 | GetTerminalBufferTool, 18 | GetCommandOutputTool, 19 | OpenCopilotTool 20 | } from './terminal/'; 21 | 22 | /** 23 | * Interface for terminal tab component with ID 24 | */ 25 | export interface BaseTerminalTabComponentWithId { 26 | id: number; 27 | tabParent: BaseTabComponent; 28 | tab: BaseTerminalTabComponent; 29 | } 30 | 31 | /** 32 | * Interface for tracking active command 33 | */ 34 | export interface ActiveCommand { 35 | tabId: number; 36 | command: string; 37 | timestamp: number; 38 | startMarker: string; 39 | endMarker: string; 40 | abort: () => void; 41 | } 42 | 43 | /** 44 | * Terminal execution tool category 45 | * Provides tools for terminal commands execution and SSH session management 46 | */ 47 | @Injectable({ providedIn: 'root' }) 48 | export class ExecToolCategory extends BaseToolCategory { 49 | name: string = 'exec'; 50 | 51 | // Track active commands per session (sessionId -> ActiveCommand) 52 | private _activeCommands = new Map(); 53 | private _activeCommandsSubject = new BehaviorSubject>(new Map()); 54 | 55 | // Observable for UI to subscribe to 56 | public readonly activeCommands$ = this._activeCommandsSubject.asObservable(); 57 | 58 | // Legacy observable for backward compatibility (returns any active command) 59 | public readonly activeCommand$ = this._activeCommandsSubject.asObservable().pipe( 60 | map(commands => commands.size > 0 ? Array.from(commands.values())[0] : null) 61 | ); 62 | 63 | // Shell context for managing different shell types 64 | public shellContext = new ShellContext(); 65 | 66 | constructor( 67 | private app: AppService, 68 | logger: McpLoggerService, 69 | private config: ConfigService, 70 | private dialogService: DialogService, 71 | private commandHistoryManager: CommandHistoryManagerService, 72 | private runningCommandsManager: RunningCommandsManagerService 73 | ) { 74 | super(logger); 75 | 76 | // Log discovered terminal sessions for debugging 77 | this.findAndSerializeTerminalSessions().forEach(session => { 78 | this.logger.debug(`Found session: ${session.id}, ${session.tab.title}`); 79 | }); 80 | 81 | // Initialize and register all tools 82 | this.initializeTools(); 83 | } 84 | 85 | /** 86 | * Initialize and register all tools 87 | */ 88 | private initializeTools(): void { 89 | // Create shared storage service for command outputs 90 | const commandOutputStorage = new CommandOutputStorageService(this.logger); 91 | 92 | // Create tool instances 93 | const sshSessionListTool = new SshSessionListTool(this, this.logger); 94 | const execCommandTool = new ExecCommandTool( 95 | this, 96 | this.logger, 97 | this.config, 98 | this.dialogService, 99 | this.app, 100 | this.runningCommandsManager, 101 | commandOutputStorage, 102 | this.commandHistoryManager 103 | ); 104 | const getTerminalBufferTool = new GetTerminalBufferTool(this, this.logger); 105 | const getCommandOutputTool = new GetCommandOutputTool(this.logger, commandOutputStorage); 106 | const openCopilotTool = new OpenCopilotTool(this.app, this.logger); 107 | 108 | // Register tools 109 | this.registerTool(sshSessionListTool.getTool()); 110 | this.registerTool(execCommandTool.getTool()); 111 | this.registerTool(getTerminalBufferTool.getTool()); 112 | this.registerTool(getCommandOutputTool.getTool()); 113 | this.registerTool(openCopilotTool.getTool()); 114 | } 115 | 116 | /** 117 | * Get current active command (legacy - returns first active command) 118 | */ 119 | public get activeCommand(): ActiveCommand | null { 120 | return this._activeCommands.size > 0 ? Array.from(this._activeCommands.values())[0] : null; 121 | } 122 | 123 | /** 124 | * Get active command for specific session 125 | */ 126 | public getActiveCommand(sessionId: number): ActiveCommand | null { 127 | return this._activeCommands.get(sessionId) || null; 128 | } 129 | 130 | /** 131 | * Set active command for a session and notify subscribers 132 | */ 133 | public setActiveCommand(command: ActiveCommand | null): void { 134 | if (command) { 135 | this._activeCommands.set(command.tabId, command); 136 | this.logger.debug(`Active command set for session ${command.tabId}: ${command.command}`); 137 | } else { 138 | // Legacy behavior - if command is null, clear the first active command 139 | if (this._activeCommands.size > 0) { 140 | const firstSessionId = Array.from(this._activeCommands.keys())[0]; 141 | this._activeCommands.delete(firstSessionId); 142 | this.logger.debug(`Active command cleared for session ${firstSessionId}`); 143 | } 144 | } 145 | this._activeCommandsSubject.next(new Map(this._activeCommands)); 146 | } 147 | 148 | /** 149 | * Clear active command for specific session 150 | */ 151 | public clearActiveCommand(sessionId: number): void { 152 | if (this._activeCommands.has(sessionId)) { 153 | this._activeCommands.delete(sessionId); 154 | this._activeCommandsSubject.next(new Map(this._activeCommands)); 155 | this.logger.debug(`Active command cleared for session ${sessionId}`); 156 | } 157 | } 158 | 159 | /** 160 | * Abort command for specific session 161 | */ 162 | public abortCommand(sessionId: number): void { 163 | const activeCommand = this._activeCommands.get(sessionId); 164 | if (activeCommand) { 165 | // Find the terminal session for this command 166 | const sessions = this.findAndSerializeTerminalSessions(); 167 | const session = sessions.find(s => s.id === sessionId); 168 | 169 | if (session) { 170 | // Send Ctrl+C to interrupt the command 171 | this.logger.debug(`Sending Ctrl+C to abort command in session ${sessionId}: ${activeCommand.command}`); 172 | session.tab.sendInput('\x03'); // Ctrl+C 173 | } 174 | 175 | // Call the abort handler which sets the aborted flag 176 | activeCommand.abort(); 177 | 178 | // Remove from active commands 179 | this._activeCommands.delete(sessionId); 180 | this._activeCommandsSubject.next(new Map(this._activeCommands)); 181 | this.logger.debug(`Command aborted for session ${sessionId}`); 182 | } 183 | } 184 | 185 | /** 186 | * Abort the current command if any (legacy method) 187 | */ 188 | public abortCurrentCommand(): void { 189 | if (this._activeCommands.size > 0) { 190 | const firstSessionId = Array.from(this._activeCommands.keys())[0]; 191 | this.abortCommand(firstSessionId); 192 | } 193 | } 194 | 195 | /** 196 | * Find all terminal sessions and map them to a serializable format 197 | * @returns Array of terminal sessions with IDs 198 | */ 199 | public findAndSerializeTerminalSessions(): BaseTerminalTabComponentWithId[] { 200 | const sessions: BaseTerminalTabComponentWithId[] = []; 201 | let id = 0; 202 | this.app.tabs.forEach((tab, tabIdx) => { 203 | if (tab instanceof BaseTerminalTabComponent) { 204 | sessions.push({ 205 | id: id++, 206 | tabParent: tab, 207 | tab: tab as BaseTerminalTabComponent 208 | }); 209 | } else if (tab instanceof SplitTabComponent) { 210 | sessions.push(...tab.getAllTabs() 211 | .filter(childTab => childTab instanceof BaseTerminalTabComponent && (childTab as BaseTerminalTabComponent).frontend !== undefined) 212 | .map(childTab => ({ 213 | id: id++, 214 | tabParent: tab, 215 | tab: childTab as BaseTerminalTabComponent 216 | }))); 217 | } 218 | }); 219 | return sessions; 220 | } 221 | 222 | /** 223 | * Get terminal buffer content as text 224 | * @param session The terminal session 225 | * @returns The terminal buffer content as text 226 | */ 227 | public getTerminalBufferText(session: BaseTerminalTabComponentWithId): string { 228 | try { 229 | const frontend = session.tab.frontend as XTermFrontend; 230 | if (!frontend || !frontend.xterm) { 231 | this.logger.error(`No xterm frontend available for session ${session.id}`); 232 | return ''; 233 | } 234 | 235 | // Check if serialize addon is already registered 236 | let serializeAddon = (frontend.xterm as any)._addonManager._addons.find( 237 | addon => addon.instance instanceof SerializeAddon 238 | )?.instance; 239 | 240 | // If not, register it 241 | if (!serializeAddon) { 242 | serializeAddon = new SerializeAddon(); 243 | frontend.xterm.loadAddon(serializeAddon); 244 | } 245 | 246 | // Get the terminal content 247 | return serializeAddon.serialize(); 248 | } catch (err) { 249 | this.logger.error(`Error getting terminal buffer:`, err); 250 | return ''; 251 | } 252 | } 253 | } -------------------------------------------------------------------------------- /src/services/dialogManager.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; 3 | import { LogService, Logger } from 'tabby-core'; 4 | import { Observable, Subject } from 'rxjs'; 5 | import { MinimizedDialogManagerService } from './minimizedDialogManager.service'; 6 | 7 | /** 8 | * Dialog request interface 9 | */ 10 | interface DialogRequest { 11 | component: any; 12 | options: any; 13 | props: any; 14 | resolve: (result: any) => void; 15 | reject: (reason: any) => void; 16 | } 17 | 18 | /** 19 | * Service to manage dialogs in the application 20 | * Ensures only one dialog is displayed at a time 21 | */ 22 | @Injectable({ providedIn: 'root' }) 23 | export class DialogManagerService { 24 | private activeDialog: NgbModalRef | null = null; 25 | private dialogQueue: DialogRequest[] = []; 26 | private logger: Logger; 27 | 28 | private dialogOpened = new Subject(); 29 | private dialogClosed = new Subject(); 30 | 31 | /** Observable that fires when a dialog is opened */ 32 | get dialogOpened$(): Observable { return this.dialogOpened; } 33 | 34 | /** Observable that fires when a dialog is closed */ 35 | get dialogClosed$(): Observable { return this.dialogClosed; } 36 | 37 | /** 38 | * Event handler to trap tab key within the modal 39 | * This prevents users from tabbing outside the modal 40 | */ 41 | private trapTabKey = (event: KeyboardEvent): void => { 42 | // Only handle Tab key 43 | if (event.key !== 'Tab') return; 44 | 45 | // Find all focusable elements in the modal 46 | const modalElement = document.querySelector('.modal-content') as HTMLElement; 47 | if (!modalElement) return; 48 | 49 | const focusableElements = modalElement.querySelectorAll( 50 | 'a[href], button, textarea, input, select, [tabindex]:not([tabindex="-1"])' 51 | ); 52 | 53 | if (focusableElements.length === 0) return; 54 | 55 | const firstElement = focusableElements[0] as HTMLElement; 56 | const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement; 57 | 58 | // If shift+tab on first element, move to last element 59 | if (event.shiftKey && document.activeElement === firstElement) { 60 | event.preventDefault(); 61 | lastElement.focus(); 62 | } 63 | // If tab on last element, move to first element 64 | else if (!event.shiftKey && document.activeElement === lastElement) { 65 | event.preventDefault(); 66 | firstElement.focus(); 67 | } 68 | } 69 | 70 | constructor( 71 | private ngbModal: NgbModal, 72 | log: LogService, 73 | private minimizedDialogManager: MinimizedDialogManagerService 74 | ) { 75 | this.logger = log.create('dialogManager'); 76 | } 77 | 78 | /** 79 | * Check if a dialog is currently active 80 | */ 81 | get hasActiveDialog(): boolean { 82 | return this.activeDialog !== null; 83 | } 84 | 85 | /** 86 | * Get the number of dialogs in the queue 87 | */ 88 | get queueLength(): number { 89 | return this.dialogQueue.length; 90 | } 91 | 92 | /** 93 | * Open a dialog 94 | * @param component Component to open 95 | * @param options Modal options 96 | * @param props Properties to set on the component instance 97 | * @returns Promise with dialog result 98 | */ 99 | async openDialog(component: any, options: any = {}, props: any = {}): Promise { 100 | return new Promise((resolve, reject) => { 101 | const request: DialogRequest = { 102 | component, 103 | options, 104 | props, 105 | resolve, 106 | reject 107 | }; 108 | 109 | // If there's no active dialog, show this one immediately 110 | if (!this.activeDialog) { 111 | this.showDialog(request); 112 | } else { 113 | // Otherwise, add it to the queue 114 | this.logger.debug(`Dialog queued, current queue length: ${this.dialogQueue.length}`); 115 | this.dialogQueue.push(request); 116 | } 117 | }); 118 | } 119 | 120 | /** 121 | * Close the active dialog 122 | */ 123 | closeActiveDialog(): void { 124 | if (this.activeDialog) { 125 | this.activeDialog.close(); 126 | } 127 | } 128 | 129 | /** 130 | * Clear the dialog queue 131 | */ 132 | clearQueue(): void { 133 | // Reject all queued dialogs 134 | for (const request of this.dialogQueue) { 135 | request.reject(new Error('Dialog queue cleared')); 136 | } 137 | this.dialogQueue = []; 138 | } 139 | 140 | /** 141 | * Show a dialog 142 | * @param request Dialog request 143 | */ 144 | private showDialog(request: DialogRequest): void { 145 | try { 146 | // Ensure modal options always include focus trapping settings 147 | const modalOptions = { 148 | backdrop: 'static', // Prevents closing when clicking outside 149 | keyboard: false, // Prevents closing with Escape key 150 | windowClass: 'force-focus-modal', // Add a class for additional styling 151 | ...request.options // Allow overriding with custom options if needed 152 | }; 153 | 154 | this.activeDialog = this.ngbModal.open(request.component, modalOptions); 155 | 156 | // Set properties on the component instance 157 | for (const key in request.props) { 158 | if (Object.prototype.hasOwnProperty.call(request.props, key)) { 159 | this.activeDialog.componentInstance[key] = request.props[key]; 160 | } 161 | } 162 | 163 | // Add event listener to prevent tab navigation outside the modal 164 | document.addEventListener('keydown', this.trapTabKey); 165 | 166 | // Emit dialog opened event 167 | this.dialogOpened.next({ 168 | component: request.component, 169 | instance: this.activeDialog.componentInstance 170 | }); 171 | 172 | // Handle dialog result 173 | this.activeDialog.result.then( 174 | (result) => { 175 | this.handleDialogClosed(result); 176 | request.resolve(result); 177 | }, 178 | (reason) => { 179 | this.handleDialogClosed(null, reason); 180 | // Don't reject promise if dialog was minimized - keep it pending 181 | if (reason !== 'minimized') { 182 | request.reject(reason); 183 | } else { 184 | // For minimized dialogs, store the promise resolver immediately 185 | console.log('Dialog minimized, storing promise resolver'); 186 | this.storePromiseResolverForMinimizedDialog(this.activeDialog, request); 187 | } 188 | } 189 | ); 190 | } catch (error) { 191 | this.logger.error('Error opening dialog:', error); 192 | this.handleDialogClosed(null, error); 193 | request.reject(error); 194 | } 195 | } 196 | 197 | /** 198 | * Handle dialog closed 199 | * @param result Dialog result 200 | * @param error Error if dialog was rejected 201 | */ 202 | private handleDialogClosed(result?: any, error?: any): void { 203 | // Emit dialog closed event 204 | this.dialogClosed.next({ 205 | result, 206 | error 207 | }); 208 | 209 | // Remove the tab trap event listener 210 | document.removeEventListener('keydown', this.trapTabKey); 211 | 212 | this.activeDialog = null; 213 | 214 | // If there are more dialogs in the queue, show the next one 215 | if (this.dialogQueue.length > 0) { 216 | const nextRequest = this.dialogQueue.shift(); 217 | if (nextRequest) { 218 | this.logger.debug(`Showing next dialog from queue, remaining: ${this.dialogQueue.length}`); 219 | setTimeout(() => this.showDialog(nextRequest), 100); // Small delay to ensure previous dialog is fully closed 220 | } 221 | } 222 | } 223 | 224 | /** 225 | * Store promise resolver for minimized dialog 226 | */ 227 | private storePromiseResolverForMinimizedDialog(modalRef: NgbModalRef | null, request: DialogRequest): void { 228 | console.log('storePromiseResolverForMinimizedDialog called'); 229 | 230 | // Capture dialog ID before checking modalRef, as it might become null 231 | let dialogId: string | null = null; 232 | 233 | if (modalRef && modalRef.componentInstance) { 234 | dialogId = modalRef.componentInstance.dialogId; 235 | console.log('Dialog ID from modal:', dialogId); 236 | } else { 237 | console.log('No modalRef or componentInstance, trying to find by other means'); 238 | 239 | // Try to find the most recently added minimized dialog 240 | const dialogs = this.minimizedDialogManager.dialogs; 241 | if (dialogs.length > 0) { 242 | // Get the most recent dialog (highest timestamp) 243 | const mostRecent = dialogs.reduce((latest, current) => 244 | current.timestamp > latest.timestamp ? current : latest 245 | ); 246 | dialogId = mostRecent.id; 247 | console.log('Using most recent minimized dialog ID:', dialogId); 248 | } 249 | } 250 | 251 | if (!dialogId) { 252 | console.log('No dialogId found'); 253 | return; 254 | } 255 | 256 | // Find the minimized dialog and update it with promise resolver 257 | const dialogs = this.minimizedDialogManager.dialogs; 258 | console.log('Current minimized dialogs:', dialogs.length); 259 | console.log('Looking for dialog with ID:', dialogId); 260 | 261 | const dialog = dialogs.find(d => d.id === dialogId); 262 | console.log('Found dialog:', !!dialog); 263 | 264 | if (dialog) { 265 | console.log('Storing promise resolver for dialog:', dialog.id); 266 | dialog.promiseResolver = { 267 | resolve: request.resolve, 268 | reject: request.reject 269 | }; 270 | 271 | // Update the dialog in the manager 272 | this.minimizedDialogManager.minimizeDialog(dialog); 273 | console.log('Promise resolver stored successfully'); 274 | } else { 275 | console.log('Dialog not found in minimized dialogs list'); 276 | console.log('Available dialog IDs:', dialogs.map(d => d.id)); 277 | } 278 | } 279 | } 280 | -------------------------------------------------------------------------------- /src/components/minimizedModal.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, OnDestroy } from '@angular/core'; 2 | import { NgbActiveModal, NgbModal } from '@ng-bootstrap/ng-bootstrap'; 3 | import { Subscription } from 'rxjs'; 4 | import { MinimizedDialogManagerService, MinimizedDialog } from '../services/minimizedDialogManager.service'; 5 | import { DialogManagerService } from '../services/dialogManager.service'; 6 | 7 | @Component({ 8 | selector: 'minimized-dialogs-modal', 9 | template: ` 10 | 17 | 18 | 52 | 53 | 62 | ` 63 | }) 64 | export class MinimizedDialogsModalComponent implements OnInit, OnDestroy { 65 | minimizedDialogs: MinimizedDialog[] = []; 66 | private subscription: Subscription; 67 | 68 | constructor( 69 | private activeModal: NgbActiveModal, 70 | private minimizedDialogManager: MinimizedDialogManagerService, 71 | private dialogManager: DialogManagerService, 72 | private modal: NgbModal 73 | ) { } 74 | 75 | ngOnInit(): void { 76 | this.subscription = this.minimizedDialogManager.minimizedDialogs$.subscribe(dialogs => { 77 | this.minimizedDialogs = dialogs; 78 | 79 | // Auto-close modal if no dialogs left 80 | if (dialogs.length === 0) { 81 | setTimeout(() => this.close(), 1000); 82 | } 83 | }); 84 | } 85 | 86 | ngOnDestroy(): void { 87 | if (this.subscription) { 88 | this.subscription.unsubscribe(); 89 | } 90 | } 91 | 92 | close(): void { 93 | this.activeModal.dismiss(); 94 | } 95 | 96 | /** 97 | * Restore a minimized dialog 98 | */ 99 | async restoreDialog(dialogId: string): Promise { 100 | const dialog = this.minimizedDialogManager.restoreDialog(dialogId); 101 | if (dialog) { 102 | try { 103 | console.log('Restoring dialog:', dialog.title); 104 | console.log('Dialog has promise resolver:', !!dialog.promiseResolver); 105 | 106 | // Close this modal first 107 | this.close(); 108 | 109 | // Create a new dialog with the same component and data, but handle the result differently 110 | const modalRef = await this.createRestoredDialog(dialog); 111 | console.log('Created restored dialog modalRef:', !!modalRef); 112 | 113 | // Set up handlers for the restored dialog 114 | if (modalRef && dialog.promiseResolver) { 115 | modalRef.result.then( 116 | (result) => { 117 | console.log('Restored dialog completed with result:', result); 118 | console.log('About to resolve original promise with result:', result); 119 | // Use the stored promise resolver to continue the original workflow 120 | dialog.promiseResolver!.resolve(result); 121 | console.log('Original promise resolved successfully'); 122 | }, 123 | (reason) => { 124 | console.log('Restored dialog dismissed with reason:', reason); 125 | if (reason !== 'minimized') { 126 | console.log('Rejecting original promise with reason:', reason); 127 | // If not minimized again, reject the original promise 128 | dialog.promiseResolver!.reject(reason); 129 | } else { 130 | console.log('Dialog minimized again, keeping promise pending'); 131 | // If minimized again, we need to store the promise resolver again 132 | // But first we need to get the minimized dialog that was just created 133 | const newMinimizedDialog = this.minimizedDialogManager.dialogs.find(d => d.instance?.command === dialog.instance?.command); 134 | if (newMinimizedDialog && dialog.promiseResolver) { 135 | newMinimizedDialog.promiseResolver = dialog.promiseResolver; 136 | this.minimizedDialogManager.minimizeDialog(newMinimizedDialog); 137 | } 138 | } 139 | } 140 | ); 141 | } else { 142 | console.log('No modalRef or promise resolver available'); 143 | if (!modalRef) console.log('modalRef is null'); 144 | if (!dialog.promiseResolver) console.log('dialog.promiseResolver is null'); 145 | } 146 | 147 | } catch (error) { 148 | console.error('Error restoring dialog:', error); 149 | 150 | // If restore fails, reject the original promise 151 | if (dialog.promiseResolver) { 152 | dialog.promiseResolver.reject(error); 153 | } 154 | } 155 | } else { 156 | console.log('No dialog found with ID:', dialogId); 157 | } 158 | } 159 | 160 | /** 161 | * Create a restored dialog without going through DialogManagerService 162 | * This prevents creating a new promise chain 163 | */ 164 | private async createRestoredDialog(dialog: MinimizedDialog): Promise { 165 | const modalRef = this.modal.open(dialog.component, { 166 | backdrop: 'static', 167 | keyboard: false, 168 | windowClass: 'restored-modal' 169 | }); 170 | 171 | // Set properties on the component instance 172 | const props = this.getDialogProps(dialog); 173 | for (const key in props) { 174 | if (Object.prototype.hasOwnProperty.call(props, key)) { 175 | modalRef.componentInstance[key] = props[key]; 176 | } 177 | } 178 | 179 | // Make sure the restored dialog has access to MinimizedDialogManagerService 180 | // and generate a new dialogId so it can be minimized again 181 | if (modalRef.componentInstance) { 182 | modalRef.componentInstance.minimizedDialogManager = this.minimizedDialogManager; 183 | modalRef.componentInstance.dialogId = this.minimizedDialogManager.generateDialogId(); 184 | 185 | console.log('Set minimizedDialogManager on restored dialog:', !!modalRef.componentInstance.minimizedDialogManager); 186 | console.log('Set new dialogId on restored dialog:', modalRef.componentInstance.dialogId); 187 | } 188 | 189 | return modalRef; 190 | } 191 | 192 | /** 193 | * Close a minimized dialog permanently 194 | */ 195 | closeDialog(dialogId: string): void { 196 | this.minimizedDialogManager.closeMinimizedDialog(dialogId); 197 | } 198 | 199 | /** 200 | * Clear all minimized dialogs 201 | */ 202 | clearAll(): void { 203 | this.minimizedDialogManager.clearAll(); 204 | } 205 | 206 | /** 207 | * Get display title for a dialog 208 | */ 209 | getDisplayTitle(dialog: MinimizedDialog): string { 210 | if (dialog.instance?.command) { 211 | // For command dialogs, show a truncated version of the command 212 | const cmd = dialog.instance.command; 213 | return cmd.length > 60 ? cmd.substring(0, 60) + '...' : cmd; 214 | } 215 | return dialog.title; 216 | } 217 | 218 | /** 219 | * Get relative time since dialog was minimized 220 | */ 221 | getRelativeTime(timestamp: number): string { 222 | const now = Date.now(); 223 | const diff = now - timestamp; 224 | 225 | if (diff < 60000) { // < 1 minute 226 | return 'just now'; 227 | } else if (diff < 3600000) { // < 1 hour 228 | const minutes = Math.floor(diff / 60000); 229 | return `${minutes}m ago`; 230 | } else if (diff < 86400000) { // < 1 day 231 | const hours = Math.floor(diff / 3600000); 232 | return `${hours}h ago`; 233 | } else { 234 | const days = Math.floor(diff / 86400000); 235 | return `${days}d ago`; 236 | } 237 | } 238 | 239 | /** 240 | * Get the original props for a dialog component 241 | */ 242 | private getDialogProps(dialog: MinimizedDialog): any { 243 | if (!dialog.instance) return {}; 244 | 245 | // Extract properties from the original instance 246 | const props: any = {}; 247 | 248 | // Common properties for command dialogs 249 | if (dialog.instance.command) props.command = dialog.instance.command; 250 | if (dialog.instance.tabId !== undefined) props.tabId = dialog.instance.tabId; 251 | if (dialog.instance.tabTitle) props.tabTitle = dialog.instance.tabTitle; 252 | if (dialog.instance.commandExplanation) props.commandExplanation = dialog.instance.commandExplanation; 253 | if (dialog.instance.output) props.output = dialog.instance.output; 254 | if (dialog.instance.exitCode !== undefined) props.exitCode = dialog.instance.exitCode; 255 | if (dialog.instance.aborted !== undefined) props.aborted = dialog.instance.aborted; 256 | if (dialog.instance.originalInstruction) props.originalInstruction = dialog.instance.originalInstruction; 257 | 258 | return props; 259 | } 260 | } -------------------------------------------------------------------------------- /src/components/commandResultDialog.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input, NgModule, ViewChild, ElementRef, AfterViewInit, HostListener, OnDestroy } from '@angular/core'; 2 | import { FormsModule } from '@angular/forms'; 3 | import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'; 4 | import { CommonModule } from '@angular/common'; 5 | import { HotkeysService } from 'tabby-core'; 6 | import { MinimizedDialogManagerService } from '../services/minimizedDialogManager.service'; 7 | 8 | /** 9 | * Dialog component for displaying command execution results 10 | */ 11 | @Component({ 12 | template: require('./commandResultDialog.component.pug').default, 13 | }) 14 | export class CommandResultDialogComponent implements AfterViewInit, OnDestroy { 15 | @Input() command: string; 16 | 17 | private _output: string = ''; 18 | @Input() 19 | set output(value: string) { 20 | this._output = value; 21 | // If the component is already initialized, adjust the textarea height 22 | if (this.outputTextareaRef?.nativeElement) { 23 | setTimeout(() => this.adjustTextareaHeight(this.outputTextareaRef.nativeElement), 0); 24 | } 25 | } 26 | get output(): string { 27 | return this._output; 28 | } 29 | 30 | @Input() exitCode: number | null; 31 | @Input() aborted: boolean; 32 | @Input() originalInstruction: string = ''; 33 | 34 | // User message input 35 | userMessage: string = ''; 36 | 37 | // Rejection message 38 | rejectionMessage: string = ''; 39 | 40 | // Flag to show if we're in reject mode 41 | isRejectMode: boolean = false; 42 | 43 | // Reference to the message textarea 44 | @ViewChild('messageTextarea') messageTextareaRef: ElementRef; 45 | 46 | // Reference to the output textarea 47 | @ViewChild('outputTextarea') outputTextareaRef: ElementRef; 48 | 49 | // Track if hotkeys are paused 50 | private hotkeysPaused = false; 51 | 52 | // Dialog ID for minimize/restore functionality 53 | public dialogId: string = ''; 54 | 55 | constructor( 56 | public modal: NgbActiveModal, 57 | private hotkeysService: HotkeysService, 58 | private minimizedDialogManager: MinimizedDialogManagerService 59 | ) { 60 | this.dialogId = this.minimizedDialogManager.generateDialogId(); 61 | } 62 | 63 | /** 64 | * After view init, focus the textarea and pause hotkeys 65 | */ 66 | ngAfterViewInit(): void { 67 | setTimeout(() => { 68 | // Focus the dialog element to capture keyboard events 69 | if (this.modal) { 70 | const modalElement = document.querySelector('.modal-content') as HTMLElement; 71 | if (modalElement) { 72 | // Add tabindex to make the modal focusable 73 | if (!modalElement.hasAttribute('tabindex')) { 74 | modalElement.setAttribute('tabindex', '-1'); 75 | } 76 | 77 | // Add focused class for visual indication 78 | modalElement.classList.add('focused'); 79 | 80 | // Focus the modal 81 | modalElement.focus(); 82 | 83 | // Add event listener to prevent focus from leaving the modal 84 | document.addEventListener('focusin', this.keepFocusInModal); 85 | } 86 | } 87 | 88 | // Set the output textarea to the correct height based on content 89 | if (this.outputTextareaRef?.nativeElement) { 90 | // Adjust the height to fit the content 91 | this.adjustTextareaHeight(this.outputTextareaRef.nativeElement); 92 | } 93 | 94 | // Pause hotkeys while dialog is open 95 | this.pauseHotkeys(); 96 | }, 100); 97 | } 98 | 99 | /** 100 | * Event handler to keep focus inside the modal 101 | */ 102 | private keepFocusInModal = (event: FocusEvent) => { 103 | const modalElement = document.querySelector('.modal-content') as HTMLElement; 104 | if (modalElement && !modalElement.contains(event.target as Node)) { 105 | // If focus is outside the modal, bring it back 106 | modalElement.focus(); 107 | } 108 | } 109 | 110 | /** 111 | * Adjust textarea height to fit content 112 | * @param textarea Textarea element to adjust 113 | */ 114 | private adjustTextareaHeight(textarea: HTMLTextAreaElement): void { 115 | // Reset height to calculate the proper scrollHeight 116 | textarea.style.height = 'auto'; 117 | 118 | // Set the height to match the scrollHeight (content height) 119 | const scrollHeight = textarea.scrollHeight; 120 | if (scrollHeight > 0) { 121 | // Limit max height to 300px (same as CSS max-height) 122 | const maxHeight = 300; 123 | textarea.style.height = Math.min(scrollHeight, maxHeight) + 'px'; 124 | } 125 | } 126 | 127 | /** 128 | * Pause hotkeys when the dialog is focused 129 | */ 130 | pauseHotkeys(): void { 131 | if (!this.hotkeysPaused) { 132 | this.hotkeysService.disable(); 133 | this.hotkeysPaused = true; 134 | } 135 | } 136 | 137 | /** 138 | * Restore hotkeys when the dialog is closed 139 | */ 140 | resumeHotkeys(): void { 141 | if (this.hotkeysPaused) { 142 | this.hotkeysService.enable(); 143 | this.hotkeysPaused = false; 144 | } 145 | } 146 | 147 | /** 148 | * Handle escape key to close dialog or cancel reject mode 149 | */ 150 | @HostListener('document:keydown.escape') 151 | onEscapePressed(): void { 152 | const modalElement = document.querySelector('.modal-content') as HTMLElement; 153 | if (modalElement) { 154 | if (document.activeElement !== modalElement) { 155 | modalElement.focus(); 156 | return; 157 | } 158 | } 159 | if (this.isRejectMode) { 160 | this.toggleRejectMode(); 161 | } else { 162 | this.cancel(); 163 | } 164 | } 165 | 166 | /** 167 | * Handle R key to toggle reject mode 168 | */ 169 | @HostListener('document:keydown.r', ['$event']) 170 | onRPressed(event: KeyboardEvent): void { 171 | // Only handle if not in a text input 172 | if (document.activeElement instanceof HTMLInputElement || 173 | document.activeElement instanceof HTMLTextAreaElement) { 174 | return; 175 | } 176 | 177 | event.preventDefault(); 178 | this.reject(); 179 | } 180 | 181 | /** 182 | * Handle Enter key to accept 183 | */ 184 | @HostListener('document:keydown.enter', ['$event']) 185 | onEnterPressed(event: KeyboardEvent): void { 186 | // Only handle if not in a text input 187 | if (document.activeElement instanceof HTMLInputElement || 188 | document.activeElement instanceof HTMLTextAreaElement) { 189 | return; 190 | } 191 | 192 | event.preventDefault(); 193 | // Always call accept, which will handle the mode switching if needed 194 | this.accept(); 195 | } 196 | 197 | /** 198 | * Handle keydown events in the textarea 199 | * @param event Keyboard event 200 | */ 201 | onTextareaKeyDown(event: KeyboardEvent): void { 202 | // Handle Shift+Enter to add a new line 203 | if (event.key === 'Enter' && event.shiftKey) { 204 | // Let the default behavior happen (add a new line) 205 | return; 206 | } 207 | 208 | // Handle Enter to submit the form 209 | if (event.key === 'Enter' && !event.shiftKey) { 210 | event.preventDefault(); 211 | 212 | if (this.isRejectMode) { 213 | this.reject(); 214 | } else { 215 | this.accept(); 216 | } 217 | } 218 | } 219 | 220 | /** 221 | * Accept the command result with user message 222 | */ 223 | accept(): void { 224 | // If in reject mode, switch to accept mode first 225 | if (this.isRejectMode) { 226 | this.toggleRejectMode(); 227 | return; 228 | } 229 | 230 | // If already in accept mode, submit the acceptance 231 | this.resumeHotkeys(); 232 | this.modal.close({ 233 | accepted: true, 234 | userMessage: this.userMessage 235 | }); 236 | } 237 | 238 | /** 239 | * Toggle rejection mode 240 | */ 241 | toggleRejectMode(): void { 242 | this.isRejectMode = !this.isRejectMode; 243 | 244 | if (this.isRejectMode) { 245 | // If entering reject mode, copy current message to rejection message 246 | this.rejectionMessage = this.userMessage; 247 | this.userMessage = ''; 248 | 249 | // Focus the textarea after a short delay 250 | setTimeout(() => { 251 | if (this.messageTextareaRef?.nativeElement) { 252 | this.messageTextareaRef.nativeElement.focus(); 253 | } 254 | }, 100); 255 | } else { 256 | // If exiting reject mode, restore previous message 257 | this.userMessage = this.rejectionMessage; 258 | this.rejectionMessage = ''; 259 | } 260 | } 261 | 262 | /** 263 | * Submit rejection with message 264 | */ 265 | reject(): void { 266 | if (!this.isRejectMode) { 267 | // If not in reject mode, toggle to reject mode 268 | this.toggleRejectMode(); 269 | return; 270 | } 271 | 272 | // If already in reject mode, submit the rejection 273 | if (!this.userMessage.trim()) { 274 | // If no reason provided, ask for one 275 | alert('Please provide a reason for rejection.'); 276 | return; 277 | } 278 | 279 | this.resumeHotkeys(); 280 | this.modal.close({ 281 | accepted: false, 282 | rejectionMessage: this.userMessage 283 | }); 284 | } 285 | 286 | /** 287 | * Cancel and close the dialog 288 | */ 289 | cancel(): void { 290 | this.resumeHotkeys(); 291 | this.modal.close(); 292 | } 293 | 294 | /** 295 | * Minimize the dialog 296 | */ 297 | minimize(): void { 298 | console.log('Minimizing command result dialog'); 299 | 300 | // Create minimized dialog object 301 | const minimizedDialog = { 302 | id: this.dialogId, 303 | title: `Result: ${this.command.length > 40 ? this.command.substring(0, 40) + '...' : this.command}`, 304 | component: CommandResultDialogComponent, 305 | instance: this, 306 | modalRef: this.modal, 307 | timestamp: Date.now() 308 | }; 309 | 310 | // Add to minimized dialogs 311 | this.minimizedDialogManager.minimizeDialog(minimizedDialog); 312 | 313 | // Dismiss the modal with 'minimized' reason 314 | this.resumeHotkeys(); 315 | this.modal.dismiss('minimized'); 316 | } 317 | 318 | /** 319 | * Clean up when component is destroyed 320 | */ 321 | ngOnDestroy(): void { 322 | this.resumeHotkeys(); 323 | 324 | // Remove the focus event listener 325 | document.removeEventListener('focusin', this.keepFocusInModal); 326 | 327 | // Remove focused class from modal if it exists 328 | const modalElement = document.querySelector('.modal-content') as HTMLElement; 329 | if (modalElement) { 330 | modalElement.classList.remove('focused'); 331 | } 332 | } 333 | } 334 | 335 | /** 336 | * Module for CommandResultDialogComponent 337 | * This allows the component to be used with NgModel 338 | */ 339 | @NgModule({ 340 | imports: [ 341 | CommonModule, 342 | FormsModule, 343 | NgbModule 344 | ], 345 | declarations: [ 346 | CommandResultDialogComponent 347 | ], 348 | exports: [ 349 | CommandResultDialogComponent 350 | ] 351 | // HotkeysService is provided at the root level 352 | }) 353 | export class CommandResultDialogModule { } 354 | -------------------------------------------------------------------------------- /src/services/commandHistoryManager.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { BehaviorSubject, Observable } from 'rxjs'; 3 | import { McpLoggerService } from './mcpLogger.service'; 4 | 5 | /** 6 | * Interface for command history entry 7 | */ 8 | export interface CommandHistoryEntry { 9 | id: string; 10 | command: string; 11 | output: string; 12 | promptShell: string | null; 13 | exitCode: number | null; 14 | timestamp: number; 15 | aborted: boolean; 16 | tabId: string; 17 | tabTitle?: string; 18 | duration?: number; // execution duration in milliseconds 19 | } 20 | 21 | /** 22 | * Service to manage command execution history 23 | */ 24 | @Injectable({ providedIn: 'root' }) 25 | export class CommandHistoryManagerService { 26 | private readonly MAX_HISTORY_ENTRIES = 1000; 27 | private commandHistory = new BehaviorSubject([]); 28 | 29 | /** Observable for command history */ 30 | get commandHistory$(): Observable { 31 | return this.commandHistory.asObservable(); 32 | } 33 | 34 | /** Get current command history */ 35 | get history(): CommandHistoryEntry[] { 36 | return this.commandHistory.value; 37 | } 38 | 39 | constructor(private logger: McpLoggerService) { 40 | this.logger.info('CommandHistoryManagerService initialized'); 41 | this.loadHistoryFromStorage(); 42 | } 43 | 44 | /** 45 | * Add a command to history 46 | */ 47 | addCommand(entry: Omit): string { 48 | const id = this.generateEntryId(); 49 | const historyEntry: CommandHistoryEntry = { 50 | ...entry, 51 | id 52 | }; 53 | 54 | const current = this.commandHistory.value; 55 | const updated = [historyEntry, ...current]; 56 | 57 | // Keep only the most recent entries 58 | if (updated.length > this.MAX_HISTORY_ENTRIES) { 59 | updated.splice(this.MAX_HISTORY_ENTRIES); 60 | } 61 | 62 | this.commandHistory.next(updated); 63 | this.saveHistoryToStorage(); 64 | 65 | this.logger.info(`Added command to history: ${entry.command} (ID: ${id})`); 66 | return id; 67 | } 68 | 69 | /** 70 | * Get a command from history by ID 71 | */ 72 | getCommand(id: string): CommandHistoryEntry | null { 73 | return this.commandHistory.value.find(entry => entry.id === id) || null; 74 | } 75 | 76 | /** 77 | * Remove a command from history 78 | */ 79 | removeCommand(id: string): boolean { 80 | const current = this.commandHistory.value; 81 | const updated = current.filter(entry => entry.id !== id); 82 | 83 | if (updated.length !== current.length) { 84 | this.commandHistory.next(updated); 85 | this.saveHistoryToStorage(); 86 | this.logger.info(`Removed command from history: ${id}`); 87 | return true; 88 | } 89 | 90 | return false; 91 | } 92 | 93 | /** 94 | * Clear all command history 95 | */ 96 | clearHistory(): void { 97 | this.commandHistory.next([]); 98 | this.saveHistoryToStorage(); 99 | this.logger.info('Cleared all command history'); 100 | } 101 | 102 | /** 103 | * Get filtered history by search term 104 | */ 105 | searchHistory(searchTerm: string): CommandHistoryEntry[] { 106 | if (!searchTerm.trim()) { 107 | return this.commandHistory.value; 108 | } 109 | 110 | const term = searchTerm.toLowerCase(); 111 | return this.commandHistory.value.filter(entry => 112 | entry.command.toLowerCase().includes(term) || 113 | entry.output.toLowerCase().includes(term) || 114 | (entry.tabTitle && entry.tabTitle.toLowerCase().includes(term)) 115 | ); 116 | } 117 | 118 | /** 119 | * Get history filtered by success/failure 120 | */ 121 | getFilteredHistory(filter: 'all' | 'success' | 'failed' | 'aborted'): CommandHistoryEntry[] { 122 | const history = this.commandHistory.value; 123 | console.log('[CommandHistoryManager] getFilteredHistory called with filter:', filter, 'total entries:', history.length); 124 | 125 | switch (filter) { 126 | case 'success': 127 | const successEntries = history.filter(entry => !entry.aborted && entry.exitCode === 0); 128 | console.log('[CommandHistoryManager] Success entries:', successEntries.length); 129 | return successEntries; 130 | case 'failed': 131 | const failedEntries = history.filter(entry => !entry.aborted && entry.exitCode !== 0); 132 | console.log('[CommandHistoryManager] Failed entries:', failedEntries.length); 133 | return failedEntries; 134 | case 'aborted': 135 | const abortedEntries = history.filter(entry => entry.aborted); 136 | console.log('[CommandHistoryManager] Aborted entries:', abortedEntries.length); 137 | return abortedEntries; 138 | default: 139 | console.log('[CommandHistoryManager] All entries:', history.length); 140 | return history; 141 | } 142 | } 143 | 144 | /** 145 | * Copy command to clipboard 146 | */ 147 | async copyCommand(id: string): Promise { 148 | const entry = this.getCommand(id); 149 | if (!entry) { 150 | return false; 151 | } 152 | 153 | try { 154 | await navigator.clipboard.writeText(entry.command); 155 | this.logger.info(`Copied command to clipboard: ${entry.command}`); 156 | return true; 157 | } catch (error) { 158 | this.logger.error('Failed to copy command to clipboard:', error); 159 | return false; 160 | } 161 | } 162 | 163 | /** 164 | * Copy command output to clipboard 165 | */ 166 | async copyOutput(id: string): Promise { 167 | const entry = this.getCommand(id); 168 | if (!entry) { 169 | return false; 170 | } 171 | 172 | try { 173 | await navigator.clipboard.writeText(entry.output); 174 | this.logger.info(`Copied output to clipboard for command: ${entry.command}`); 175 | return true; 176 | } catch (error) { 177 | this.logger.error('Failed to copy output to clipboard:', error); 178 | return false; 179 | } 180 | } 181 | 182 | /** 183 | * Export all command history as commands only 184 | */ 185 | exportCommandsOnly(entries?: CommandHistoryEntry[]): string { 186 | const historyToExport = entries || this.commandHistory.value; 187 | 188 | if (historyToExport.length === 0) { 189 | return ''; 190 | } 191 | 192 | const commands = historyToExport.map(entry => entry.command); 193 | const exportContent = commands.join('\n'); 194 | 195 | this.logger.info(`Exported ${commands.length} commands only`); 196 | return exportContent; 197 | } 198 | 199 | /** 200 | * Export all command history with output 201 | */ 202 | exportCommandsWithOutput(entries?: CommandHistoryEntry[]): string { 203 | const historyToExport = entries || this.commandHistory.value; 204 | 205 | if (historyToExport.length === 0) { 206 | return ''; 207 | } 208 | 209 | const exportLines: string[] = []; 210 | 211 | historyToExport.forEach((entry, index) => { 212 | const timestamp = new Date(entry.timestamp).toLocaleString(); 213 | const status = entry.aborted ? 'ABORTED' : (entry.exitCode === 0 ? 'SUCCESS' : 'FAILED'); 214 | const duration = entry.duration ? ` (${this.formatDuration(entry.duration)})` : ''; 215 | 216 | exportLines.push(`# Entry ${index + 1} - ${timestamp} - ${status}${duration}`); 217 | if (entry.tabTitle) { 218 | exportLines.push(`# Terminal: ${entry.tabTitle}`); 219 | } 220 | exportLines.push(`$ ${entry.command}`); 221 | 222 | if (entry.output && entry.output.trim()) { 223 | exportLines.push(entry.output.trim()); 224 | } 225 | 226 | if (entry.exitCode !== null) { 227 | exportLines.push(`# Exit Code: ${entry.exitCode}`); 228 | } 229 | 230 | exportLines.push(''); // Empty line between entries 231 | }); 232 | 233 | const exportContent = exportLines.join('\n'); 234 | this.logger.info(`Exported ${historyToExport.length} commands with output`); 235 | return exportContent; 236 | } 237 | 238 | /** 239 | * Export command history as JSON 240 | */ 241 | exportAsJSON(entries?: CommandHistoryEntry[]): string { 242 | const historyToExport = entries || this.commandHistory.value; 243 | 244 | if (historyToExport.length === 0) { 245 | return ''; 246 | } 247 | 248 | const exportData = { 249 | exportDate: new Date().toISOString(), 250 | totalCommands: historyToExport.length, 251 | commands: historyToExport.map(entry => ({ 252 | id: entry.id, 253 | command: entry.command, 254 | output: entry.output, 255 | exitCode: entry.exitCode, 256 | timestamp: entry.timestamp, 257 | date: new Date(entry.timestamp).toISOString(), 258 | aborted: entry.aborted, 259 | tabId: entry.tabId, 260 | tabTitle: entry.tabTitle, 261 | duration: entry.duration, 262 | status: entry.aborted ? 'ABORTED' : (entry.exitCode === 0 ? 'SUCCESS' : 'FAILED') 263 | })) 264 | }; 265 | 266 | const jsonContent = JSON.stringify(exportData, null, 2); 267 | this.logger.info(`Exported ${historyToExport.length} commands as JSON`); 268 | return jsonContent; 269 | } 270 | 271 | /** 272 | * Export command history as CSV 273 | */ 274 | exportAsCSV(entries?: CommandHistoryEntry[]): string { 275 | const historyToExport = entries || this.commandHistory.value; 276 | 277 | if (historyToExport.length === 0) { 278 | return ''; 279 | } 280 | 281 | const csvLines: string[] = []; 282 | 283 | // CSV Header 284 | csvLines.push('Date,Command,Status,Exit Code,Duration (ms),Terminal,Output'); 285 | 286 | // CSV Data 287 | historyToExport.forEach(entry => { 288 | const date = new Date(entry.timestamp).toISOString(); 289 | const command = this.escapeCsvField(entry.command); 290 | const status = entry.aborted ? 'ABORTED' : (entry.exitCode === 0 ? 'SUCCESS' : 'FAILED'); 291 | const exitCode = entry.exitCode !== null ? entry.exitCode.toString() : ''; 292 | const duration = entry.duration ? entry.duration.toString() : ''; 293 | const terminal = this.escapeCsvField(entry.tabTitle || ''); 294 | const output = this.escapeCsvField(entry.output || ''); 295 | 296 | csvLines.push(`${date},${command},${status},${exitCode},${duration},${terminal},${output}`); 297 | }); 298 | 299 | const csvContent = csvLines.join('\n'); 300 | this.logger.info(`Exported ${historyToExport.length} commands as CSV`); 301 | return csvContent; 302 | } 303 | 304 | /** 305 | * Export command history as Markdown 306 | */ 307 | exportAsMarkdown(entries?: CommandHistoryEntry[]): string { 308 | const historyToExport = entries || this.commandHistory.value; 309 | 310 | if (historyToExport.length === 0) { 311 | return ''; 312 | } 313 | 314 | const mdLines: string[] = []; 315 | 316 | // Markdown Header 317 | mdLines.push('# Command History Export'); 318 | mdLines.push(`**Export Date:** ${new Date().toLocaleString()}`); 319 | mdLines.push(`**Total Commands:** ${historyToExport.length}`); 320 | mdLines.push(''); 321 | mdLines.push('---'); 322 | mdLines.push(''); 323 | 324 | // Markdown entries 325 | historyToExport.forEach((entry, index) => { 326 | const timestamp = new Date(entry.timestamp).toLocaleString(); 327 | const status = entry.aborted ? '⚠️ ABORTED' : (entry.exitCode === 0 ? '✅ SUCCESS' : '❌ FAILED'); 328 | const duration = entry.duration ? ` (${this.formatDuration(entry.duration)})` : ''; 329 | 330 | mdLines.push(`## Command ${index + 1}`); 331 | mdLines.push(`**Date:** ${timestamp}`); 332 | mdLines.push(`**Status:** ${status}${duration}`); 333 | if (entry.tabTitle) { 334 | mdLines.push(`**Terminal:** ${entry.tabTitle}`); 335 | } 336 | mdLines.push(`**Exit Code:** ${entry.exitCode !== null ? entry.exitCode : 'N/A'}`); 337 | mdLines.push(''); 338 | mdLines.push('### Command'); 339 | mdLines.push('```bash'); 340 | mdLines.push(entry.command); 341 | mdLines.push('```'); 342 | mdLines.push(''); 343 | 344 | if (entry.output && entry.output.trim()) { 345 | mdLines.push('### Output'); 346 | mdLines.push('```'); 347 | mdLines.push(entry.output.trim()); 348 | mdLines.push('```'); 349 | mdLines.push(''); 350 | } 351 | 352 | mdLines.push('---'); 353 | mdLines.push(''); 354 | }); 355 | 356 | const mdContent = mdLines.join('\n'); 357 | this.logger.info(`Exported ${historyToExport.length} commands as Markdown`); 358 | return mdContent; 359 | } 360 | 361 | /** 362 | * Escape CSV field (handle commas, quotes, newlines) 363 | */ 364 | private escapeCsvField(field: string): string { 365 | if (!field) return ''; 366 | 367 | // If field contains comma, quote, or newline, wrap in quotes and escape existing quotes 368 | if (field.indexOf(',') !== -1 || field.indexOf('"') !== -1 || field.indexOf('\n') !== -1 || field.indexOf('\r') !== -1) { 369 | return `"${field.replace(/"/g, '""')}"`; 370 | } 371 | 372 | return field; 373 | } 374 | 375 | /** 376 | * Format duration for display 377 | */ 378 | private formatDuration(duration: number): string { 379 | if (duration < 1000) { 380 | return `${duration}ms`; 381 | } else if (duration < 60000) { 382 | return `${(duration / 1000).toFixed(1)}s`; 383 | } else { 384 | const minutes = Math.floor(duration / 60000); 385 | const seconds = Math.floor((duration % 60000) / 1000); 386 | return `${minutes}m ${seconds}s`; 387 | } 388 | } 389 | 390 | /** 391 | * Generate unique entry ID 392 | */ 393 | private generateEntryId(): string { 394 | return `hist_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; 395 | } 396 | 397 | /** 398 | * Save history to localStorage 399 | */ 400 | private saveHistoryToStorage(): void { 401 | try { 402 | const history = this.commandHistory.value; 403 | localStorage.setItem('mcp_command_history', JSON.stringify(history)); 404 | } catch (error) { 405 | this.logger.error('Failed to save history to storage:', error); 406 | } 407 | } 408 | 409 | /** 410 | * Load history from localStorage 411 | */ 412 | private loadHistoryFromStorage(): void { 413 | try { 414 | console.log('[CommandHistoryManager] Loading history from localStorage...'); 415 | const stored = localStorage.getItem('mcp_command_history'); 416 | console.log('[CommandHistoryManager] Stored data:', stored); 417 | 418 | if (stored) { 419 | const history: CommandHistoryEntry[] = JSON.parse(stored); 420 | console.log('[CommandHistoryManager] Parsed history:', history.length, 'entries'); 421 | 422 | // Validate and filter valid entries 423 | const validHistory = history.filter(entry => 424 | entry.id && entry.command && entry.timestamp 425 | ); 426 | console.log('[CommandHistoryManager] Valid history:', validHistory.length, 'entries'); 427 | 428 | this.commandHistory.next(validHistory); 429 | this.logger.info(`Loaded ${validHistory.length} entries from history storage`); 430 | } else { 431 | console.log('[CommandHistoryManager] No stored history found'); 432 | this.commandHistory.next([]); 433 | } 434 | } catch (error) { 435 | console.error('[CommandHistoryManager] Error loading history:', error); 436 | this.logger.error('Failed to load history from storage:', error); 437 | this.commandHistory.next([]); 438 | } 439 | } 440 | 441 | /** 442 | * Download export content as file 443 | */ 444 | async downloadExport(content: string, filename: string, mimeType: string = 'text/plain;charset=utf-8'): Promise { 445 | try { 446 | const blob = new Blob([content], { type: mimeType }); 447 | const url = URL.createObjectURL(blob); 448 | 449 | const link = document.createElement('a'); 450 | link.href = url; 451 | link.download = filename; 452 | link.style.display = 'none'; 453 | 454 | document.body.appendChild(link); 455 | link.click(); 456 | document.body.removeChild(link); 457 | 458 | URL.revokeObjectURL(url); 459 | 460 | this.logger.info(`Downloaded export file: ${filename}`); 461 | return true; 462 | } catch (error) { 463 | this.logger.error('Failed to download export file:', error); 464 | return false; 465 | } 466 | } 467 | 468 | /** 469 | * Copy export content to clipboard 470 | */ 471 | async copyExportToClipboard(content: string): Promise { 472 | try { 473 | await navigator.clipboard.writeText(content); 474 | this.logger.info('Copied export content to clipboard'); 475 | return true; 476 | } catch (error) { 477 | this.logger.error('Failed to copy export content to clipboard:', error); 478 | return false; 479 | } 480 | } 481 | } --------------------------------------------------------------------------------