diff --git a/demo/client.ts b/demo/client.ts
index c8fcfb79b2..3593004a35 100644
--- a/demo/client.ts
+++ b/demo/client.ts
@@ -189,6 +189,7 @@ if (document.location.pathname === '/test') {
   document.getElementById('powerline-symbol-test').addEventListener('click', powerlineSymbolTest);
   document.getElementById('underline-test').addEventListener('click', underlineTest);
   document.getElementById('ansi-colors').addEventListener('click', ansiColorsTest);
+  document.getElementById('osc-hyperlinks').addEventListener('click', addAnsiHyperlink);
   document.getElementById('add-decoration').addEventListener('click', addDecoration);
   document.getElementById('add-overview-ruler').addEventListener('click', addOverviewRuler);
 }
@@ -842,6 +843,26 @@ function ansiColorsTest() {
   }
 }
 
+function addAnsiHyperlink() {
+  term.write('\n\n\r');
+  term.writeln(`Regular link with no id:`);
+  term.writeln('\x1b]8;;https://github.com\x07GitHub\x1b]8;;\x07');
+  term.writeln('\x1b]8;;https://xtermjs.org\x07https://xtermjs.org\x1b]8;;\x07\x1b[C<- null cell');
+  term.writeln(`\nAdjacent links:`);
+  term.writeln('\x1b]8;;https://github.com\x07GitHub\x1b]8;;https://xtermjs.org\x07\x1b[32mxterm.js\x1b[0m\x1b]8;;\x07');
+  term.writeln(`\nShared ID link (underline should be shared):`);
+  term.writeln('╔════╗');
+  term.writeln('║\x1b]8;id=testid;https://github.com\x07GitH\x1b]8;;\x07║');
+  term.writeln('║\x1b]8;id=testid;https://github.com\x07ub\x1b]8;;\x07  ║');
+  term.writeln('╚════╝');
+  term.writeln(`\nWrapped link with no ID (not necessarily meant to share underline):`);
+  term.writeln('╔════╗');
+  term.writeln('║    ║');
+  term.writeln('║    ║');
+  term.writeln('╚════╝');
+  term.write('\x1b[3A\x1b[1C\x1b]8;;https://xtermjs.org\x07xter\x1b[B\x1b[4Dm.js\x1b]8;;\x07\x1b[2B\x1b[5D');
+}
+
 function addDecoration() {
   term.options['overviewRulerWidth'] = 15;
   const marker = term.registerMarker(1);
diff --git a/demo/index.html b/demo/index.html
index 836084b3a3..c38cb00727 100644
--- a/demo/index.html
+++ b/demo/index.html
@@ -79,6 +79,7 @@ <h3>Test</h3>
               <dd><button id="powerline-symbol-test" title="Write powerline symbol characters to the terminal (\ue0a0+)">Powerline symbol test</button></dd>
               <dd><button id="underline-test" title="Write text with Kitty's extended underline sequences">Underline test</button></dd>
 	            <dd><button id="ansi-colors" title="Write a wide range of ansi colors">Ansi colors test</button></dd>
+	            <dd><button id="osc-hyperlinks" title="Write some OSC 8 hyperlinks">Ansi hyperlinks test</button></dd>
 
               <dt>Decorations</dt>
               <dd><button id="add-decoration" title="Add a decoration to the terminal">Decoration</button></dd>
diff --git a/src/browser/OscLinkProvider.ts b/src/browser/OscLinkProvider.ts
new file mode 100644
index 0000000000..38c0710681
--- /dev/null
+++ b/src/browser/OscLinkProvider.ts
@@ -0,0 +1,110 @@
+/**
+ * Copyright (c) 2022 The xterm.js authors. All rights reserved.
+ * @license MIT
+ */
+
+import { ILink, ILinkProvider } from 'browser/Types';
+import { CellData } from 'common/buffer/CellData';
+import { IBufferService, IOptionsService, IOscLinkService } from 'common/services/Services';
+
+export class OscLinkProvider implements ILinkProvider {
+  constructor(
+    @IBufferService private readonly _bufferService: IBufferService,
+    @IOptionsService private readonly _optionsService: IOptionsService,
+    @IOscLinkService private readonly _oscLinkService: IOscLinkService
+  ) {
+  }
+
+  public provideLinks(y: number, callback: (links: ILink[] | undefined) => void): void {
+    const line = this._bufferService.buffer.lines.get(y - 1);
+    if (!line) {
+      callback(undefined);
+      return;
+    }
+
+    const result: ILink[] = [];
+    const linkHandler = this._optionsService.rawOptions.linkHandler;
+    const cell = new CellData();
+    const lineLength = line.getTrimmedLength();
+    let currentLinkId = -1;
+    let currentStart = -1;
+    let finishLink = false;
+    for (let x = 0; x < lineLength; x++) {
+      // Minor optimization, only check for content if there isn't a link in case the link ends with
+      // a null cell
+      if (currentStart === -1 && !line.hasContent(x)) {
+        continue;
+      }
+
+      line.loadCell(x, cell);
+      if (cell.hasExtendedAttrs() && cell.extended.urlId) {
+        if (currentStart === -1) {
+          currentStart = x;
+          currentLinkId = cell.extended.urlId;
+          continue;
+        } else {
+          finishLink = cell.extended.urlId !== currentLinkId;
+        }
+      } else {
+        if (currentStart !== -1) {
+          finishLink = true;
+        }
+      }
+
+      if (finishLink || (currentStart !== -1 && x === lineLength - 1)) {
+        const text = this._oscLinkService.getLinkData(currentLinkId)?.uri;
+        if (text) {
+          // OSC links always use underline and pointer decorations
+          result.push({
+            text,
+            // These ranges are 1-based
+            range: {
+              start: {
+                x: currentStart + 1,
+                y
+              },
+              end: {
+                // Offset end x if it's a link that ends on the last cell in the line
+                x: x + (!finishLink && x === lineLength - 1 ? 1 : 0),
+                y
+              }
+            },
+            activate: linkHandler?.activate || defaultActivate,
+            hover: linkHandler?.hover,
+            leave: linkHandler?.leave
+          });
+        }
+        finishLink = false;
+
+        // Clear link or start a new link if one starts immediately
+        if (cell.hasExtendedAttrs() && cell.extended.urlId) {
+          currentStart = x;
+          currentLinkId = cell.extended.urlId;
+        } else {
+          currentStart = -1;
+          currentLinkId = -1;
+        }
+      }
+    }
+
+    // TODO: Handle fetching and returning other link ranges to underline other links with the same id
+    callback(result);
+  }
+}
+
+function defaultActivate(e: MouseEvent, uri: string): void {
+  const answer = confirm(`Do you want to navigate to ${uri}?`);
+  if (answer) {
+    const newWindow = window.open();
+    if (newWindow) {
+      try {
+        newWindow.opener = null;
+      } catch {
+        // no-op, Electron can throw
+      }
+      newWindow.location.href = uri;
+    } else {
+      console.warn('Opening link blocked as opener could not be cleared');
+    }
+  }
+}
diff --git a/src/browser/Terminal.ts b/src/browser/Terminal.ts
index 57099ac238..aacd6cf703 100644
--- a/src/browser/Terminal.ts
+++ b/src/browser/Terminal.ts
@@ -55,6 +55,7 @@ import { BufferDecorationRenderer } from 'browser/decorations/BufferDecorationRe
 import { OverviewRulerRenderer } from 'browser/decorations/OverviewRulerRenderer';
 import { DecorationService } from 'common/services/DecorationService';
 import { IDecorationService } from 'common/services/Services';
+import { OscLinkProvider } from 'browser/OscLinkProvider';
 
 // Let it work inside Node.js for automated testing purposes.
 const document: Document = (typeof window !== 'undefined') ? window.document : null as any;
@@ -163,6 +164,7 @@ export class Terminal extends CoreTerminal implements ITerminal {
     this._setup();
 
     this.linkifier2 = this.register(this._instantiationService.createInstance(Linkifier2));
+    this.linkifier2.registerLinkProvider(this._instantiationService.createInstance(OscLinkProvider));
     this._decorationService = this._instantiationService.createInstance(DecorationService);
     this._instantiationService.setService(IDecorationService, this._decorationService);
 
diff --git a/src/common/CoreTerminal.ts b/src/common/CoreTerminal.ts
index af9ec3f9ef..6e318ce7c0 100644
--- a/src/common/CoreTerminal.ts
+++ b/src/common/CoreTerminal.ts
@@ -22,7 +22,7 @@
  */
 
 import { Disposable } from 'common/Lifecycle';
-import { IInstantiationService, IOptionsService, IBufferService, ILogService, ICharsetService, ICoreService, ICoreMouseService, IUnicodeService, IDirtyRowService, LogLevelEnum, ITerminalOptions } from 'common/services/Services';
+import { IInstantiationService, IOptionsService, IBufferService, ILogService, ICharsetService, ICoreService, ICoreMouseService, IUnicodeService, IDirtyRowService, LogLevelEnum, ITerminalOptions, IOscLinkService } from 'common/services/Services';
 import { InstantiationService } from 'common/services/InstantiationService';
 import { LogService } from 'common/services/LogService';
 import { BufferService, MINIMUM_COLS, MINIMUM_ROWS } from 'common/services/BufferService';
@@ -39,6 +39,7 @@ import { IFunctionIdentifier, IParams } from 'common/parser/Types';
 import { IBufferSet } from 'common/buffer/Types';
 import { InputHandler } from 'common/InputHandler';
 import { WriteBuffer } from 'common/input/WriteBuffer';
+import { OscLinkService } from 'common/services/OscLinkService';
 
 // Only trigger this warning a single time per session
 let hasWriteSyncWarnHappened = false;
@@ -49,6 +50,7 @@ export abstract class CoreTerminal extends Disposable implements ICoreTerminal {
   protected readonly _logService: ILogService;
   protected readonly _charsetService: ICharsetService;
   protected readonly _dirtyRowService: IDirtyRowService;
+  protected readonly _oscLinkService: IOscLinkService;
 
   public readonly coreMouseService: ICoreMouseService;
   public readonly coreService: ICoreService;
@@ -118,9 +120,11 @@ export abstract class CoreTerminal extends Disposable implements ICoreTerminal {
     this._instantiationService.setService(IUnicodeService, this.unicodeService);
     this._charsetService = this._instantiationService.createInstance(CharsetService);
     this._instantiationService.setService(ICharsetService, this._charsetService);
+    this._oscLinkService = this._instantiationService.createInstance(OscLinkService);
+    this._instantiationService.setService(IOscLinkService, this._oscLinkService);
 
     // Register input handler and handle/forward events
-    this._inputHandler = new InputHandler(this._bufferService, this._charsetService, this.coreService, this._dirtyRowService, this._logService, this.optionsService, this.coreMouseService, this.unicodeService);
+    this._inputHandler = new InputHandler(this._bufferService, this._charsetService, this.coreService, this._dirtyRowService, this._logService, this.optionsService, this._oscLinkService, this.coreMouseService, this.unicodeService);
     this.register(forwardEvent(this._inputHandler.onLineFeed, this._onLineFeed));
     this.register(this._inputHandler);
 
diff --git a/src/common/InputHandler.test.ts b/src/common/InputHandler.test.ts
index eac4105243..2fa4ac2fa2 100644
--- a/src/common/InputHandler.test.ts
+++ b/src/common/InputHandler.test.ts
@@ -11,7 +11,7 @@ import { CellData } from 'common/buffer/CellData';
 import { Attributes, UnderlineStyle } from 'common/buffer/Constants';
 import { AttributeData } from 'common/buffer/AttributeData';
 import { Params } from 'common/parser/Params';
-import { MockCoreService, MockBufferService, MockDirtyRowService, MockOptionsService, MockLogService, MockCoreMouseService, MockCharsetService, MockUnicodeService } from 'common/TestUtils.test';
+import { MockCoreService, MockBufferService, MockDirtyRowService, MockOptionsService, MockLogService, MockCoreMouseService, MockCharsetService, MockUnicodeService, MockOscLinkService } from 'common/TestUtils.test';
 import { IBufferService, ICoreService } from 'common/services/Services';
 import { DEFAULT_OPTIONS } from 'common/services/OptionsService';
 import { clone } from 'common/Clone';
@@ -67,7 +67,7 @@ describe('InputHandler', () => {
     bufferService.resize(80, 30);
     coreService = new CoreService(() => { }, bufferService, new MockLogService(), optionsService);
 
-    inputHandler = new TestInputHandler(bufferService, new MockCharsetService(), coreService, new MockDirtyRowService(), new MockLogService(), optionsService, new MockCoreMouseService(), new MockUnicodeService());
+    inputHandler = new TestInputHandler(bufferService, new MockCharsetService(), coreService, new MockDirtyRowService(), new MockLogService(), optionsService, new MockOscLinkService(), new MockCoreMouseService(), new MockUnicodeService());
   });
 
   describe('SL/SR/DECIC/DECDC', () => {
@@ -236,7 +236,7 @@ describe('InputHandler', () => {
   describe('setMode', () => {
     it('should toggle bracketedPasteMode', () => {
       const coreService = new MockCoreService();
-      const inputHandler = new TestInputHandler(new MockBufferService(80, 30), new MockCharsetService(), coreService, new MockDirtyRowService(), new MockLogService(), new MockOptionsService(), new MockCoreMouseService(), new MockUnicodeService());
+      const inputHandler = new TestInputHandler(new MockBufferService(80, 30), new MockCharsetService(), coreService, new MockDirtyRowService(), new MockLogService(), new MockOptionsService(), new MockOscLinkService(), new MockCoreMouseService(), new MockUnicodeService());
       // Set bracketed paste mode
       inputHandler.setModePrivate(Params.fromArray([2004]));
       assert.equal(coreService.decPrivateModes.bracketedPasteMode, true);
@@ -261,6 +261,7 @@ describe('InputHandler', () => {
         new MockDirtyRowService(),
         new MockLogService(),
         new MockOptionsService(),
+        new MockOscLinkService(),
         new MockCoreMouseService(),
         new MockUnicodeService()
       );
@@ -307,6 +308,7 @@ describe('InputHandler', () => {
         new MockDirtyRowService(),
         new MockLogService(),
         new MockOptionsService(),
+        new MockOscLinkService(),
         new MockCoreMouseService(),
         new MockUnicodeService()
       );
@@ -357,6 +359,7 @@ describe('InputHandler', () => {
         new MockDirtyRowService(),
         new MockLogService(),
         new MockOptionsService(),
+        new MockOscLinkService(),
         new MockCoreMouseService(),
         new MockUnicodeService()
       );
@@ -394,6 +397,7 @@ describe('InputHandler', () => {
         new MockDirtyRowService(),
         new MockLogService(),
         new MockOptionsService(),
+        new MockOscLinkService(),
         new MockCoreMouseService(),
         new MockUnicodeService()
       );
@@ -444,6 +448,7 @@ describe('InputHandler', () => {
         new MockDirtyRowService(),
         new MockLogService(),
         new MockOptionsService(),
+        new MockOscLinkService(),
         new MockCoreMouseService(),
         new MockUnicodeService()
       );
@@ -570,6 +575,7 @@ describe('InputHandler', () => {
         new MockDirtyRowService(),
         new MockLogService(),
         new MockOptionsService(),
+        new MockOscLinkService(),
         new MockCoreMouseService(),
         new MockUnicodeService()
       );
@@ -593,7 +599,7 @@ describe('InputHandler', () => {
 
     beforeEach(() => {
       bufferService = new MockBufferService(80, 30);
-      handler = new TestInputHandler(bufferService, new MockCharsetService(), new MockCoreService(), new MockDirtyRowService(), new MockLogService(), new MockOptionsService(), new MockCoreMouseService(), new MockUnicodeService());
+      handler = new TestInputHandler(bufferService, new MockCharsetService(), new MockCoreService(), new MockDirtyRowService(), new MockLogService(), new MockOptionsService(), new MockOscLinkService(), new MockCoreMouseService(), new MockUnicodeService());
     });
     it('should handle DECSET/DECRST 47 (alt screen buffer)', async () => {
       await handler.parseP('\x1b[?47h\r\n\x1b[31mJUNK\x1b[?47lTEST');
@@ -790,7 +796,7 @@ describe('InputHandler', () => {
   describe('colon notation', () => {
     let inputHandler2: TestInputHandler;
     beforeEach(() => {
-      inputHandler2 = new TestInputHandler(bufferService, new MockCharsetService(), coreService, new MockDirtyRowService(), new MockLogService(), optionsService, new MockCoreMouseService(), new MockUnicodeService());
+      inputHandler2 = new TestInputHandler(bufferService, new MockCharsetService(), coreService, new MockDirtyRowService(), new MockLogService(), optionsService, new MockOscLinkService(), new MockCoreMouseService(), new MockUnicodeService());
     });
     describe('should equal to semicolon', () => {
       it('CSI 38:2::50:100:150 m', async () => {
@@ -2156,7 +2162,7 @@ describe('InputHandler - async handlers', () => {
     coreService = new CoreService(() => { }, bufferService, new MockLogService(), optionsService);
     coreService.onData(data => { console.log(data); });
 
-    inputHandler = new TestInputHandler(bufferService, new MockCharsetService(), coreService, new MockDirtyRowService(), new MockLogService(), optionsService, new MockCoreMouseService(), new MockUnicodeService());
+    inputHandler = new TestInputHandler(bufferService, new MockCharsetService(), coreService, new MockDirtyRowService(), new MockLogService(), optionsService, new MockOscLinkService(), new MockCoreMouseService(), new MockUnicodeService());
   });
 
   it('async CUP with CPR check', async () => {
diff --git a/src/common/InputHandler.ts b/src/common/InputHandler.ts
index d5b8d9481c..5a0725a496 100644
--- a/src/common/InputHandler.ts
+++ b/src/common/InputHandler.ts
@@ -4,7 +4,7 @@
  * @license MIT
  */
 
-import { IInputHandler, IAttributeData, IDisposable, IWindowOptions, IColorEvent, IParseStack, ColorIndex, ColorRequestType } from 'common/Types';
+import { IInputHandler, IAttributeData, IDisposable, IWindowOptions, IColorEvent, IParseStack, ColorIndex, ColorRequestType, IOscLinkData } from 'common/Types';
 import { C0, C1 } from 'common/data/EscapeSequences';
 import { CHARSETS, DEFAULT_CHARSET } from 'common/data/Charsets';
 import { EscapeSequenceParser } from 'common/parser/EscapeSequenceParser';
@@ -17,7 +17,7 @@ import { IParsingState, IDcsHandler, IEscapeSequenceParser, IParams, IFunctionId
 import { NULL_CELL_CODE, NULL_CELL_WIDTH, Attributes, FgFlags, BgFlags, Content, UnderlineStyle } from 'common/buffer/Constants';
 import { CellData } from 'common/buffer/CellData';
 import { AttributeData } from 'common/buffer/AttributeData';
-import { ICoreService, IBufferService, IOptionsService, ILogService, IDirtyRowService, ICoreMouseService, ICharsetService, IUnicodeService, LogLevelEnum } from 'common/services/Services';
+import { ICoreService, IBufferService, IOptionsService, ILogService, IDirtyRowService, ICoreMouseService, ICharsetService, IUnicodeService, LogLevelEnum, IOscLinkService } from 'common/services/Services';
 import { OscHandler } from 'common/parser/OscParser';
 import { DcsHandler } from 'common/parser/DcsParser';
 import { IBuffer } from 'common/buffer/Types';
@@ -214,8 +214,6 @@ class DECRQSS implements IDcsHandler {
  * @vt: #N  DCS   XTSETTCAP   "Set Terminfo Data"  "DCS + p Pt ST"   "Set Terminfo Data."
  */
 
-
-
 /**
  * The terminal's standard implementation of IInputHandler, this handles all
  * input from the Parser.
@@ -230,6 +228,7 @@ export class InputHandler extends Disposable implements IInputHandler {
   private _workCell: CellData = new CellData();
   private _windowTitle = '';
   private _iconName = '';
+  private _currentLinkId?: number;
   protected _windowTitleStack: string[] = [];
   protected _iconNameStack: string[] = [];
 
@@ -281,6 +280,7 @@ export class InputHandler extends Disposable implements IInputHandler {
     private readonly _dirtyRowService: IDirtyRowService,
     private readonly _logService: ILogService,
     private readonly _optionsService: IOptionsService,
+    private readonly _oscLinkService: IOscLinkService,
     private readonly _coreMouseService: ICoreMouseService,
     private readonly _unicodeService: IUnicodeService,
     private readonly _parser: IEscapeSequenceParser = new EscapeSequenceParser()
@@ -403,6 +403,8 @@ export class InputHandler extends Disposable implements IInputHandler {
     //   5 - Change Special Color Number
     //   6 - Enable/disable Special Color Number c
     //   7 - current directory? (not in xterm spec, see https://gitlab.com/gnachman/iterm2/issues/3939)
+    //   8 - create hyperlink (not in xterm spec, see https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda)
+    this._parser.registerOscHandler(8, new OscHandler(data => this.setHyperlink(data)));
     //  10 - Change VT100 text foreground color to Pt.
     this._parser.registerOscHandler(10, new OscHandler(data => this.setOrReportFgColor(data)));
     //  11 - Change VT100 text background color to Pt.
@@ -637,6 +639,9 @@ export class InputHandler extends Disposable implements IInputHandler {
       if (screenReaderMode) {
         this._onA11yChar.fire(stringFromCodePoint(code));
       }
+      if (this._currentLinkId !== undefined) {
+        this._oscLinkService.addLineToLink(this._currentLinkId, this._activeBuffer.ybase + this._activeBuffer.y);
+      }
 
       // insert combining char at last cursor position
       // this._activeBuffer.x should never be 0 for a combining char
@@ -2495,6 +2500,7 @@ export class InputHandler extends Disposable implements IInputHandler {
       } else if (p === 24) {
         // not underlined
         attr.fg &= ~FgFlags.UNDERLINE;
+        this._processUnderline(UnderlineStyle.NONE, attr);
       } else if (p === 25) {
         // not blink
         attr.fg &= ~FgFlags.BLINK;
@@ -2889,6 +2895,62 @@ export class InputHandler extends Disposable implements IInputHandler {
     return true;
   }
 
+  /**
+   * OSC 8 ; <params> ; <uri> ST - create hyperlink
+   * OSC 8 ; ; ST - finish hyperlink
+   *
+   * Test case:
+   *
+   * ```sh
+   * printf '\e]8;;http://example.com\e\\This is a link\e]8;;\e\\\n'
+   * ```
+   *
+   * @vt: #Y    OSC    8    "Create hyperlink"   "OSC 8 ; params ; uri BEL" "Create a hyperlink to `uri` using `params`."
+   * `uri` is a hyperlink starting with `http://`, `https://`, `ftp://`, `file://` or `mailto://`. `params` is an
+   * optional list of key=value assignments, separated by the : character. Example: `id=xyz123:foo=bar:baz=quux`.
+   * Currently only the id key is defined. Cells that share the same ID and URI share hover feedback.
+   * Use `OSC 8 ; ; BEL` to finish the current hyperlink.
+   */
+  public setHyperlink(data: string): boolean {
+    const args = data.split(';');
+    if (args.length < 2) {
+      return false;
+    }
+    if (args[1]) {
+      return this._createHyperlink(args[0], args[1]);
+    }
+    if (args[0]) {
+      return false;
+    }
+    return this._finishHyperlink();
+  }
+
+  private _createHyperlink(params: string, uri: string): boolean {
+    // It's legal to open a new hyperlink without explicitly finishing the previous one
+    if (this._currentLinkId !== undefined) {
+      this._finishHyperlink();
+    }
+    const parsedParams = params.split(':');
+    let id: string | undefined;
+    const idParamIndex = parsedParams.findIndex(e => e.startsWith('id='));
+    if (idParamIndex !== -1) {
+      id = parsedParams[idParamIndex].slice(3) || undefined;
+    }
+    this._curAttrData.extended = this._curAttrData.extended.clone();
+    this._currentLinkId = this._oscLinkService.registerLink({ id, uri });
+    this._curAttrData.extended.urlId = this._currentLinkId;
+    this._curAttrData.updateExtended();
+    return true;
+  }
+
+  private _finishHyperlink(): boolean {
+    this._curAttrData.extended = this._curAttrData.extended.clone();
+    this._curAttrData.extended.urlId = 0;
+    this._curAttrData.updateExtended();
+    this._currentLinkId = undefined;
+    return true;
+  }
+
   // special colors - OSC 10 | 11 | 12
   private _specialColors = [ColorIndex.FOREGROUND, ColorIndex.BACKGROUND, ColorIndex.CURSOR];
 
diff --git a/src/common/TestUtils.test.ts b/src/common/TestUtils.test.ts
index ff1e1b4691..24c756a43e 100644
--- a/src/common/TestUtils.test.ts
+++ b/src/common/TestUtils.test.ts
@@ -3,13 +3,13 @@
  * @license MIT
  */
 
-import { IBufferService, ICoreService, ILogService, IOptionsService, ITerminalOptions, IDirtyRowService, ICoreMouseService, ICharsetService, IUnicodeService, IUnicodeVersionProvider, LogLevelEnum, IDecorationService, IInternalDecoration } from 'common/services/Services';
+import { IBufferService, ICoreService, ILogService, IOptionsService, ITerminalOptions, IDirtyRowService, ICoreMouseService, ICharsetService, IUnicodeService, IUnicodeVersionProvider, LogLevelEnum, IDecorationService, IInternalDecoration, IOscLinkService } from 'common/services/Services';
 import { IEvent, EventEmitter } from 'common/EventEmitter';
 import { clone } from 'common/Clone';
 import { DEFAULT_OPTIONS } from 'common/services/OptionsService';
 import { IBufferSet, IBuffer } from 'common/buffer/Types';
 import { BufferSet } from 'common/buffer/BufferSet';
-import { IDecPrivateModes, ICoreMouseEvent, CoreMouseEventType, ICharset, IModes, IAttributeData } from 'common/Types';
+import { IDecPrivateModes, ICoreMouseEvent, CoreMouseEventType, ICharset, IModes, IAttributeData, IOscLinkData } from 'common/Types';
 import { UnicodeV6 } from 'common/input/UnicodeV6';
 import { IDecorationOptions, IDecoration } from 'xterm';
 
@@ -138,6 +138,18 @@ export class MockOptionsService implements IOptionsService {
   }
 }
 
+export class MockOscLinkService implements IOscLinkService {
+  public serviceBrand: any;
+  public registerLink(linkData: IOscLinkData): number {
+    return 1;
+  }
+  public getLinkData(linkId: number): IOscLinkData | undefined {
+    return undefined;
+  }
+  public addLineToLink(linkId: number, y: number): void {
+  }
+}
+
 // defaults to V6 always to keep tests passing
 export class MockUnicodeService implements IUnicodeService {
   public serviceBrand: any;
diff --git a/src/common/Types.d.ts b/src/common/Types.d.ts
index 56815da0dd..129f8e1b41 100644
--- a/src/common/Types.d.ts
+++ b/src/common/Types.d.ts
@@ -9,6 +9,7 @@ import { IDeleteEvent, IInsertEvent } from 'common/CircularList';
 import { IParams } from 'common/parser/Types';
 import { ICoreMouseService, ICoreService, IOptionsService, IUnicodeService } from 'common/services/Services';
 import { IBufferSet } from 'common/buffer/Types';
+import { UnderlineStyle } from 'common/buffer/Constants';
 
 export interface ICoreTerminal {
   coreMouseService: ICoreMouseService;
@@ -114,12 +115,24 @@ export type IColorRGB = [number, number, number];
 
 export interface IExtendedAttrs {
   ext: number;
-  underlineStyle: number;
+  underlineStyle: UnderlineStyle;
   underlineColor: number;
+  urlId: number;
   clone(): IExtendedAttrs;
   isEmpty(): boolean;
 }
 
+/**
+ * Tracks the current hyperlink. Since these are treated as extended attirbutes, these get passed on
+ * to the linkifier when anything is printed. Doing it this way ensures that even when the cursor
+ * moves around unexpectedly the link is tracked, as opposed to using a start position and
+ * finalizing it at the end.
+ */
+export interface IOscLinkData {
+  id?: string;
+  uri: string;
+}
+
 /** Attribute data */
 export interface IAttributeData {
   fg: number;
diff --git a/src/common/buffer/AttributeData.ts b/src/common/buffer/AttributeData.ts
index b51f7ecbe2..3af3d29393 100644
--- a/src/common/buffer/AttributeData.ts
+++ b/src/common/buffer/AttributeData.ts
@@ -35,7 +35,12 @@ export class AttributeData implements IAttributeData {
   // flags
   public isInverse(): number       { return this.fg & FgFlags.INVERSE; }
   public isBold(): number          { return this.fg & FgFlags.BOLD; }
-  public isUnderline(): number     { return this.fg & FgFlags.UNDERLINE; }
+  public isUnderline(): number     {
+    if (this.hasExtendedAttrs() && this.extended.underlineStyle !== UnderlineStyle.NONE) {
+      return 1;
+    }
+    return this.fg & FgFlags.UNDERLINE;
+  }
   public isBlink(): number         { return this.fg & FgFlags.BLINK; }
   public isInvisible(): number     { return this.fg & FgFlags.INVISIBLE; }
   public isItalic(): number        { return this.bg & BgFlags.ITALIC; }
@@ -128,10 +133,22 @@ export class AttributeData implements IAttributeData {
  */
 export class ExtendedAttrs implements IExtendedAttrs {
   private _ext: number = 0;
-  public get ext(): number { return this._ext; }
+  public get ext(): number {
+    if (this._urlId) {
+      return (
+        (this._ext & ~ExtFlags.UNDERLINE_STYLE) |
+        (this.underlineStyle << 26)
+      );
+    }
+    return this._ext;
+  }
   public set ext(value: number) { this._ext = value; }
 
   public get underlineStyle(): UnderlineStyle {
+    // Always return the URL style if it has one
+    if (this._urlId) {
+      return UnderlineStyle.DASHED;
+    }
     return (this._ext & ExtFlags.UNDERLINE_STYLE) >> 26;
   }
   public set underlineStyle(value: UnderlineStyle) {
@@ -147,16 +164,24 @@ export class ExtendedAttrs implements IExtendedAttrs {
     this._ext |= value & (Attributes.CM_MASK | Attributes.RGB_MASK);
   }
 
+  private _urlId: number = 0;
+  public get urlId(): number {
+    return this._urlId;
+  }
+  public set urlId(value: number) {
+    this._urlId = value;
+  }
+
   constructor(
-    underlineStyle: UnderlineStyle = UnderlineStyle.NONE,
-    underlineColor: number = Attributes.CM_DEFAULT
+    ext: number = 0,
+    urlId: number = 0
   ) {
-    this.underlineStyle = underlineStyle;
-    this.underlineColor = underlineColor;
+    this._ext = ext;
+    this._urlId = urlId;
   }
 
   public clone(): IExtendedAttrs {
-    return new ExtendedAttrs(this.underlineStyle, this.underlineColor);
+    return new ExtendedAttrs(this._ext, this._urlId);
   }
 
   /**
@@ -164,6 +189,6 @@ export class ExtendedAttrs implements IExtendedAttrs {
    * that needs to be persistant in the buffer.
    */
   public isEmpty(): boolean {
-    return this.underlineStyle === UnderlineStyle.NONE;
+    return this.underlineStyle === UnderlineStyle.NONE && this._urlId === 0;
   }
 }
diff --git a/src/common/services/BufferService.ts b/src/common/services/BufferService.ts
index bba60dd8a3..e3b7dcd87a 100644
--- a/src/common/services/BufferService.ts
+++ b/src/common/services/BufferService.ts
@@ -32,13 +32,11 @@ export class BufferService extends Disposable implements IBufferService {
   /** An IBufferline to clone/copy from for new blank lines */
   private _cachedBlankLine: IBufferLine | undefined;
 
-  constructor(
-    @IOptionsService private _optionsService: IOptionsService
-  ) {
+  constructor(@IOptionsService optionsService: IOptionsService) {
     super();
-    this.cols = Math.max(_optionsService.rawOptions.cols || 0, MINIMUM_COLS);
-    this.rows = Math.max(_optionsService.rawOptions.rows || 0, MINIMUM_ROWS);
-    this.buffers = new BufferSet(_optionsService, this);
+    this.cols = Math.max(optionsService.rawOptions.cols || 0, MINIMUM_COLS);
+    this.rows = Math.max(optionsService.rawOptions.rows || 0, MINIMUM_ROWS);
+    this.buffers = new BufferSet(optionsService, this);
   }
 
   public dispose(): void {
diff --git a/src/common/services/OptionsService.ts b/src/common/services/OptionsService.ts
index ab9edfbfb2..744903f177 100644
--- a/src/common/services/OptionsService.ts
+++ b/src/common/services/OptionsService.ts
@@ -24,6 +24,7 @@ export const DEFAULT_OPTIONS: Readonly<ITerminalOptions> = {
   fontWeightBold: 'bold',
   lineHeight: 1.0,
   letterSpacing: 0,
+  linkHandler: null,
   logLevel: 'info',
   scrollback: 1000,
   scrollSensitivity: 1,
diff --git a/src/common/services/OscLinkService.test.ts b/src/common/services/OscLinkService.test.ts
new file mode 100644
index 0000000000..5000e8e273
--- /dev/null
+++ b/src/common/services/OscLinkService.test.ts
@@ -0,0 +1,44 @@
+/**
+ * Copyright (c) 2020 The xterm.js authors. All rights reserved.
+ * @license MIT
+ */
+
+import { assert } from 'chai';
+import { AttributeData } from 'common/buffer/AttributeData';
+import { BufferService } from 'common/services/BufferService';
+import { OptionsService } from 'common/services/OptionsService';
+import { OscLinkService } from 'common/services/OscLinkService';
+import { IBufferService, IOptionsService, IOscLinkService } from 'common/services/Services';
+
+describe('OscLinkService', () => {
+  describe('constructor', () => {
+    let bufferService: IBufferService;
+    let optionsService: IOptionsService;
+    let oscLinkService: IOscLinkService;
+    beforeEach(() => {
+      optionsService = new OptionsService({ rows: 3, cols: 10 });
+      bufferService = new BufferService(optionsService);
+      oscLinkService = new OscLinkService(bufferService);
+    });
+
+    it('link IDs are created and fetched consistently', () => {
+      const linkId = oscLinkService.registerLink({ id: 'foo', uri: 'bar' });
+      assert.ok(linkId);
+      assert.equal(oscLinkService.registerLink({ id: 'foo', uri: 'bar' }), linkId);
+    });
+
+    it('should dispose the link ID when the last marker is trimmed from the buffer', () => {
+      // Activate the alt buffer to get 0 scrollback
+      bufferService.buffers.activateAltBuffer();
+      const linkId = oscLinkService.registerLink({ id: 'foo', uri: 'bar' });
+      assert.ok(linkId);
+      bufferService.scroll(new AttributeData());
+      assert.notStrictEqual(oscLinkService.registerLink({ id: 'foo', uri: 'bar' }), linkId);
+    });
+
+    it('should fetch link data from link id', () => {
+      const linkId = oscLinkService.registerLink({ id: 'foo', uri: 'bar' });
+      assert.deepStrictEqual(oscLinkService.getLinkData(linkId), { id: 'foo', uri: 'bar' });
+    });
+  });
+});
diff --git a/src/common/services/OscLinkService.ts b/src/common/services/OscLinkService.ts
new file mode 100644
index 0000000000..13bd8aa4bd
--- /dev/null
+++ b/src/common/services/OscLinkService.ts
@@ -0,0 +1,115 @@
+/**
+ * Copyright (c) 2022 The xterm.js authors. All rights reserved.
+ * @license MIT
+ */
+import { IBufferService, IOscLinkService } from 'common/services/Services';
+import { IMarker, IOscLinkData } from 'common/Types';
+
+export class OscLinkService implements IOscLinkService {
+  public serviceBrand: any;
+
+  private _nextId = 1;
+
+  /**
+   * A map of the link key to link entry. This is used to add additional lines to links with ids.
+   */
+  private _entriesWithId: Map<string, IOscLinkEntryWithId> = new Map();
+
+  /**
+   * A map of the link id to the link entry. The "link id" (number) which is the numberic
+   * representation of a unique link should not be confused with "id" (string) which comes in with
+   * `id=` in the OSC link's properties.
+   */
+  private _dataByLinkId: Map<number, IOscLinkEntryNoId | IOscLinkEntryWithId> = new Map();
+
+  constructor(
+    @IBufferService private readonly _bufferService: IBufferService
+  ) {
+  }
+
+  public registerLink(data: IOscLinkData): number {
+    const buffer = this._bufferService.buffer;
+
+    // Links with no id will only ever be registered a single time
+    if (data.id === undefined) {
+      const marker = buffer.addMarker(buffer.ybase + buffer.y);
+      const entry: IOscLinkEntryNoId = {
+        data,
+        id: this._nextId++,
+        lines: [marker]
+      };
+      marker.onDispose(() => this._removeMarkerFromLink(entry, marker));
+      this._dataByLinkId.set(entry.id, entry);
+      return entry.id;
+    }
+
+    // Add the line to the link if it already exists
+    const castData = data as Required<IOscLinkData>;
+    const key = this._getEntryIdKey(castData);
+    const match = this._entriesWithId.get(key);
+    if (match) {
+      this.addLineToLink(match.id, buffer.ybase + buffer.y);
+      return match.id;
+    }
+
+    // Create the link
+    const marker = buffer.addMarker(buffer.ybase + buffer.y);
+    const entry: IOscLinkEntryWithId = {
+      id: this._nextId++,
+      key: this._getEntryIdKey(castData),
+      data: castData,
+      lines: [marker]
+    };
+    marker.onDispose(() => this._removeMarkerFromLink(entry, marker));
+    this._entriesWithId.set(entry.key, entry);
+    this._dataByLinkId.set(entry.id, entry);
+    return entry.id;
+  }
+
+  public addLineToLink(linkId: number, y: number): void {
+    const entry = this._dataByLinkId.get(linkId);
+    if (!entry) {
+      return;
+    }
+    if (entry.lines.every(e => e.line !== y)) {
+      const marker = this._bufferService.buffer.addMarker(y);
+      entry.lines.push(marker);
+      marker.onDispose(() => this._removeMarkerFromLink(entry, marker));
+    }
+  }
+
+  public getLinkData(linkId: number): IOscLinkData | undefined {
+    return this._dataByLinkId.get(linkId)?.data;
+  }
+
+  private _getEntryIdKey(linkData: Required<IOscLinkData>): string {
+    return `${linkData.id};;${linkData.uri}`;
+  }
+
+  private _removeMarkerFromLink(entry: IOscLinkEntryNoId | IOscLinkEntryWithId, marker: IMarker): void {
+    const index = entry.lines.indexOf(marker);
+    if (index === -1) {
+      return;
+    }
+    entry.lines.splice(index, 1);
+    if (entry.lines.length === 0) {
+      if (entry.data.id !== undefined) {
+        this._entriesWithId.delete((entry as IOscLinkEntryWithId).key);
+      }
+      this._dataByLinkId.delete(entry.id);
+    }
+  }
+}
+
+interface IOscLinkEntry<T extends IOscLinkData> {
+  data: T;
+  id: number;
+  lines: IMarker[];
+}
+
+interface IOscLinkEntryNoId extends IOscLinkEntry<IOscLinkData> {
+}
+
+interface IOscLinkEntryWithId extends IOscLinkEntry<Required<IOscLinkData>> {
+  key: string;
+}
diff --git a/src/common/services/Services.ts b/src/common/services/Services.ts
index 817a7680c1..82ad735543 100644
--- a/src/common/services/Services.ts
+++ b/src/common/services/Services.ts
@@ -5,9 +5,9 @@
 
 import { IEvent, IEventEmitter } from 'common/EventEmitter';
 import { IBuffer, IBufferSet } from 'common/buffer/Types';
-import { IDecPrivateModes, ICoreMouseEvent, CoreMouseEncoding, ICoreMouseProtocol, CoreMouseEventType, ICharset, IWindowOptions, IModes, IAttributeData, ScrollSource, IDisposable, IColorRGB, IColor, CursorStyle } from 'common/Types';
+import { IDecPrivateModes, ICoreMouseEvent, CoreMouseEncoding, ICoreMouseProtocol, CoreMouseEventType, ICharset, IWindowOptions, IModes, IAttributeData, ScrollSource, IDisposable, IColorRGB, IColor, CursorStyle, IOscLinkData } from 'common/Types';
 import { createDecorator } from 'common/services/ServiceRegistry';
-import { IDecorationOptions, IDecoration } from 'xterm';
+import { IDecorationOptions, IDecoration, ILinkHandler } from 'xterm';
 
 export const IBufferService = createDecorator<IBufferService>('BufferService');
 export interface IBufferService {
@@ -223,6 +223,7 @@ export interface ITerminalOptions {
   fontWeightBold: FontWeight;
   letterSpacing: number;
   lineHeight: number;
+  linkHandler: ILinkHandler | null;
   logLevel: LogLevel;
   macOptionIsMeta: boolean;
   macOptionClickForcesSelection: boolean;
@@ -272,6 +273,22 @@ export interface ITheme {
   extendedAnsi?: string[];
 }
 
+export const IOscLinkService = createDecorator<IOscLinkService>('OscLinkService');
+export interface IOscLinkService {
+  serviceBrand: undefined;
+  /**
+   * Registers a link to the service, returning the link ID. The link data is managed by this
+   * service and will be freed when this current cursor position is trimmed off the buffer.
+   */
+  registerLink(linkData: IOscLinkData): number;
+  /**
+   * Adds a line to a link if needed.
+   */
+  addLineToLink(linkId: number, y: number): void;
+  /** Get the link data associated with a link ID. */
+  getLinkData(linkId: number): IOscLinkData | undefined;
+}
+
 export const IUnicodeService = createDecorator<IUnicodeService>('UnicodeService');
 export interface IUnicodeService {
   serviceBrand: undefined;
diff --git a/typings/xterm.d.ts b/typings/xterm.d.ts
index 05058d04df..2fa1742174 100644
--- a/typings/xterm.d.ts
+++ b/typings/xterm.d.ts
@@ -128,6 +128,14 @@ declare module 'xterm' {
      */
     lineHeight?: number;
 
+    /**
+     * The handler for OSC 8 hyperlinks. Links will use the `confirm` browser
+     * API if no link handler is set. Consider the security of users when using
+     * this, there should be some tooltip or prompt when hovering or activating
+     * the link.
+     */
+    linkHandler?: ILinkHandler | null;
+
     /**
      * What log level to use, this will log for all levels below and including
      * what is set:
@@ -1101,6 +1109,34 @@ declare module 'xterm' {
     y: number;
   }
 
+  /**
+   * A link handler for OSC 8 hyperlinks.
+   */
+  interface ILinkHandler {
+    /**
+     * Calls when the link is activated.
+     * @param event The mouse event triggering the callback.
+     * @param text The text of the link.
+     */
+     activate(event: MouseEvent, text: string): void;
+
+     /**
+      * Called when the mouse hovers the link. To use this to create a DOM-based hover tooltip,
+      * create the hover element within `Terminal.element` and add the `xterm-hover` class to it,
+      * that will cause mouse events to not fall through and activate other links.
+      * @param event The mouse event triggering the callback.
+      * @param text The text of the link.
+      */
+     hover?(event: MouseEvent, text: string): void;
+
+     /**
+      * Called when the mouse leaves the link.
+      * @param event The mouse event triggering the callback.
+      * @param text The text of the link.
+      */
+     leave?(event: MouseEvent, text: string): void;
+  }
+
   /**
    * A custom link provider.
    */