Ver Fonte

bundle the clickable-links plugin with Tabby

Eugene Pankov há 3 anos atrás
pai
commit
bbb02f4e64

+ 6 - 0
app/src/pluginBlacklist.ts

@@ -0,0 +1,6 @@
+export const PLUGIN_BLACKLIST = [
+    'terminus-shell-selector', // superseded by profiles
+    'terminus-scrollbar', // now useless
+    'terminus-clickable-links', // now bundled with Tabby
+    'tabby-clickable-links', // now bundled with Tabby
+]

+ 2 - 1
app/src/plugins.ts

@@ -2,6 +2,7 @@ import * as fs from 'mz/fs'
 import * as path from 'path'
 import * as remote from '@electron/remote'
 import { PluginInfo } from '../../tabby-core/src/api/mainProcess'
+import { PLUGIN_BLACKLIST } from './pluginBlacklist'
 
 const nodeModule = require('module') // eslint-disable-line @typescript-eslint/no-var-requires
 
@@ -109,7 +110,7 @@ export async function findPlugins (): Promise<PluginInfo[]> {
             })
         }
         for (const packageName of pluginNames) {
-            if (packageName.startsWith(PREFIX) || packageName.startsWith(LEGACY_PREFIX)) {
+            if ((packageName.startsWith(PREFIX) || packageName.startsWith(LEGACY_PREFIX)) && !PLUGIN_BLACKLIST.includes(packageName)) {
                 candidateLocations.push({ pluginDir, packageName })
             }
         }

+ 1 - 1
package.json

@@ -91,7 +91,7 @@
     "start": "cross-env TABBY_DEV=1 electron app --debug --inspect",
     "start:prod": "electron app --debug",
     "prod": "cross-env TABBY_DEV=1 electron app",
-    "docs": "typedoc --out docs/api --tsconfig tabby-core/tsconfig.typings.json tabby-core/src/index.ts && typedoc --out docs/api/terminal --tsconfig tabby-terminal/tsconfig.typings.json tabby-terminal/src/index.ts && typedoc --out docs/api/local --tsconfig tabby-local/tsconfig.typings.json tabby-local/src/index.ts && typedoc --out docs/api/settings --tsconfig tabby-settings/tsconfig.typings.json tabby-settings/src/index.ts",
+    "docs": "node scripts/build-docs.js",
     "lint": "eslint --ext ts */src */lib",
     "postinstall": "patch-package && node ./scripts/install-deps.js"
   },

+ 9 - 0
scripts/build-docs.js

@@ -0,0 +1,9 @@
+#!/usr/bin/env node
+const sh = require('shelljs')
+const vars = require('./vars')
+const log = require('npmlog')
+
+vars.packagesWithDocs.forEach(([dest, src]) => {
+    log.info('docs', src)
+    sh.exec(`yarn typedoc --out docs/api/${dest} --tsconfig ${src}/tsconfig.typings.json ${src}/src/index.ts`)
+})

+ 8 - 0
scripts/vars.js

@@ -25,6 +25,14 @@ exports.builtinPlugins = [
     'tabby-electron',
     'tabby-local',
     'tabby-plugin-manager',
+    'tabby-linkifier',
+]
+
+exports.packagesWithDocs = [
+    ['.', 'tabby-core'],
+    ['terminal', 'tabby-terminal'],
+    ['local', 'tabby-local'],
+    ['settings', 'tabby-settings'],
 ]
 
 exports.allPackages = [

+ 9 - 3
tabby-core/README.md

@@ -1,7 +1,13 @@
-Tabby Core Plugin
---------------------
+# Tabby Core Plugin
 
-See also: [Settings plugin API](./settings/), [Terminal plugin API](./terminal/), [Local terminal API](./local/)
+See also:
+
+* [Settings plugin API](./settings/)
+* [Terminal plugin API](./terminal/)
+* [Local terminal API](./local/)
+* [Linkifier plugin API](./linkifier/)
+
+This module provides:
 
 * tabbed interface services
 * toolbar UI

+ 1 - 1
tabby-core/src/api/index.ts

@@ -25,7 +25,7 @@ export { DockingService, Screen } from '../services/docking.service'
 export { Logger, ConsoleLogger, LogService } from '../services/log.service'
 export { HomeBaseService } from '../services/homeBase.service'
 export { HotkeysService } from '../services/hotkeys.service'
-export { KeyEventData, KeyName, Keystroke } from '../services/hotkeys.util'
+export { KeyEventData, KeyName, Keystroke, altKeyName, metaKeyName } from '../services/hotkeys.util'
 export { NotificationsService } from '../services/notifications.service'
 export { ThemesService } from '../services/themes.service'
 export { ProfilesService } from '../services/profiles.service'

+ 3 - 0
tabby-linkifier/README.md

@@ -0,0 +1,3 @@
+# Tabby Linkifier Plugin
+
+This plugin makes URLs, IPs and file paths in the terminal clickable and adds a context menu that allows quickly copying them.

+ 22 - 0
tabby-linkifier/package.json

@@ -0,0 +1,22 @@
+{
+  "name": "tabby-linkifier",
+  "version": "1.0.165-nightly.0",
+  "description": "Makes URLs, IPs and file paths clickable in Tabby",
+  "keywords": [
+    "tabby-builtin-plugin"
+  ],
+  "main": "dist/index.js",
+  "typings": "typings/index.d.ts",
+  "scripts": {
+    "build": "webpack --progress --color --display-modules",
+    "watch": "webpack --progress --color --watch"
+  },
+  "files": [
+    "typings"
+  ],
+  "author": "Eugene Pankov",
+  "license": "MIT",
+  "devDependencies": {
+    "untildify": "^4.0.0"
+  }
+}

+ 16 - 0
tabby-linkifier/src/api.ts

@@ -0,0 +1,16 @@
+import { BaseTerminalTabComponent } from 'tabby-terminal'
+
+export abstract class LinkHandler {
+    regex: RegExp
+    priority = 1
+
+    convert (uri: string, _tab?: BaseTerminalTabComponent): Promise<string>|string {
+        return uri
+    }
+
+    verify (_uri: string, _tab?: BaseTerminalTabComponent): Promise<boolean>|boolean {
+        return true
+    }
+
+    abstract handle (uri: string, tab?: BaseTerminalTabComponent): void
+}

+ 12 - 0
tabby-linkifier/src/config.ts

@@ -0,0 +1,12 @@
+import { ConfigProvider } from 'tabby-core'
+
+/** @hidden */
+export class ClickableLinksConfigProvider extends ConfigProvider {
+    defaults = {
+        clickableLinks: {
+            modifier: null,
+        },
+    }
+
+    platformDefaults = { }
+}

+ 67 - 0
tabby-linkifier/src/decorator.ts

@@ -0,0 +1,67 @@
+import { Inject, Injectable } from '@angular/core'
+import { ConfigService, PlatformService } from 'tabby-core'
+import { TerminalDecorator, BaseTerminalTabComponent } from 'tabby-terminal'
+
+import { LinkHandler } from './api'
+
+@Injectable()
+export class LinkHighlighterDecorator extends TerminalDecorator {
+    constructor (
+        private config: ConfigService,
+        private platform: PlatformService,
+        @Inject(LinkHandler) private handlers: LinkHandler[],
+    ) {
+        super()
+    }
+
+    attach (tab: BaseTerminalTabComponent): void {
+        if (!(tab.frontend as any).xterm) {
+            // not hterm
+            return
+        }
+
+        for (let handler of this.handlers) {
+            const getLink = async uri => handler.convert(uri, tab)
+            const openLink = async uri => handler.handle(await getLink(uri), tab)
+
+            ;(tab.frontend as any).xterm.registerLinkMatcher(
+                handler.regex,
+                (event: MouseEvent, uri: string) => {
+                    if (!this.willHandleEvent(event)) {
+                        return
+                    }
+                    openLink(uri)
+                },
+                {
+                    priority: handler.priority,
+                    validationCallback: async (uri: string, callback: (isValid: boolean) => void) => {
+                        callback(await handler.verify(await handler.convert(uri, tab), tab))
+                    },
+                    willLinkActivate: (event: MouseEvent, uri: string) => {
+                        if (event.button === 2) {
+                            this.platform.popupContextMenu([
+                                {
+                                    click: () => openLink(uri),
+                                    label: 'Open',
+                                },
+                                {
+                                    click: async () => {
+                                        this.platform.setClipboard({ text: await getLink(uri) })
+                                    },
+                                    label: 'Copy',
+                                },
+                            ])
+                            return false
+                        }
+                        return this.willHandleEvent(event)
+                    },
+                }
+            )
+        }
+    }
+
+    private willHandleEvent (event: MouseEvent) {
+        const modifier = this.config.store.clickableLinks.modifier
+        return !modifier || event[modifier]
+    }
+}

+ 108 - 0
tabby-linkifier/src/handlers.ts

@@ -0,0 +1,108 @@
+import * as fs from 'fs/promises'
+import * as path from 'path'
+import untildify from 'untildify'
+import { Injectable } from '@angular/core'
+import { ToastrService } from 'ngx-toastr'
+import { PlatformService } from 'tabby-core'
+import { BaseTerminalTabComponent } from 'tabby-terminal'
+
+import { LinkHandler } from './api'
+
+@Injectable()
+export class URLHandler extends LinkHandler {
+    // From https://urlregex.com/
+    regex = /((([A-Za-z]{3,9}:(?:\/\/)?)(?:[\-;:&=\+\$,\w]+@)?[A-Za-z0-9\.\-]+|(?:www\.|[\-;:&=\+\$,\w]+@)[A-Za-z0-9\.\-]+)((:((6553[0-5])|(655[0-2][0-9])|(65[0-4][0-9]{2})|(6[0-4][0-9]{3})|([1-5][0-9]{4})|([0-5]{1,5})|([0-9]{1,4})))?(?:\/[\+~%\/\.\w\-_]*)?\??(?:[\-\+=&;%@\.\w_]*)#?(?:[\.\!\/\\\w]*))?)/
+
+    priority = 5
+
+    constructor (private platform: PlatformService) {
+        super()
+    }
+
+    handle (uri: string) {
+        this.platform.openExternal(uri)
+    }
+}
+
+@Injectable()
+export class IPHandler extends LinkHandler {
+    regex = /\b((2[0-4]\d|25[0-5]|[01]?\d\d?)\.){3}(2[0-4]\d|25[0-5]|[01]?\d\d?)/
+
+    priority = 4
+
+    constructor (private platform: PlatformService) {
+        super()
+    }
+
+    handle (uri: string) {
+        this.platform.openExternal(`http://${uri}`)
+    }
+}
+
+export class BaseFileHandler extends LinkHandler {
+    constructor (
+        protected toastr: ToastrService,
+        protected platform: PlatformService,
+    ) {
+        super()
+    }
+
+    async handle (uri: string) {
+        try {
+            await this.platform.openExternal('file://' + uri)
+        } catch (err) {
+            this.toastr.error(err.toString())
+        }
+    }
+
+    async verify (uri: string): Promise<boolean> {
+        try {
+            await fs.access(uri)
+            return true
+        } catch {
+            return false
+        }
+    }
+
+    async convert (uri: string, tab?: BaseTerminalTabComponent): Promise<string> {
+        let p = untildify(uri)
+        if (!path.isAbsolute(p) && tab) {
+            const cwd = await tab.session?.getWorkingDirectory()
+            if (cwd) {
+                p = path.resolve(cwd, p)
+            }
+        }
+        return p
+    }
+}
+
+@Injectable()
+export class UnixFileHandler extends BaseFileHandler {
+    // Only absolute and home paths
+    regex = /[~]?(\/[\w\d.~-]{1,100})+/
+
+    constructor (
+        protected toastr: ToastrService,
+        protected platform: PlatformService,
+    ) {
+        super(toastr, platform)
+    }
+}
+
+
+@Injectable()
+export class WindowsFileHandler extends BaseFileHandler {
+    regex = /(([a-zA-Z]:|\\|~)\\[\w\-()\\\.]{1,1024}|"([a-zA-Z]:|\\)\\[\w\s\-()\\\.]{1,1024}")/
+
+    constructor (
+        protected toastr: ToastrService,
+        protected platform: PlatformService,
+    ) {
+        super(toastr, platform)
+    }
+
+    convert (uri: string, tab?: BaseTerminalTabComponent): Promise<string> {
+        const sanitizedUri = uri.replace(/"/g, '')
+        return super.convert(sanitizedUri, tab)
+    }
+}

+ 26 - 0
tabby-linkifier/src/index.ts

@@ -0,0 +1,26 @@
+import { NgModule } from '@angular/core'
+import { ToastrModule } from 'ngx-toastr'
+import { ConfigProvider } from 'tabby-core'
+import { TerminalDecorator } from 'tabby-terminal'
+
+import { LinkHandler } from './api'
+import { UnixFileHandler, WindowsFileHandler, URLHandler, IPHandler } from './handlers'
+import { LinkHighlighterDecorator } from './decorator'
+import { ClickableLinksConfigProvider } from './config'
+
+@NgModule({
+    imports: [
+        ToastrModule,
+    ],
+    providers: [
+        { provide: LinkHandler, useClass: URLHandler, multi: true },
+        { provide: LinkHandler, useClass: IPHandler, multi: true },
+        { provide: LinkHandler, useClass: UnixFileHandler, multi: true },
+        { provide: LinkHandler, useClass: WindowsFileHandler, multi: true },
+        { provide: TerminalDecorator, useClass: LinkHighlighterDecorator, multi: true },
+        { provide: ConfigProvider, useClass: ClickableLinksConfigProvider, multi: true },
+    ],
+})
+export default class LinkifierModule { }
+
+export * from './api'

+ 7 - 0
tabby-linkifier/tsconfig.json

@@ -0,0 +1,7 @@
+{
+  "extends": "../tsconfig.json",
+  "exclude": ["node_modules", "dist"],
+  "compilerOptions": {
+    "baseUrl": "src",
+  }
+}

+ 14 - 0
tabby-linkifier/tsconfig.typings.json

@@ -0,0 +1,14 @@
+{
+  "extends": "../tsconfig.json",
+  "exclude": ["node_modules", "dist", "typings"],
+  "compilerOptions": {
+    "baseUrl": "src",
+    "emitDeclarationOnly": true,
+    "declaration": true,
+    "declarationDir": "./typings",
+    "paths": {
+      "tabby-*": ["../../tabby-*"],
+      "*": ["../../app/node_modules/*"]
+    }
+  }
+}

+ 5 - 0
tabby-linkifier/webpack.config.js

@@ -0,0 +1,5 @@
+const config = require('../webpack.plugin.config')
+module.exports = config({
+    name: 'linkifier',
+    dirname: __dirname,
+})

+ 8 - 0
tabby-linkifier/yarn.lock

@@ -0,0 +1,8 @@
+# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
+# yarn lockfile v1
+
+
+untildify@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/untildify/-/untildify-4.0.0.tgz#2bc947b953652487e4600949fb091e3ae8cd919b"
+  integrity sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==

+ 1 - 2
tabby-local/README.md

@@ -1,5 +1,4 @@
-Tabby Local Plugin
----------------------
+# Tabby Local Plugin
 
 * local shells
 

+ 2 - 5
tabby-plugin-manager/src/services/pluginManager.service.ts

@@ -3,13 +3,10 @@ import { compare as semverCompare } from 'semver'
 import { Observable, from, forkJoin, map } from 'rxjs'
 import { Injectable, Inject } from '@angular/core'
 import { Logger, LogService, PlatformService, BOOTSTRAP_DATA, BootstrapData, PluginInfo } from 'tabby-core'
+import { PLUGIN_BLACKLIST } from '../../../app/src/pluginBlacklist'
 
 const OFFICIAL_NPM_ACCOUNT = 'eugenepankov'
 
-const BLACKLIST = [
-    'terminus-shell-selector', // superseded by profiles
-    'terminus-scrollbar', // now useless
-]
 
 @Injectable({ providedIn: 'root' })
 export class PluginManagerService {
@@ -69,7 +66,7 @@ export class PluginManagerService {
                 }))
             ),
             map(plugins => plugins.filter(x => x.packageName.startsWith(namePrefix))),
-            map(plugins => plugins.filter(x => !BLACKLIST.includes(x.packageName))),
+            map(plugins => plugins.filter(x => !PLUGIN_BLACKLIST.includes(x.packageName))),
             map(plugins => {
                 const mapping: Record<string, PluginInfo[]> = {}
                 for (const p of plugins) {

+ 1 - 2
tabby-settings/README.md

@@ -1,5 +1,4 @@
-Tabby Settings Plugin
-------------------------
+# Tabby Settings Plugin
 
 * tabbed settings interface
 

+ 1 - 1
tabby-settings/src/components/settingsTab.component.scss

@@ -16,7 +16,7 @@
 
         > .nav {
             padding: 20px 10px;
-            width: 212px;
+            width: 222px;
             flex: none;
             overflow-y: auto;
             flex-wrap: nowrap;

+ 1 - 2
tabby-terminal/README.md

@@ -1,5 +1,4 @@
-Tabby Terminal Plugin
-------------------------
+# Tabby Terminal Plugin
 
 * terminal tabs
 * terminal frontends

+ 203 - 169
tabby-terminal/src/components/terminalSettingsTab.component.pug

@@ -1,169 +1,203 @@
-h3.mb-3 Terminal
-
-.form-line(*ngIf='hostApp.platform !== Platform.Web')
-    .header
-        .title Frontend
-        .description Switches terminal frontend implementation (experimental)
-
-    select.form-control(
-        [(ngModel)]='config.store.terminal.frontend',
-        (ngModelChange)='config.save()',
-    )
-        option(value='xterm') xterm
-        option(value='xterm-webgl') xterm (WebGL)
-
-.form-line
-    .header
-        .title Terminal bell
-    .btn-group(
-        [(ngModel)]='config.store.terminal.bell',
-        (ngModelChange)='config.save()',
-        ngbRadioGroup
-    )
-        label.btn.btn-secondary(ngbButtonLabel)
-            input(
-                type='radio',
-                ngbButton,
-                [value]='"off"'
-            )
-            | Off
-        label.btn.btn-secondary(ngbButtonLabel)
-            input(
-                type='radio',
-                ngbButton,
-                [value]='"visual"'
-            )
-            | Visual
-        label.btn.btn-secondary(ngbButtonLabel)
-            input(
-                type='radio',
-                ngbButton,
-                [value]='"audible"'
-            )
-            | Audible
-
-.alert.alert-info.d-flex.align-items-center(*ngIf='config.store.terminal.bell != "audible" && (config.store.terminal.profile || "").startsWith("wsl")')
-    .mr-auto WSL terminal bell can only be muted via Volume Mixer
-    button.btn.btn-secondary((click)='openWSLVolumeMixer()') Show Mixer
-
-.form-line
-    .header
-        .title Right click
-        .description(*ngIf='config.store.terminal.rightClick == "paste"') Long-click for context menu
-    .btn-group(
-        [(ngModel)]='config.store.terminal.rightClick',
-        (ngModelChange)='config.save()',
-        ngbRadioGroup
-    )
-        label.btn.btn-secondary(ngbButtonLabel)
-            input(
-                type='radio',
-                ngbButton,
-                value='off'
-            )
-            | Off
-        label.btn.btn-secondary(ngbButtonLabel)
-            input(
-                type='radio',
-                ngbButton,
-                value='menu'
-            )
-            | Context menu
-        label.btn.btn-secondary(ngbButtonLabel)
-            input(
-                type='radio',
-                ngbButton,
-                value='paste'
-            )
-            | Paste
-
-.form-line
-    .header
-        .title Paste on middle-click
-
-    toggle(
-        [(ngModel)]='config.store.terminal.pasteOnMiddleClick',
-        (ngModelChange)='config.save()',
-    )
-
-.form-line(*ngIf='hostApp.platform !== Platform.Web')
-    .header
-        .title Auto-open a terminal on app start
-
-    toggle(
-        [(ngModel)]='config.store.terminal.autoOpen',
-        (ngModelChange)='config.save()',
-    )
-
-.form-line
-    .header
-        .title Restore terminal tabs on app start
-
-    toggle(
-        [(ngModel)]='config.store.recoverTabs',
-        (ngModelChange)='config.save()',
-    )
-
-.form-line
-    .header
-        .title Bracketed paste (requires shell support)
-        .description Prevents accidental execution of pasted commands
-    toggle(
-        [(ngModel)]='config.store.terminal.bracketedPaste',
-        (ngModelChange)='config.save()',
-    )
-
-.form-line
-    .header
-        .title Copy on select
-    toggle(
-        [(ngModel)]='config.store.terminal.copyOnSelect',
-        (ngModelChange)='config.save()',
-    )
-
-.form-line
-    .header
-        .title Scroll on input
-        .description Scrolls the terminal to the bottom on user input
-    toggle(
-        [(ngModel)]='config.store.terminal.scrollOnInput',
-        (ngModelChange)='config.save()',
-    )
-
-.form-line
-    .header
-        .title Use Alt key as the Meta key
-        .description Lets the shell handle Meta key instead of OS
-    toggle(
-        [(ngModel)]='config.store.terminal.altIsMeta',
-        (ngModelChange)='config.save()',
-    )
-
-.form-line
-    .header
-        .title Word separators
-        .description Double-click selection will stop at these characters
-    input.form-control(
-        type='text',
-        placeholder=' ()[]{}\'"',
-        [(ngModel)]='config.store.terminal.wordSeparator',
-        (ngModelChange)='config.save()',
-    )
-
-.form-line
-    .header
-        .title Warn on multi-line paste
-        .description Show a confirmation box when pasting multiple lines
-    toggle(
-        [(ngModel)]='config.store.terminal.warnOnMultilinePaste',
-        (ngModelChange)='config.save()',
-    )
-
-.form-line(*ngIf='hostApp.platform === Platform.Windows')
-    .header
-        .title Set Tabby as %COMSPEC%
-        .description Allows opening .bat files in tabs, but breaks some shells
-    toggle(
-        [(ngModel)]='config.store.terminal.setComSpec',
-        (ngModelChange)='config.save()',
-    )
+div
+    h3.mb-3 Rendering
+
+    .form-line(*ngIf='hostApp.platform !== Platform.Web')
+        .header
+            .title Frontend
+            .description Switches terminal frontend implementation (experimental)
+
+        select.form-control(
+            [(ngModel)]='config.store.terminal.frontend',
+            (ngModelChange)='config.save()',
+        )
+            option(value='xterm') xterm
+            option(value='xterm-webgl') xterm (WebGL)
+
+div.mt-4
+    h3 Keyboard
+
+    .form-line
+        .header
+            .title Use {{altKeyName}} as the Meta key
+            .description Lets the shell handle Meta key instead of OS
+        toggle(
+            [(ngModel)]='config.store.terminal.altIsMeta',
+            (ngModelChange)='config.save()',
+        )
+
+    .form-line
+        .header
+            .title Scroll on input
+            .description Scrolls the terminal to the bottom on user input
+        toggle(
+            [(ngModel)]='config.store.terminal.scrollOnInput',
+            (ngModelChange)='config.save()',
+        )
+
+div.mt-4
+    h3 Mouse
+
+    .form-line
+        .header
+            .title Right click
+            .description(*ngIf='config.store.terminal.rightClick == "paste"') Long-click for context menu
+        .btn-group(
+            [(ngModel)]='config.store.terminal.rightClick',
+            (ngModelChange)='config.save()',
+            ngbRadioGroup
+        )
+            label.btn.btn-secondary(ngbButtonLabel)
+                input(
+                    type='radio',
+                    ngbButton,
+                    value='off'
+                )
+                | Off
+            label.btn.btn-secondary(ngbButtonLabel)
+                input(
+                    type='radio',
+                    ngbButton,
+                    value='menu'
+                )
+                | Context menu
+            label.btn.btn-secondary(ngbButtonLabel)
+                input(
+                    type='radio',
+                    ngbButton,
+                    value='paste'
+                )
+                | Paste
+
+    .form-line
+        .header
+            .title Paste on middle-click
+
+        toggle(
+            [(ngModel)]='config.store.terminal.pasteOnMiddleClick',
+            (ngModelChange)='config.save()',
+        )
+
+    .form-line
+        .header
+            .title Word separators
+            .description Double-click selection will stop at these characters
+        input.form-control(
+            type='text',
+            placeholder=' ()[]{}\'"',
+            [(ngModel)]='config.store.terminal.wordSeparator',
+            (ngModelChange)='config.save()',
+        )
+
+    .form-line
+        .header
+            .title Require a key to click links
+            .description When enabled, links are only clickable while holding this key
+
+        select.form-control(
+            [(ngModel)]='config.store.clickableLinks.modifier',
+            (ngModelChange)='config.save()',
+        )
+            option([value]='null') None
+            option(value='ctrlKey') Ctrl
+            option(value='altKey') {{altKeyName}}
+            option(value='shiftKey') Shift
+            option(value='metaKey') {{metaKeyName}}
+
+div.mt-4
+    h3 Clipboard
+
+    .form-line
+        .header
+            .title Copy on select
+        toggle(
+            [(ngModel)]='config.store.terminal.copyOnSelect',
+            (ngModelChange)='config.save()',
+        )
+
+    .form-line
+        .header
+            .title Bracketed paste (requires shell support)
+            .description Prevents accidental execution of pasted commands
+        toggle(
+            [(ngModel)]='config.store.terminal.bracketedPaste',
+            (ngModelChange)='config.save()',
+        )
+
+    .form-line
+        .header
+            .title Warn on multi-line paste
+            .description Show a confirmation box when pasting multiple lines
+        toggle(
+            [(ngModel)]='config.store.terminal.warnOnMultilinePaste',
+            (ngModelChange)='config.save()',
+        )
+
+div.mt-4
+    h3 Sound
+
+    .form-line
+        .header
+            .title Terminal bell
+        .btn-group(
+            [(ngModel)]='config.store.terminal.bell',
+            (ngModelChange)='config.save()',
+            ngbRadioGroup
+        )
+            label.btn.btn-secondary(ngbButtonLabel)
+                input(
+                    type='radio',
+                    ngbButton,
+                    [value]='"off"'
+                )
+                | Off
+            label.btn.btn-secondary(ngbButtonLabel)
+                input(
+                    type='radio',
+                    ngbButton,
+                    [value]='"visual"'
+                )
+                | Visual
+            label.btn.btn-secondary(ngbButtonLabel)
+                input(
+                    type='radio',
+                    ngbButton,
+                    [value]='"audible"'
+                )
+                | Audible
+
+    .alert.alert-info.d-flex.align-items-center(*ngIf='config.store.terminal.bell != "audible" && (config.store.terminal.profile || "").startsWith("wsl")')
+        .mr-auto WSL terminal bell can only be muted via Volume Mixer
+        button.btn.btn-secondary((click)='openWSLVolumeMixer()') Show Mixer
+
+div.mt-4
+    h3 Startup
+
+    .form-line(*ngIf='hostApp.platform !== Platform.Web')
+        .header
+            .title Auto-open a terminal on app start
+
+        toggle(
+            [(ngModel)]='config.store.terminal.autoOpen',
+            (ngModelChange)='config.save()',
+        )
+
+    .form-line
+        .header
+            .title Restore terminal tabs on app start
+
+        toggle(
+            [(ngModel)]='config.store.recoverTabs',
+            (ngModelChange)='config.save()',
+        )
+
+div.mt-4(*ngIf='hostApp.platform === Platform.Windows')
+    h3 Windows
+
+    .form-line
+        .header
+            .title Set Tabby as %COMSPEC%
+            .description Allows opening .bat files in tabs, but breaks some shells
+        toggle(
+            [(ngModel)]='config.store.terminal.setComSpec',
+            (ngModelChange)='config.save()',
+        )

+ 3 - 1
tabby-terminal/src/components/terminalSettingsTab.component.ts

@@ -1,5 +1,5 @@
 import { Component, HostBinding } from '@angular/core'
-import { ConfigService, HostAppService, Platform, PlatformService } from 'tabby-core'
+import { ConfigService, HostAppService, Platform, PlatformService, altKeyName, metaKeyName } from 'tabby-core'
 
 /** @hidden */
 @Component({
@@ -7,6 +7,8 @@ import { ConfigService, HostAppService, Platform, PlatformService } from 'tabby-
 })
 export class TerminalSettingsTabComponent {
     Platform = Platform
+    altKeyName = altKeyName
+    metaKeyName = metaKeyName
 
     @HostBinding('class.content-box') true