Browse Source

Merge pull request #6833 from mbektasbbg/outputconsole

Log Console extension to capture unhandled messages and other activity
Jason Grout 5 years ago
parent
commit
a310eb3f92

+ 1 - 0
dev_mode/imports.css

@@ -19,6 +19,7 @@
 @import url('~@jupyterlab/javascript-extension/style/index.css');
 @import url('~@jupyterlab/json-extension/style/index.css');
 @import url('~@jupyterlab/launcher-extension/style/index.css');
+@import url('~@jupyterlab/logconsole-extension/style/index.css');
 @import url('~@jupyterlab/mainmenu-extension/style/index.css');
 @import url('~@jupyterlab/markdownviewer-extension/style/index.css');
 @import url('~@jupyterlab/mathjax2-extension/style/index.css');

+ 6 - 3
dev_mode/package.json

@@ -37,6 +37,7 @@
     "@jupyterlab/javascript-extension": "~2.0.0-alpha.0",
     "@jupyterlab/json-extension": "~2.0.0-alpha.0",
     "@jupyterlab/launcher-extension": "~2.0.0-alpha.0",
+    "@jupyterlab/logconsole-extension": "~1.0.0-alpha.0",
     "@jupyterlab/mainmenu-extension": "~2.0.0-alpha.0",
     "@jupyterlab/markdownviewer-extension": "~2.0.0-alpha.0",
     "@jupyterlab/mathjax2-extension": "~2.0.0-alpha.0",
@@ -126,6 +127,8 @@
     "@jupyterlab/json-extension": "~2.0.0-alpha.0",
     "@jupyterlab/launcher": "~2.0.0-alpha.0",
     "@jupyterlab/launcher-extension": "~2.0.0-alpha.0",
+    "@jupyterlab/logconsole": "~1.0.0-alpha.0",
+    "@jupyterlab/logconsole-extension": "~1.0.0-alpha.0",
     "@jupyterlab/mainmenu": "~2.0.0-alpha.0",
     "@jupyterlab/mainmenu-extension": "~2.0.0-alpha.0",
     "@jupyterlab/markdownviewer": "~2.0.0-alpha.0",
@@ -169,12 +172,9 @@
     "@phosphor/coreutils": "^1.3.1",
     "@phosphor/datagrid": "^0.1.11",
     "@phosphor/disposable": "^1.3.0",
-    "@phosphor/domutils": "^1.1.3",
-    "@phosphor/dragdrop": "^1.3.0",
     "@phosphor/messaging": "^1.3.0",
     "@phosphor/properties": "^1.1.3",
     "@phosphor/signaling": "^1.3.0",
-    "@phosphor/virtualdom": "^1.2.0",
     "@phosphor/widgets": "^1.9.0",
     "react": "~16.8.4",
     "react-dom": "~16.8.4"
@@ -203,6 +203,7 @@
       "@jupyterlab/imageviewer-extension": "",
       "@jupyterlab/inspector-extension": "",
       "@jupyterlab/launcher-extension": "",
+      "@jupyterlab/logconsole-extension": "",
       "@jupyterlab/mainmenu-extension": "",
       "@jupyterlab/markdownviewer-extension": "",
       "@jupyterlab/mathjax2-extension": "",
@@ -309,6 +310,8 @@
       "@jupyterlab/json-extension": "../packages/json-extension",
       "@jupyterlab/launcher": "../packages/launcher",
       "@jupyterlab/launcher-extension": "../packages/launcher-extension",
+      "@jupyterlab/logconsole": "../packages/logconsole",
+      "@jupyterlab/logconsole-extension": "../packages/logconsole-extension",
       "@jupyterlab/mainmenu": "../packages/mainmenu",
       "@jupyterlab/mainmenu-extension": "../packages/mainmenu-extension",
       "@jupyterlab/markdownviewer": "../packages/markdownviewer",

+ 2 - 2
packages/apputils/src/clientsession.tsx

@@ -251,7 +251,7 @@ export class ClientSession implements IClientSession {
   /**
    * A signal emitted for iopub kernel messages.
    */
-  get iopubMessage(): ISignal<this, KernelMessage.IMessage> {
+  get iopubMessage(): ISignal<this, KernelMessage.IIOPubMessage> {
     return this._iopubMessage;
   }
 
@@ -830,7 +830,7 @@ export class ClientSession implements IClientSession {
   private _terminated = new Signal<this, void>(this);
   private _kernelChanged = new Signal<this, Session.IKernelChangedArgs>(this);
   private _statusChanged = new Signal<this, Kernel.Status>(this);
-  private _iopubMessage = new Signal<this, KernelMessage.IMessage>(this);
+  private _iopubMessage = new Signal<this, KernelMessage.IIOPubMessage>(this);
   private _unhandledMessage = new Signal<this, KernelMessage.IMessage>(this);
   private _propertyChanged = new Signal<this, 'path' | 'name' | 'type'>(this);
   private _dialog: Dialog<any> | null = null;

+ 4 - 2
packages/console/src/foreign.ts

@@ -116,8 +116,10 @@ export class ForeignHandler implements IDisposable {
         if (!cell) {
           return false;
         }
-        let output = msg.content as nbformat.IOutput;
-        output.output_type = msgType as nbformat.OutputType;
+        const output: nbformat.IOutput = {
+          ...msg.content,
+          output_type: msgType
+        };
         cell.model.outputs.add(output);
         parent.update();
         return true;

+ 1 - 1
packages/inspector-extension/src/index.ts

@@ -50,7 +50,7 @@ const inspector: JupyterFrontEndPlugin<IInspector> = {
   ): IInspector => {
     const { commands, shell } = app;
     const command = CommandIDs.open;
-    const label = 'Contextual Help';
+    const label = 'Show Contextual Help';
     const namespace = 'inspector';
     const tracker = new WidgetTracker<MainAreaWidget<InspectorPanel>>({
       namespace

+ 3 - 0
packages/logconsole-extension/README.md

@@ -0,0 +1,3 @@
+# @jupyterlab/logconsole-extension
+
+JupyterLab - Log Console Extension

+ 63 - 0
packages/logconsole-extension/package.json

@@ -0,0 +1,63 @@
+{
+  "name": "@jupyterlab/logconsole-extension",
+  "version": "1.0.0-alpha.0",
+  "description": "JupyterLab - Log Console Extension",
+  "homepage": "https://github.com/jupyterlab/jupyterlab",
+  "bugs": {
+    "url": "https://github.com/jupyterlab/jupyterlab/issues"
+  },
+  "repository": {
+    "type": "git",
+    "url": "https://github.com/jupyterlab/jupyterlab.git"
+  },
+  "license": "BSD-3-Clause",
+  "author": "Project Jupyter",
+  "files": [
+    "lib/**/*.{d.ts,eot,gif,html,jpg,js,js.map,json,png,svg,woff2,ttf}",
+    "style/**/*.{css,eot,gif,html,jpg,json,png,svg,woff2,ttf}",
+    "schema/*.json"
+  ],
+  "sideEffects": [
+    "style/**/*"
+  ],
+  "main": "lib/index.js",
+  "types": "lib/index.d.ts",
+  "style": "style/index.css",
+  "directories": {
+    "lib": "lib/"
+  },
+  "scripts": {
+    "build": "tsc -b",
+    "clean": "rimraf lib",
+    "prepublishOnly": "npm run build",
+    "watch": "tsc -w --listEmittedFiles"
+  },
+  "dependencies": {
+    "@jupyterlab/application": "^2.0.0-alpha.0",
+    "@jupyterlab/apputils": "^2.0.0-alpha.0",
+    "@jupyterlab/coreutils": "^4.0.0-alpha.0",
+    "@jupyterlab/docregistry": "^2.0.0-alpha.0",
+    "@jupyterlab/logconsole": "^1.0.0-alpha.0",
+    "@jupyterlab/mainmenu": "^2.0.0-alpha.0",
+    "@jupyterlab/notebook": "^2.0.0-alpha.0",
+    "@jupyterlab/rendermime": "^2.0.0-alpha.0",
+    "@jupyterlab/services": "^5.0.0-alpha.0",
+    "@jupyterlab/statusbar": "^2.0.0-alpha.0",
+    "@phosphor/algorithm": "^1.2.0",
+    "@phosphor/coreutils": "^1.3.1",
+    "@phosphor/disposable": "^1.3.0",
+    "@phosphor/signaling": "^1.3.0",
+    "react": "~16.8.4"
+  },
+  "devDependencies": {
+    "rimraf": "~2.6.2",
+    "typescript": "~3.5.1"
+  },
+  "publishConfig": {
+    "access": "public"
+  },
+  "jupyterlab": {
+    "extension": true,
+    "schemaDir": "schema"
+  }
+}

+ 22 - 0
packages/logconsole-extension/schema/plugin.json

@@ -0,0 +1,22 @@
+{
+  "jupyter.lab.setting-icon-class": "jp-SettingsIcon",
+  "jupyter.lab.setting-icon-label": "Log Console",
+  "title": "Log Console",
+  "description": "Log Console settings.",
+  "properties": {
+    "maxLogEntries": {
+      "type": "number",
+      "title": "Log entry count limit",
+      "description": "Maximum number of log entries to store in memory",
+      "default": 1000
+    },
+    "flash": {
+      "type": "boolean",
+      "title": "Status Bar Item flash",
+      "description": "Whether to flash on new log message or not",
+      "default": true
+    }
+  },
+  "additionalProperties": false,
+  "type": "object"
+}

+ 595 - 0
packages/logconsole-extension/src/index.tsx

@@ -0,0 +1,595 @@
+// Copyright (c) Jupyter Development Team.
+// Distributed under the terms of the Modified BSD License.
+
+import {
+  JupyterFrontEnd,
+  JupyterFrontEndPlugin,
+  ILayoutRestorer
+} from '@jupyterlab/application';
+
+import {
+  MainAreaWidget,
+  WidgetTracker,
+  ToolbarButton
+} from '@jupyterlab/apputils';
+
+import { INotebookTracker, NotebookPanel } from '@jupyterlab/notebook';
+
+import {
+  ILoggerRegistry,
+  LoggerRegistry,
+  LogConsolePanel,
+  ILogger,
+  ILoggerChange,
+  ILoggerRegistryChange,
+  DEFAULT_LOG_ENTRY_LIMIT
+} from '@jupyterlab/logconsole';
+
+import { ICommandPalette, VDomModel, VDomRenderer } from '@jupyterlab/apputils';
+
+import { IRenderMimeRegistry } from '@jupyterlab/rendermime';
+
+import { IMainMenu } from '@jupyterlab/mainmenu';
+
+import React from 'react';
+
+import {
+  IStatusBar,
+  GroupItem,
+  IconItem,
+  TextItem,
+  interactiveItem
+} from '@jupyterlab/statusbar';
+
+import { ISettingRegistry } from '@jupyterlab/coreutils';
+
+import { Signal } from '@phosphor/signaling';
+
+const LOG_CONSOLE_PLUGIN_ID = '@jupyterlab/logconsole-extension:plugin';
+
+/**
+ * The Log Console extension.
+ */
+const logConsolePlugin: JupyterFrontEndPlugin<ILoggerRegistry> = {
+  activate: activateLogConsole,
+  id: LOG_CONSOLE_PLUGIN_ID,
+  provides: ILoggerRegistry,
+  requires: [
+    IMainMenu,
+    ICommandPalette,
+    INotebookTracker,
+    IStatusBar,
+    IRenderMimeRegistry
+  ],
+  optional: [ILayoutRestorer, ISettingRegistry],
+  autoStart: true
+};
+
+/*
+ * A namespace for LogConsoleStatusComponent.
+ */
+namespace LogConsoleStatusComponent {
+  /**
+   * The props for the LogConsoleStatusComponent.
+   */
+  export interface IProps {
+    /**
+     * A click handler for the item. By default
+     * Log Console panel is launched.
+     */
+    handleClick: () => void;
+
+    /**
+     * Number of logs.
+     */
+    logCount: number;
+  }
+}
+
+/**
+ * A pure functional component for a Log Console status item.
+ *
+ * @param props - the props for the component.
+ *
+ * @returns a tsx component for rendering the Log Console status.
+ */
+function LogConsoleStatusComponent(
+  props: LogConsoleStatusComponent.IProps
+): React.ReactElement<LogConsoleStatusComponent.IProps> {
+  return (
+    <GroupItem
+      spacing={0}
+      onClick={props.handleClick}
+      title={`${props.logCount} logs in Log Console`}
+    >
+      <IconItem source={'jp-LogConsoleIcon'} />
+      <TextItem source={props.logCount} />
+    </GroupItem>
+  );
+}
+
+/**
+ * A VDomRenderer widget for displaying the status of Log Console logs.
+ */
+export class LogConsoleStatus extends VDomRenderer<LogConsoleStatus.Model> {
+  /**
+   * Construct the log console status widget.
+   *
+   * @param options - The status widget initialization options.
+   */
+  constructor(options: LogConsoleStatus.IOptions) {
+    super();
+    this._handleClick = options.handleClick;
+    this.model = new LogConsoleStatus.Model(options.loggerRegistry);
+    this.addClass(interactiveItem);
+    this.addClass('jp-LogConsoleStatusItem');
+
+    let flashRequestTimer: number = null;
+
+    this.model.activeSourceChanged.connect(() => {
+      if (
+        this.model.activeSource &&
+        this.model.flashEnabled &&
+        !this.model.isSourceLogsViewed(this.model.activeSource) &&
+        this.model.logCount > 0
+      ) {
+        this._showHighlighted();
+      } else {
+        this._clearHighlight();
+      }
+    });
+
+    this.model.flashEnabledChanged.connect(() => {
+      if (!this.model.flashEnabled) {
+        this._clearHighlight();
+      }
+    });
+
+    this.model.logChanged.connect(() => {
+      if (!this.model.flashEnabled || this.model.logCount === 0) {
+        // cancel existing request
+        clearTimeout(flashRequestTimer);
+        flashRequestTimer = null;
+        this._clearHighlight();
+        return;
+      }
+
+      const wasFlashed = this.hasClass('hilite') || this.hasClass('hilited');
+      if (wasFlashed) {
+        this._clearHighlight();
+        // cancel previous request
+        clearTimeout(flashRequestTimer);
+        flashRequestTimer = setTimeout(() => {
+          this._flashHighlight();
+        }, 100);
+      } else {
+        this._flashHighlight();
+      }
+    });
+  }
+
+  /**
+   * Render the log console status item.
+   */
+  render() {
+    if (this.model === null) {
+      return null;
+    } else {
+      return (
+        <LogConsoleStatusComponent
+          handleClick={this._handleClick}
+          logCount={this.model.logCount}
+        />
+      );
+    }
+  }
+
+  private _flashHighlight() {
+    this.addClass('hilite');
+  }
+
+  private _showHighlighted() {
+    this.addClass('hilited');
+  }
+
+  private _clearHighlight() {
+    this.removeClass('hilite');
+    this.removeClass('hilited');
+  }
+
+  private _handleClick: () => void;
+}
+
+/**
+ * A namespace for Log Console log status.
+ */
+export namespace LogConsoleStatus {
+  /**
+   * A VDomModel for the LogConsoleStatus item.
+   */
+  export class Model extends VDomModel {
+    /**
+     * Create a new LogConsoleStatus model.
+     *
+     * @param loggerRegistry - The logger registry providing the logs.
+     */
+    constructor(loggerRegistry: ILoggerRegistry) {
+      super();
+
+      this._loggerRegistry = loggerRegistry;
+
+      this._loggerRegistry.registryChanged.connect(
+        (sender: ILoggerRegistry, args: ILoggerRegistryChange) => {
+          const loggers = this._loggerRegistry.getLoggers();
+          for (let logger of loggers) {
+            if (this._loggersWatched.has(logger.source)) {
+              continue;
+            }
+
+            logger.logChanged.connect(
+              (sender: ILogger, change: ILoggerChange) => {
+                if (sender.source === this._activeSource) {
+                  this.stateChanged.emit();
+                  this.logChanged.emit();
+                }
+
+                // mark logger as dirty
+                this._loggersWatched.set(sender.source, false);
+              }
+            );
+
+            // mark logger as viewed
+            this._loggersWatched.set(logger.source, true);
+          }
+        }
+      );
+    }
+
+    /**
+     * Number of logs.
+     */
+    get logCount(): number {
+      if (this._activeSource) {
+        const logger = this._loggerRegistry.getLogger(this._activeSource);
+        return Math.min(logger.length, this._entryLimit);
+      }
+
+      return 0;
+    }
+
+    /**
+     * The name of the active log source
+     */
+    get activeSource(): string {
+      return this._activeSource;
+    }
+
+    set activeSource(name: string) {
+      if (this._activeSource === name) {
+        return;
+      }
+
+      this._activeSource = name;
+      this.activeSourceChanged.emit();
+
+      // refresh rendering
+      this.stateChanged.emit();
+    }
+
+    /**
+     * Flag to toggle flashing when new logs added.
+     */
+    get flashEnabled(): boolean {
+      return this._flashEnabled;
+    }
+
+    set flashEnabled(enabled: boolean) {
+      if (this._flashEnabled === enabled) {
+        return;
+      }
+
+      this._flashEnabled = enabled;
+      this.flashEnabledChanged.emit();
+
+      // refresh rendering
+      this.stateChanged.emit();
+    }
+
+    /**
+     * Log output entry limit.
+     */
+    set entryLimit(limit: number) {
+      if (limit > 0) {
+        this._entryLimit = limit;
+
+        // refresh rendering
+        this.stateChanged.emit();
+      }
+    }
+
+    /**
+     * Mark logs from the source as viewed.
+     *
+     * @param source - The name of the log source.
+     */
+    markSourceLogsViewed(source: string) {
+      this._loggersWatched.set(source, true);
+    }
+
+    /**
+     * Check if logs from the source are viewed.
+     *
+     * @param source - The name of the log source.
+     *
+     * @returns True if logs from source are viewer.
+     */
+    isSourceLogsViewed(source: string): boolean {
+      return (
+        !this._loggersWatched.has(source) ||
+        this._loggersWatched.get(source) === true
+      );
+    }
+
+    /**
+     * A signal emitted when the log model changes.
+     */
+    public logChanged = new Signal<this, void>(this);
+    /**
+     * A signal emitted when the active log source changes.
+     */
+    public activeSourceChanged = new Signal<this, void>(this);
+    /**
+     * A signal emitted when the flash enablement changes.
+     */
+    public flashEnabledChanged = new Signal<this, void>(this);
+    private _flashEnabled: boolean = true;
+    private _loggerRegistry: ILoggerRegistry;
+    private _activeSource: string = null;
+    private _entryLimit: number = DEFAULT_LOG_ENTRY_LIMIT;
+    // A map storing keys as source names of the loggers watched
+    // and values as whether logs from the source are viewed
+    private _loggersWatched: Map<string, boolean> = new Map();
+  }
+
+  /**
+   * Options for creating a new LogConsoleStatus item
+   */
+  export interface IOptions {
+    /**
+     * The logger registry providing the logs.
+     */
+    loggerRegistry: ILoggerRegistry;
+
+    /**
+     * A click handler for the item. By default
+     * Log Console panel is launched.
+     */
+    handleClick: () => void;
+  }
+}
+
+/**
+ * Activate the Log Console extension.
+ */
+function activateLogConsole(
+  app: JupyterFrontEnd,
+  mainMenu: IMainMenu,
+  palette: ICommandPalette,
+  nbtracker: INotebookTracker,
+  statusBar: IStatusBar,
+  rendermime: IRenderMimeRegistry,
+  restorer: ILayoutRestorer | null,
+  settingRegistry: ISettingRegistry | null
+): ILoggerRegistry {
+  let logConsoleWidget: MainAreaWidget<LogConsolePanel> = null;
+  let entryLimit: number = DEFAULT_LOG_ENTRY_LIMIT;
+  let flashEnabled: boolean = true;
+
+  const loggerRegistry = new LoggerRegistry(rendermime);
+  const command = 'logconsole:open';
+  const category: string = 'Main Area';
+
+  const tracker = new WidgetTracker<MainAreaWidget<LogConsolePanel>>({
+    namespace: 'logconsole'
+  });
+
+  if (restorer) {
+    void restorer.restore(tracker, {
+      command,
+      args: obj => ({
+        fromRestorer: true,
+        activeSource: obj.content.activeSource
+      }),
+      name: () => 'logconsole'
+    });
+  }
+
+  const status = new LogConsoleStatus({
+    loggerRegistry: loggerRegistry,
+    handleClick: () => {
+      if (!logConsoleWidget) {
+        createLogConsoleWidget();
+      } else {
+        logConsoleWidget.activate();
+      }
+    }
+  });
+
+  const createLogConsoleWidget = () => {
+    let activeSource: string = nbtracker.currentWidget
+      ? nbtracker.currentWidget.context.path
+      : null;
+
+    const logConsolePanel = new LogConsolePanel(loggerRegistry);
+    logConsoleWidget = new MainAreaWidget({ content: logConsolePanel });
+    logConsoleWidget.addClass('jp-LogConsole');
+    logConsoleWidget.title.closable = true;
+    logConsoleWidget.title.label = 'Log Console';
+    logConsoleWidget.title.iconClass = 'jp-LogConsoleIcon';
+    logConsolePanel.entryLimit = entryLimit;
+
+    const addTimestampButton = new ToolbarButton({
+      onClick: (): void => {
+        if (!logConsolePanel.activeSource) {
+          return;
+        }
+
+        const logger = loggerRegistry.getLogger(logConsolePanel.activeSource);
+        logger.log({
+          data: {
+            'text/html': '<hr>'
+          },
+          output_type: 'display_data'
+        });
+      },
+      iconClassName: 'jp-AddIcon',
+      tooltip: 'Add Timestamp',
+      label: 'Add Timestamp'
+    });
+
+    const clearButton = new ToolbarButton({
+      onClick: (): void => {
+        const logger = loggerRegistry.getLogger(logConsolePanel.activeSource);
+        logger.clear();
+      },
+      iconClassName: 'fa fa-ban clear-icon',
+      tooltip: 'Clear Logs',
+      label: 'Clear Logs'
+    });
+
+    logConsoleWidget.toolbar.addItem(
+      'lab-output-console-add-timestamp',
+      addTimestampButton
+    );
+    logConsoleWidget.toolbar.addItem('lab-output-console-clear', clearButton);
+
+    void tracker.add(logConsoleWidget);
+
+    logConsolePanel.attached.connect(() => {
+      status.model.markSourceLogsViewed(status.model.activeSource);
+      status.model.flashEnabled = false;
+    });
+
+    logConsoleWidget.disposed.connect(() => {
+      logConsoleWidget = null;
+      status.model.flashEnabled = flashEnabled;
+    });
+
+    app.shell.add(logConsoleWidget, 'main', {
+      ref: '',
+      mode: 'split-bottom'
+    });
+
+    logConsoleWidget.update();
+
+    app.shell.activateById(logConsoleWidget.id);
+
+    if (activeSource) {
+      logConsolePanel.activeSource = activeSource;
+    }
+  };
+
+  app.commands.addCommand(command, {
+    label: 'Show Log Console',
+    execute: (args: any) => {
+      if (!logConsoleWidget) {
+        createLogConsoleWidget();
+
+        if (args && args.activeSource) {
+          logConsoleWidget.content.activeSource = args.activeSource;
+        }
+      } else if (!(args && args.fromRestorer)) {
+        logConsoleWidget.dispose();
+      }
+    },
+    isToggled: () => {
+      return logConsoleWidget !== null;
+    }
+  });
+
+  mainMenu.viewMenu.addGroup([{ command }]);
+  palette.addItem({ command, category });
+  app.contextMenu.addItem({
+    command: command,
+    selector: '.jp-Notebook'
+  });
+
+  let appRestored = false;
+
+  void app.restored.then(() => {
+    appRestored = true;
+  });
+
+  statusBar.registerStatusItem('@jupyterlab/logconsole-extension:status', {
+    item: status,
+    align: 'left',
+    isActive: () => true,
+    activeStateChanged: status.model!.stateChanged
+  });
+
+  nbtracker.widgetAdded.connect(
+    (sender: INotebookTracker, nb: NotebookPanel) => {
+      nb.activated.connect((nb: NotebookPanel, args: void) => {
+        // set activeSource only after app is restored
+        // in order to allow restorer to restore previous activeSource
+        if (!appRestored) {
+          return;
+        }
+
+        const sourceName = nb.context.path;
+        if (logConsoleWidget) {
+          logConsoleWidget.content.activeSource = sourceName;
+          status.model.markSourceLogsViewed(sourceName);
+          void tracker.save(logConsoleWidget);
+        }
+        status.model.activeSource = sourceName;
+      });
+
+      nb.disposed.connect((nb: NotebookPanel, args: void) => {
+        const sourceName = nb.context.path;
+        if (
+          logConsoleWidget &&
+          logConsoleWidget.content.activeSource === sourceName
+        ) {
+          logConsoleWidget.content.activeSource = null;
+          void tracker.save(logConsoleWidget);
+        }
+        if (status.model.activeSource === sourceName) {
+          status.model.activeSource = null;
+        }
+      });
+    }
+  );
+
+  if (settingRegistry) {
+    const updateSettings = (settings: ISettingRegistry.ISettings): void => {
+      const maxLogEntries = settings.get('maxLogEntries').composite as number;
+      entryLimit = maxLogEntries;
+
+      if (logConsoleWidget) {
+        logConsoleWidget.content.entryLimit = entryLimit;
+      }
+      status.model.entryLimit = entryLimit;
+
+      flashEnabled = settings.get('flash').composite as boolean;
+      status.model.flashEnabled = !logConsoleWidget && flashEnabled;
+    };
+
+    Promise.all([settingRegistry.load(LOG_CONSOLE_PLUGIN_ID), app.restored])
+      .then(([settings]) => {
+        updateSettings(settings);
+        settings.changed.connect(settings => {
+          updateSettings(settings);
+        });
+      })
+      .catch((reason: Error) => {
+        console.error(reason.message);
+      });
+  }
+
+  return loggerRegistry;
+  // The notebook can call this command.
+  // When is the output model disposed?
+}
+
+export default [logConsolePlugin];

+ 24 - 0
packages/logconsole-extension/style/base.css

@@ -0,0 +1,24 @@
+:root [data-jp-theme-light='true'] {
+  --jp-icon-output-console: url('./list-icon-light.svg');
+}
+
+:root [data-jp-theme-light='false'] {
+  --jp-icon-output-console: url('./list-icon-dark.svg');
+}
+
+.jp-LogConsoleIcon {
+  background-image: var(--jp-icon-output-console);
+}
+
+.jp-LogConsoleStatusItem.hilite {
+  transition: background-color 1s ease-out;
+  background-color: var(--jp-info-color0);
+}
+.jp-LogConsoleStatusItem.hilited {
+  background-color: var(--jp-info-color0);
+}
+
+.jp-LogConsole .clear-icon {
+  transform: rotate(90deg);
+  margin-top: -1px;
+}

+ 16 - 0
packages/logconsole-extension/style/index.css

@@ -0,0 +1,16 @@
+/*-----------------------------------------------------------------------------
+| Copyright (c) Jupyter Development Team.
+| Distributed under the terms of the Modified BSD License.
+|----------------------------------------------------------------------------*/
+
+/* This file was auto-generated by ensurePackage() in @jupyterlab/buildutils */
+@import url('~@jupyterlab/apputils/style/index.css');
+@import url('~@jupyterlab/statusbar/style/index.css');
+@import url('~@jupyterlab/rendermime/style/index.css');
+@import url('~@jupyterlab/docregistry/style/index.css');
+@import url('~@jupyterlab/application/style/index.css');
+@import url('~@jupyterlab/logconsole/style/index.css');
+@import url('~@jupyterlab/mainmenu/style/index.css');
+@import url('~@jupyterlab/notebook/style/index.css');
+
+@import url('./base.css');

+ 1 - 0
packages/logconsole-extension/style/list-icon-dark.svg

@@ -0,0 +1 @@
+<svg fill="#e0e0e0" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M19 5v14H5V5h14m1.1-2H3.9c-.5 0-.9.4-.9.9v16.2c0 .4.4.9.9.9h16.2c.4 0 .9-.5.9-.9V3.9c0-.5-.5-.9-.9-.9zM11 7h6v2h-6V7zm0 4h6v2h-6v-2zm0 4h6v2h-6zM7 7h2v2H7zm0 4h2v2H7zm0 4h2v2H7z"/><path fill="none" d="M0 0h24v24H0z"/></svg>

+ 1 - 0
packages/logconsole-extension/style/list-icon-light.svg

@@ -0,0 +1 @@
+<svg fill="#616161" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M19 5v14H5V5h14m1.1-2H3.9c-.5 0-.9.4-.9.9v16.2c0 .4.4.9.9.9h16.2c.4 0 .9-.5.9-.9V3.9c0-.5-.5-.9-.9-.9zM11 7h6v2h-6V7zm0 4h6v2h-6v-2zm0 4h6v2h-6zM7 7h2v2H7zm0 4h2v2H7zm0 4h2v2H7z"/><path fill="none" d="M0 0h24v24H0z"/></svg>

+ 15 - 0
packages/logconsole-extension/tdoptions.json

@@ -0,0 +1,15 @@
+{
+  "excludeNotExported": true,
+  "mode": "file",
+  "target": "es2017",
+  "module": "es5",
+  "lib": ["lib.dom.d.ts", "lib.es2017.d.ts"],
+  "out": "../../docs/api/logconsole-extension",
+  "baseUrl": ".",
+  "paths": {
+    "@jupyterlab/*": ["../packages/*"]
+  },
+  "esModuleInterop": true,
+  "jsx": "react",
+  "types": []
+}

+ 40 - 0
packages/logconsole-extension/tsconfig.json

@@ -0,0 +1,40 @@
+{
+  "extends": "../../tsconfigbase",
+  "compilerOptions": {
+    "outDir": "lib",
+    "rootDir": "src"
+  },
+  "include": ["src/*"],
+  "references": [
+    {
+      "path": "../application"
+    },
+    {
+      "path": "../apputils"
+    },
+    {
+      "path": "../coreutils"
+    },
+    {
+      "path": "../docregistry"
+    },
+    {
+      "path": "../logconsole"
+    },
+    {
+      "path": "../mainmenu"
+    },
+    {
+      "path": "../notebook"
+    },
+    {
+      "path": "../rendermime"
+    },
+    {
+      "path": "../services"
+    },
+    {
+      "path": "../statusbar"
+    }
+  ]
+}

+ 3 - 0
packages/logconsole/README.md

@@ -0,0 +1,3 @@
+# @jupyterlab/logconsole
+
+JupyterLab - Log Console

+ 56 - 0
packages/logconsole/package.json

@@ -0,0 +1,56 @@
+{
+  "name": "@jupyterlab/logconsole",
+  "version": "1.0.0-alpha.0",
+  "description": "JupyterLab - Log Console",
+  "homepage": "https://github.com/jupyterlab/jupyterlab",
+  "bugs": {
+    "url": "https://github.com/jupyterlab/jupyterlab/issues"
+  },
+  "repository": {
+    "type": "git",
+    "url": "https://github.com/jupyterlab/jupyterlab.git"
+  },
+  "license": "BSD-3-Clause",
+  "author": "Project Jupyter",
+  "files": [
+    "lib/**/*.{d.ts,eot,gif,html,jpg,js,js.map,json,png,svg,woff2,ttf}",
+    "style/**/*.{css,eot,gif,html,jpg,json,png,svg,woff2,ttf}"
+  ],
+  "sideEffects": [
+    "style/**/*"
+  ],
+  "main": "lib/index.js",
+  "types": "lib/index.d.ts",
+  "style": "style/index.css",
+  "directories": {
+    "lib": "lib/"
+  },
+  "scripts": {
+    "build": "tsc -b",
+    "clean": "rimraf lib",
+    "prepublishOnly": "npm run build",
+    "watch": "tsc -w --listEmittedFiles"
+  },
+  "dependencies": {
+    "@blueprintjs/core": "^3.9.0",
+    "@jupyterlab/apputils": "^2.0.0-alpha.0",
+    "@jupyterlab/coreutils": "^4.0.0-alpha.0",
+    "@jupyterlab/outputarea": "^2.0.0-alpha.0",
+    "@jupyterlab/rendermime": "^2.0.0-alpha.0",
+    "@jupyterlab/services": "^5.0.0-alpha.0",
+    "@jupyterlab/ui-components": "^2.0.0-alpha.0",
+    "@phosphor/coreutils": "^1.3.1",
+    "@phosphor/disposable": "^1.3.0",
+    "@phosphor/messaging": "^1.3.0",
+    "@phosphor/signaling": "^1.3.0",
+    "@phosphor/widgets": "^1.9.0",
+    "react": "~16.8.4"
+  },
+  "devDependencies": {
+    "rimraf": "~2.6.2",
+    "typescript": "~3.5.1"
+  },
+  "publishConfig": {
+    "access": "public"
+  }
+}

+ 604 - 0
packages/logconsole/src/index.tsx

@@ -0,0 +1,604 @@
+// Copyright (c) Jupyter Development Team.
+// Distributed under the terms of the Modified BSD License.
+
+import { StackedPanel, Widget, Panel } from '@phosphor/widgets';
+
+import { Token } from '@phosphor/coreutils';
+
+import { ISignal, Signal } from '@phosphor/signaling';
+
+import { Kernel, KernelMessage } from '@jupyterlab/services';
+
+import { nbformat } from '@jupyterlab/coreutils';
+
+import {
+  OutputArea,
+  IOutputAreaModel,
+  OutputAreaModel,
+  IOutputPrompt
+} from '@jupyterlab/outputarea';
+
+import {
+  IRenderMimeRegistry,
+  IOutputModel,
+  OutputModel
+} from '@jupyterlab/rendermime';
+import { Message } from '@phosphor/messaging';
+
+/* tslint:disable */
+/**
+ * The Logger Registry token.
+ */
+export const ILoggerRegistry = new Token<ILoggerRegistry>(
+  '@jupyterlab/logconsole:ILoggerRegistry'
+);
+
+/**
+ * A Logger Registry that registers and provides loggers by source.
+ */
+export interface ILoggerRegistry {
+  /**
+   * Get the logger for the specified source.
+   *
+   * @param source - The name of the log source.
+   *
+   * @returns The logger for the specified source.
+   */
+  getLogger(source: string): ILogger;
+  /**
+   * Get all loggers registered.
+   *
+   * @returns The array containing all registered loggers.
+   */
+  getLoggers(): ILogger[];
+
+  /**
+   * A signal emitted when the logger registry changes.
+   */
+  readonly registryChanged: ISignal<this, ILoggerRegistryChange>;
+}
+
+/**
+ * Custom Notebook Output with timestamp member.
+ */
+interface ITimestampedOutput extends nbformat.IBaseOutput {
+  /**
+   * Date & time when output is logged in integer representation.
+   */
+  timestamp: number;
+}
+
+export const DEFAULT_LOG_ENTRY_LIMIT: number = 1000;
+
+/**
+ * Custom Notebook Output with optional timestamp.
+ */
+type IOutputWithTimestamp = nbformat.IOutput | ITimestampedOutput;
+
+export type ILoggerChange = 'append' | 'clear';
+
+/**
+ * A Logger that manages logs from a particular source.
+ */
+export interface ILogger {
+  /**
+   * Log an output to logger.
+   *
+   * @param log - The output to be logged.
+   */
+  log(log: nbformat.IOutput): void;
+  /**
+   * Clear all outputs logged.
+   */
+  clear(): void;
+  /**
+   * Number of outputs logged.
+   */
+  readonly length: number;
+  /**
+   * Rendermime to use when rendering outputs logged.
+   */
+  rendermime: IRenderMimeRegistry;
+  /**
+   * A signal emitted when the log model changes.
+   */
+  readonly logChanged: ISignal<this, ILoggerChange>;
+  /**
+   * A signal emitted when the rendermime changes.
+   */
+  readonly rendermimeChanged: ISignal<this, void>;
+  /**
+   * The name of the log source.
+   */
+  readonly source: string;
+  /**
+   * Output Area Model used to manage log storage in memory.
+   */
+  readonly outputAreaModel: LoggerOutputAreaModel;
+}
+
+/**
+ * Log Output Model with timestamp which provides
+ * item information for Output Area Model.
+ */
+class LogOutputModel extends OutputModel {
+  /**
+   * Construct a LogOutputModel.
+   *
+   * @param options - The model initialization options.
+   */
+  constructor(options: LogOutputModel.IOptions) {
+    super(options);
+
+    this.timestamp = new Date(options.value.timestamp as number);
+  }
+
+  /**
+   * Date & time when output is logged.
+   */
+  timestamp: Date = null;
+}
+
+/**
+ * Log Output Model namespace that defines initialization options.
+ */
+namespace LogOutputModel {
+  export interface IOptions extends IOutputModel.IOptions {
+    value: IOutputWithTimestamp;
+  }
+}
+
+/**
+ * Implementation of `IContentFactory` for Output Area Model
+ * which creates LogOutputModel instances.
+ */
+class LogConsoleModelContentFactory extends OutputAreaModel.ContentFactory {
+  /**
+   * Create a rendermime output model from notebook output.
+   */
+  createOutputModel(options: IOutputModel.IOptions): LogOutputModel {
+    return new LogOutputModel(options);
+  }
+}
+
+/**
+ * A concrete implementation of ILogger.
+ */
+export class Logger implements ILogger {
+  /**
+   * Construct a Logger.
+   *
+   * @param source - The name of the log source.
+   */
+  constructor(source: string) {
+    this.source = source;
+  }
+
+  /**
+   * Number of outputs logged.
+   */
+  get length(): number {
+    return this.outputAreaModel.length;
+  }
+
+  /**
+   * A signal emitted when the log model changes.
+   */
+  get logChanged(): ISignal<this, ILoggerChange> {
+    return this._logChanged;
+  }
+
+  /**
+   * A signal emitted when the rendermime changes.
+   */
+  get rendermimeChanged(): ISignal<this, void> {
+    return this._rendermimeChanged;
+  }
+
+  /**
+   * Log an output to logger.
+   *
+   * @param log - The output to be logged.
+   */
+  log(log: nbformat.IOutput) {
+    const timestamp = new Date();
+    this.outputAreaModel.add({ ...log, timestamp: timestamp.valueOf() });
+    this._logChanged.emit('append');
+  }
+
+  /**
+   * Clear all outputs logged.
+   */
+  clear() {
+    this.outputAreaModel.clear(false);
+    this._logChanged.emit('clear');
+  }
+
+  set rendermime(value: IRenderMimeRegistry | null) {
+    if (value !== this._rendermime) {
+      this._rendermime = value;
+      this._rendermimeChanged.emit();
+    }
+  }
+
+  /**
+   * Rendermime to use when rendering outputs logged.
+   */
+  get rendermime(): IRenderMimeRegistry | null {
+    return this._rendermime;
+  }
+
+  readonly source: string;
+  readonly outputAreaModel = new LoggerOutputAreaModel({
+    contentFactory: new LogConsoleModelContentFactory()
+  });
+  private _logChanged = new Signal<this, ILoggerChange>(this);
+  private _rendermimeChanged = new Signal<this, void>(this);
+  private _rendermime: IRenderMimeRegistry | null = null;
+}
+
+export type ILoggerRegistryChange = 'append';
+
+/**
+ * A concrete implementation of ILoggerRegistry.
+ */
+export class LoggerRegistry implements ILoggerRegistry {
+  /**
+   * Construct a LoggerRegistry.
+   *
+   * @param defaultRendermime - Default rendermime to render outputs
+   * with when logger is not supplied with one.
+   */
+  constructor(defaultRendermime: IRenderMimeRegistry) {
+    this._defaultRendermime = defaultRendermime;
+  }
+
+  /**
+   * Get the logger for the specified source.
+   *
+   * @param source - The name of the log source.
+   *
+   * @returns The logger for the specified source.
+   */
+  getLogger(source: string): ILogger {
+    const loggers = this._loggers;
+    let logger = loggers.get(source);
+    if (logger) {
+      return logger;
+    }
+
+    logger = new Logger(source);
+    logger.rendermime = this._defaultRendermime;
+    loggers.set(source, logger);
+
+    this._registryChanged.emit('append');
+
+    return logger;
+  }
+
+  /**
+   * Get all loggers registered.
+   *
+   * @returns The array containing all registered loggers.
+   */
+  getLoggers(): ILogger[] {
+    return Array.from(this._loggers.values());
+  }
+
+  /**
+   * A signal emitted when the logger registry changes.
+   */
+  get registryChanged(): ISignal<this, ILoggerRegistryChange> {
+    return this._registryChanged;
+  }
+
+  private _loggers = new Map<string, Logger>();
+  private _registryChanged = new Signal<this, ILoggerRegistryChange>(this);
+  private _defaultRendermime: IRenderMimeRegistry = null;
+}
+
+/**
+ * Log console output prompt implementation
+ */
+class LogConsoleOutputPrompt extends Widget implements IOutputPrompt {
+  constructor() {
+    super();
+
+    this._timestampNode = document.createElement('div');
+    this.node.append(this._timestampNode);
+  }
+
+  /**
+   * Date & time when output is logged.
+   */
+  set timestamp(value: Date) {
+    this._timestampNode.innerHTML = value.toLocaleTimeString();
+  }
+
+  /**
+   * The execution count for the prompt.
+   */
+  executionCount: nbformat.ExecutionCount;
+
+  private _timestampNode: HTMLDivElement;
+}
+
+/**
+ * Output Area implementation displaying log outputs
+ * with prompts showing log timestamps.
+ */
+class LogConsoleOutputArea extends OutputArea {
+  /**
+   * Handle an input request from a kernel by doing nothing.
+   */
+  protected onInputRequest(
+    msg: KernelMessage.IInputRequestMsg,
+    future: Kernel.IShellFuture
+  ): void {
+    return;
+  }
+
+  /**
+   * Create an output item with a prompt and actual output
+   */
+  protected createOutputItem(model: LogOutputModel): Widget | null {
+    const panel = super.createOutputItem(model) as Panel;
+    // first widget in panel is prompt of type LoggerOutputPrompt
+    (panel.widgets[0] as LogConsoleOutputPrompt).timestamp = model.timestamp;
+    return panel;
+  }
+
+  /**
+   * The rendermime instance used by the widget.
+   */
+  rendermime: IRenderMimeRegistry;
+  /**
+   * Output area model used by the widget.
+   */
+  readonly model: LoggerOutputAreaModel;
+}
+
+/**
+ * Output Area Model implementation which is able to
+ * limit number of outputs stored.
+ */
+class LoggerOutputAreaModel extends OutputAreaModel {
+  constructor(options?: IOutputAreaModel.IOptions) {
+    super(options);
+  }
+
+  /**
+   * Maximum number of log entries to store in the model.
+   */
+  set entryLimit(limit: number) {
+    this._entryLimit = limit;
+    this.applyLimit();
+  }
+
+  /**
+   * Manually apply entry limit.
+   */
+  applyLimit() {
+    if (this.list.length > this._entryLimit) {
+      const diff = this.list.length - this._entryLimit;
+      this.list.removeRange(0, diff);
+    }
+  }
+
+  private _entryLimit: number = DEFAULT_LOG_ENTRY_LIMIT;
+}
+
+/**
+ * Implementation of `IContentFactory` for Output Area
+ * which creates custom output prompts.
+ */
+class LogConsoleContentFactory extends OutputArea.ContentFactory {
+  /**
+   * Create the output prompt for the widget.
+   */
+  createOutputPrompt(): LogConsoleOutputPrompt {
+    return new LogConsoleOutputPrompt();
+  }
+}
+
+/**
+ * A StackedPanel implementation that creates Output Areas
+ * for each log source and activates as source is switched.
+ */
+export class LogConsolePanel extends StackedPanel {
+  /**
+   * Construct a LogConsolePanel instance.
+   *
+   * @param loggerRegistry - The logger registry that provides
+   * logs to be displayed.
+   */
+  constructor(loggerRegistry: ILoggerRegistry) {
+    super();
+
+    this._loggerRegistry = loggerRegistry;
+    this.addClass('jp-LogConsolePanel');
+
+    loggerRegistry.registryChanged.connect(
+      (sender: ILoggerRegistry, args: ILoggerRegistryChange) => {
+        this._bindLoggerSignals();
+      },
+      this
+    );
+
+    this._bindLoggerSignals();
+
+    this._placeholder = new Widget();
+    this._placeholder.addClass('jp-LogConsoleListPlaceholder');
+    this._placeholder.node.innerHTML = 'No log messages.';
+
+    this.addWidget(this._placeholder);
+  }
+
+  protected onAfterAttach(msg: Message): void {
+    this._updateOutputAreas();
+    this._showOutputFromSource(this._activeSource);
+    this._showPlaceholderIfNoMessage();
+    this.attached.emit();
+  }
+
+  private _bindLoggerSignals() {
+    const loggers = this._loggerRegistry.getLoggers();
+    for (let logger of loggers) {
+      if (this._loggersWatched.has(logger.source)) {
+        continue;
+      }
+
+      logger.logChanged.connect((sender: ILogger, args: ILoggerChange) => {
+        this._updateOutputAreas();
+        this._showPlaceholderIfNoMessage();
+      }, this);
+
+      logger.rendermimeChanged.connect((sender: ILogger) => {
+        const viewId = `source:${sender.source}`;
+        const outputArea = this._outputAreas.get(viewId);
+        if (outputArea) {
+          outputArea.rendermime = sender.rendermime;
+        }
+      }, this);
+
+      this._loggersWatched.add(logger.source);
+    }
+  }
+
+  /**
+   * The logger registry providing the logs.
+   */
+  get loggerRegistry(): ILoggerRegistry {
+    return this._loggerRegistry;
+  }
+
+  private _showOutputFromSource(source: string) {
+    const viewId = `source:${source}`;
+
+    this._outputAreas.forEach(
+      (outputArea: LogConsoleOutputArea, name: string) => {
+        if (outputArea.id === viewId) {
+          outputArea.show();
+          setTimeout(() => {
+            this._scrollOuputAreaToBottom(outputArea);
+          }, 50);
+        } else {
+          outputArea.hide();
+        }
+      }
+    );
+
+    const title = source ? `Log: ${source}` : 'Log Console';
+    this.title.label = title;
+    this.title.caption = title;
+  }
+
+  set activeSource(name: string) {
+    this._activeSource = name;
+    this._showOutputFromSource(this._activeSource);
+    this._showPlaceholderIfNoMessage();
+  }
+
+  /**
+   * The name of the active log source
+   */
+  get activeSource(): string {
+    return this._activeSource;
+  }
+
+  private _showPlaceholderIfNoMessage() {
+    const noMessage =
+      !this.activeSource ||
+      this._loggerRegistry.getLogger(this.activeSource).length === 0;
+
+    if (noMessage) {
+      this._placeholder.show();
+    } else {
+      this._placeholder.hide();
+    }
+  }
+
+  private _scrollOuputAreaToBottom(outputArea: LogConsoleOutputArea) {
+    outputArea.node.scrollTo({
+      left: 0,
+      top: outputArea.node.scrollHeight,
+      behavior: 'smooth'
+    });
+  }
+
+  private _updateOutputAreas() {
+    const loggerIds = new Set<string>();
+    const loggers = this._loggerRegistry.getLoggers();
+
+    for (let logger of loggers) {
+      const viewId = `source:${logger.source}`;
+      loggerIds.add(viewId);
+
+      // add view for logger if not exist
+      if (!this._outputAreas.has(viewId)) {
+        const outputArea = new LogConsoleOutputArea({
+          rendermime: logger.rendermime,
+          contentFactory: new LogConsoleContentFactory(),
+          model: logger.outputAreaModel
+        });
+        outputArea.id = viewId;
+        outputArea.model.entryLimit = this.entryLimit;
+
+        logger.logChanged.connect((sender: ILogger, args: ILoggerChange) => {
+          this._scrollOuputAreaToBottom(outputArea);
+        }, this);
+
+        outputArea.outputLengthChanged.connect(
+          (sender: LogConsoleOutputArea, args: number) => {
+            outputArea.model.applyLimit();
+            clearTimeout(this._scrollTimer);
+            this._scrollTimer = setTimeout(() => {
+              this._scrollOuputAreaToBottom(outputArea);
+            }, 50);
+          },
+          this
+        );
+
+        this.addWidget(outputArea);
+        this._outputAreas.set(viewId, outputArea);
+      }
+    }
+
+    // remove output areas that do not have corresponding loggers anymore
+    const viewIds = this._outputAreas.keys();
+
+    for (let viewId of viewIds) {
+      if (!loggerIds.has(viewId)) {
+        const outputArea = this._outputAreas.get(viewId);
+        outputArea.dispose();
+        this._outputAreas.delete(viewId);
+      }
+    }
+  }
+
+  /**
+   * Log output entry limit.
+   */
+  get entryLimit(): number {
+    return this._entryLimit;
+  }
+
+  set entryLimit(limit: number) {
+    if (limit > 0) {
+      this._outputAreas.forEach((outputView: LogConsoleOutputArea) => {
+        outputView.model.entryLimit = limit;
+      });
+
+      this._entryLimit = limit;
+    }
+  }
+
+  readonly attached = new Signal<this, void>(this);
+  private _loggerRegistry: ILoggerRegistry;
+  private _outputAreas = new Map<string, LogConsoleOutputArea>();
+  private _activeSource: string = null;
+  private _entryLimit: number = DEFAULT_LOG_ENTRY_LIMIT;
+  private _scrollTimer: number = null;
+  private _placeholder: Widget;
+  private _loggersWatched: Set<string> = new Set();
+}

+ 25 - 0
packages/logconsole/style/base.css

@@ -0,0 +1,25 @@
+/*-----------------------------------------------------------------------------
+| Copyright (c) Jupyter Development Team.
+| Distributed under the terms of the Modified BSD License.
+|----------------------------------------------------------------------------*/
+
+.jp-LogConsolePanel {
+  overflow-y: auto;
+}
+
+.jp-LogConsolePanel .jp-OutputArea-child {
+  border-bottom: 1px solid var(--jp-border-color3);
+}
+
+.jp-LogConsolePanel .jp-OutputArea-prompt {
+  width: 85px;
+  color: var(--jp-ui-font-color2);
+  font-size: 13px;
+  padding: 2px;
+}
+
+.jp-LogConsoleListPlaceholder {
+  padding: 5px;
+  font-size: 13px;
+  color: var(--jp-ui-font-color3);
+}

+ 14 - 0
packages/logconsole/style/index.css

@@ -0,0 +1,14 @@
+/*-----------------------------------------------------------------------------
+| Copyright (c) Jupyter Development Team.
+| Distributed under the terms of the Modified BSD License.
+|----------------------------------------------------------------------------*/
+
+/* This file was auto-generated by ensurePackage() in @jupyterlab/buildutils */
+@import url('~@blueprintjs/core/lib/css/blueprint.css');
+@import url('~@phosphor/widgets/style/index.css');
+@import url('~@jupyterlab/ui-components/style/index.css');
+@import url('~@jupyterlab/apputils/style/index.css');
+@import url('~@jupyterlab/rendermime/style/index.css');
+@import url('~@jupyterlab/outputarea/style/index.css');
+
+@import url('./base.css');

+ 15 - 0
packages/logconsole/tdoptions.json

@@ -0,0 +1,15 @@
+{
+  "excludeNotExported": true,
+  "mode": "file",
+  "target": "es2017",
+  "module": "es5",
+  "lib": ["lib.dom.d.ts", "lib.es2017.d.ts"],
+  "out": "../../docs/api/logconsole",
+  "baseUrl": ".",
+  "paths": {
+    "@jupyterlab/*": ["../packages/*"]
+  },
+  "esModuleInterop": true,
+  "jsx": "react",
+  "types": []
+}

+ 28 - 0
packages/logconsole/tsconfig.json

@@ -0,0 +1,28 @@
+{
+  "extends": "../../tsconfigbase",
+  "compilerOptions": {
+    "outDir": "lib",
+    "rootDir": "src"
+  },
+  "include": ["src/*"],
+  "references": [
+    {
+      "path": "../apputils"
+    },
+    {
+      "path": "../coreutils"
+    },
+    {
+      "path": "../outputarea"
+    },
+    {
+      "path": "../rendermime"
+    },
+    {
+      "path": "../services"
+    },
+    {
+      "path": "../ui-components"
+    }
+  ]
+}

+ 2 - 0
packages/metapackage/package.json

@@ -70,6 +70,8 @@
     "@jupyterlab/json-extension": "^2.0.0-alpha.0",
     "@jupyterlab/launcher": "^2.0.0-alpha.0",
     "@jupyterlab/launcher-extension": "^2.0.0-alpha.0",
+    "@jupyterlab/logconsole": "^1.0.0-alpha.0",
+    "@jupyterlab/logconsole-extension": "^1.0.0-alpha.0",
     "@jupyterlab/mainmenu": "^2.0.0-alpha.0",
     "@jupyterlab/mainmenu-extension": "^2.0.0-alpha.0",
     "@jupyterlab/markdownviewer": "^2.0.0-alpha.0",

+ 6 - 0
packages/metapackage/tsconfig.json

@@ -123,6 +123,12 @@
     {
       "path": "../launcher-extension"
     },
+    {
+      "path": "../logconsole"
+    },
+    {
+      "path": "../logconsole-extension"
+    },
     {
       "path": "../mainmenu"
     },

+ 4 - 1
packages/outputarea/src/model.ts

@@ -12,6 +12,7 @@ import { nbformat } from '@jupyterlab/coreutils';
 import { IObservableList, ObservableList } from '@jupyterlab/observables';
 
 import { IOutputModel, OutputModel } from '@jupyterlab/rendermime';
+import { JSONExt } from '@phosphor/coreutils';
 
 /**
  * The model for an output area.
@@ -227,6 +228,7 @@ export class OutputAreaModel implements IOutputAreaModel {
    * Set the value at the specified index.
    */
   set(index: number, value: nbformat.IOutput): void {
+    value = JSONExt.deepCopy(value);
     // Normalize stream data.
     Private.normalize(value);
     let item = this._createItem({ value, trusted: this._trusted });
@@ -288,10 +290,11 @@ export class OutputAreaModel implements IOutputAreaModel {
   }
 
   /**
-   * Add an item to the list.
+   * Add a copy of the item to the list.
    */
   private _add(value: nbformat.IOutput): number {
     let trusted = this._trusted;
+    value = JSONExt.deepCopy(value);
 
     // Normalize the value.
     Private.normalize(value);

+ 56 - 6
packages/outputarea/src/widget.ts

@@ -218,9 +218,30 @@ export class OutputArea extends Widget {
         this.outputLengthChanged.emit(this.model.length);
         break;
       case 'remove':
-        // Only clear is supported by the model.
         if (this.widgets.length) {
-          this._clear();
+          // all items removed from model
+          if (this.model.length === 0) {
+            this._clear();
+          } else {
+            // range of items removed from model
+            // remove widgets corresponding to removed model items
+            const startIndex = args.oldIndex;
+            for (
+              let i = 0;
+              i < args.oldValues.length && startIndex < this.widgets.length;
+              ++i
+            ) {
+              let widget = this.widgets[startIndex];
+              widget.parent = null;
+              widget.dispose();
+            }
+
+            // apply item offset to target model item indices in _displayIdMap
+            this._moveDisplayIdIndices(startIndex, args.oldValues.length);
+
+            // prevent jitter caused by immediate height change
+            this._preventHeightChangeJitter();
+          }
           this.outputLengthChanged.emit(this.model.length);
         }
         break;
@@ -233,6 +254,32 @@ export class OutputArea extends Widget {
     }
   }
 
+  /**
+   * Update indices in _displayIdMap in response to element remove from model items
+   * *
+   * @param startIndex - The index of first element removed
+   *
+   * @param count - The number of elements removed from model items
+   *
+   */
+  private _moveDisplayIdIndices(startIndex: number, count: number) {
+    this._displayIdMap.forEach((indices: number[]) => {
+      const rangeEnd = startIndex + count;
+      const numIndices = indices.length;
+      // reverse loop in order to prevent removing element affecting the index
+      for (let i = numIndices - 1; i >= 0; --i) {
+        const index = indices[i];
+        // remove model item indices in removed range
+        if (index >= startIndex && index < rangeEnd) {
+          indices.splice(i, 1);
+        } else if (index >= rangeEnd) {
+          // move model item indices that were larger than range end
+          indices[i] -= count;
+        }
+      }
+    });
+  }
+
   /**
    * Follow changes on the output model state.
    */
@@ -263,6 +310,11 @@ export class OutputArea extends Widget {
     // Clear the display id map.
     this._displayIdMap.clear();
 
+    // prevent jitter caused by immediate height change
+    this._preventHeightChangeJitter();
+  }
+
+  private _preventHeightChangeJitter() {
     // When an output area is cleared and then quickly replaced with new
     // content (as happens with @interact in widgets, for example), the
     // quickly changing height can make the page jitter.
@@ -440,8 +492,7 @@ export class OutputArea extends Widget {
       case 'display_data':
       case 'stream':
       case 'error':
-        output = msg.content as nbformat.IOutput;
-        output.output_type = msgType as nbformat.OutputType;
+        output = { ...msg.content, output_type: msgType };
         model.add(output);
         break;
       case 'clear_output':
@@ -449,8 +500,7 @@ export class OutputArea extends Widget {
         model.clear(wait);
         break;
       case 'update_display_data':
-        output = msg.content as nbformat.IOutput;
-        output.output_type = 'display_data';
+        output = { ...msg.content, output_type: 'display_data' };
         targets = this._displayIdMap.get(displayId);
         if (targets) {
           for (let index of targets) {