Repository: stateful/vscode-runme
Pull request #304 (2023-03-14 20:18:25)

Allow concurrent outputs
Solves #289 

Allows multiple parts of the code to concurrently manage a cell's outputs without having to worry about executions:

<p align="center">
<img src="https://user-images.githubusercontent.com/16108792/225126373-f323c9d8-fa9c-4a73-9d28-fceaa672cedc.gif" width="600" />
</p>

To do this, I've unified output handling to a new file `cell.ts`, which acts as a single source of truth for all output handling.

Changes are synchronized using a new `Mutex` utility class, which essentially acts as a dynamic promise queue that ensures mutations to outputs are done in sequence. I am open to alternative patterns for accomplishing this.

### The API

The important parts are mostly in `cell.ts`, which has a `NotebookCellOutputManager` class. It is expected that one is created per each cell, where this would be the new central location for dealing with that cell's outputs. In particular, it has the responsibility of generating outputs for each output type, based on the metadata of that cell.

The expectation is that all matters of outputs are dealt with through this class. For example, to toggle the annotations output, you would do `outputs.toggleOutput(OutputType.annotations)`.

As more complex output behavior is needed, this class should provide the internal tools to make this happen by extending the API. All of the race conditions should be dealt with.

### Persisting Output Data

There is a broader move here from sending the data to an output through the json, to persisting it with the cell's metadata. This is necessary since cells will potentially be re-rendered as the user e.g. opens the annotation window, toggles outputs, etc.

The solution given is very far from excellent, and I am all ears to suggestions. But hopefully, it should work for now. 

There is a data duplication/caching issue (same data is sent over the messaging channel as is persisted through metadata cache), and it is perhaps confusing how you set the metadata and then have to manually call a refresh. 

In the future, we should introduce a broader abstraction to handle/unify state synchronization and persistence in outputs. Bonus points if we can make generating `NotebookCellOutput` objects part of this as well. Interesting if we can learn from marquee/tangle here, since it's a very similar problem.

## Known issues:

 - [x] A user manually removing outputs is not handled. Ideally we can listen to an event for this, if not we have to figure it out lazily.
 - [x] Non-gRPC execution code is untouched
 - [x] gRPC execution code needs to be unified for vercel & deno outputs
 - [x] Deno output should hide embedded terminal
 - [x] No unit tests (yet)
 
Other known issues:

 - Deno output won't persist promoted (if you toggle annotations, promotion status gets reset)

sourishkrout: Something for next week - not super high priority short-term: Tried to review this branch but notebooks won't load for me for now. Assuming the final touches leading up to v1 led to inconsistencies and merge conflicts.
mxsdev: @sourishkrout 

I rebased against main, this should be working now. (Problem was that TLS wasn't implemented on this branch)
mxsdev: Now integrated with the notebook terminal 👍 

Also changed the "Open Terminal" button to just be "Terminal," and it now acts as a toggle across all output modes. I think this is better UX, let me know your thoughts!
sourishkrout: Let's perhaps get all the remaining Notebook Terminal stuff merged & shipped before releasing this one into `edge` for a break-in period. Makes sense, @mxsdev?
mxsdev: @sourishkrout 

Sounds good to me!

Patch:
diff --git a/__mocks__/vscode.ts b/__mocks__/vscode.ts
index 7b9cfe97..207b4b22 100644
--- a/__mocks__/vscode.ts
+++ b/__mocks__/vscode.ts
@@ -180,9 +180,9 @@ export class NotebookCellStatusBarItem {
 export class NotebookCellOutputItem {
 
   // eslint-disable-next-line @typescript-eslint/no-unused-vars
-  static json(value: any, mime?: string): NotebookCellOutputItem {
+  static json(value: any, mime = 'text/plain'): NotebookCellOutputItem {
     return {
-      mime: 'text/plain',
+      mime: mime,
       data: new Uint8Array()
     }
   }
@@ -194,10 +194,8 @@ export class NotebookCellOutputItem {
 }
 
 export class NotebookCellOutput {
-  items: NotebookCellOutputItem[]
-  metadata?: { [key: string]: any }
   // eslint-disable-next-line @typescript-eslint/no-unused-vars
-  constructor(items: NotebookCellOutputItem[], metadata?: { [key: string]: any }) {}
+  constructor(public items: NotebookCellOutputItem[], public metadata?: { [key: string]: any }) {}
 }
 
 export const ProgressLocation = {
diff --git a/package.json b/package.json
index 2e2c51d3..1d86c21d 100644
--- a/package.json
+++ b/package.json
@@ -63,8 +63,8 @@
         "title": "Runme Notebook"
       },
       {
-        "command": "runme.openTerminal",
-        "title": "Open Terminal for Background Task"
+        "command": "runme.toggleTerminal",
+        "title": "Toggle Terminal For Cell"
       },
       {
         "command": "runme.openIntegratedTerminal",
@@ -148,7 +148,7 @@
     "menus": {
       "commandPalette": [
         {
-          "command": "runme.openTerminal",
+          "command": "runme.toggleTerminal",
           "when": "false"
         },
         {
diff --git a/src/client/components/deno.ts b/src/client/components/deno.ts
index 38378a6b..272d5828 100644
--- a/src/client/components/deno.ts
+++ b/src/client/components/deno.ts
@@ -6,14 +6,13 @@ import '@vscode/webview-ui-toolkit/dist/button/index'
 import { getContext } from '../utils'
 import { Deployment, Project } from '../../utils/deno/api_types'
 import { ClientMessages } from '../../constants'
-import type { ClientMessage } from '../../types'
+import type { ClientMessage, DenoState } from '../../types'
 
 import './spinner'
 
 @customElement('deno-output')
 export class DenoOutput extends LitElement {
   #isPromoting = false
-  #promoted = false
 
   // Define scoped styles right with your component, in plain CSS
   static styles = css`
@@ -48,13 +47,24 @@ export class DenoOutput extends LitElement {
     }
   `
 
-  // Declare reactive properties
-  @property({ type: Boolean })
-  deployed = false
   @property({ type: Object })
-  project?: Project
-  @property({ type: Object })
-  deployments?: Deployment[]
+  state: DenoState = {}
+
+  private get project(): Project {
+    return this.state?.project
+  }
+
+  private get deployments() {
+    return this.state?.deployments
+  }
+
+  private get deployed() {
+    return this.state?.deployed
+  }
+
+  private get promoted() {
+    return this.state?.promoted
+  }
 
   // Render the UI as a function of component state
   render() {
@@ -87,9 +97,9 @@ export class DenoOutput extends LitElement {
         ${this.deployed ? (new Date(deployment.createdAt)).toString() : 'Pending' }
         <h4>Status</h4>
           ${this.deployed
-          ? ((supportsMessaging && this.#promoted) ? 'Production' : 'Preview')
+          ? ((supportsMessaging && this.promoted) ? 'Production' : 'Preview')
             : html`Deploying <vscode-spinner />`}
-        ${when(this.deployed && supportsMessaging && !this.#promoted, () => html`
+        ${when(this.deployed && supportsMessaging && !this.promoted, () => html`
           <vscode-button
             class="btnPromote"
             @click="${() => this.#promote(deployment)}"
@@ -98,7 +108,7 @@ export class DenoOutput extends LitElement {
             🚀 ${this.#isPromoting ? 'Promoting...' : 'Promote to Production'}
           </vscode-button>
         `)}
-        ${when(this.deployed && supportsMessaging && this.#promoted && project.hasProductionDeployment, () => html`
+        ${when(this.deployed && supportsMessaging && this.promoted && project.hasProductionDeployment, () => html`
           <p>
             Promoted to 🚀:
             <vscode-link href="https://${prodDomainMapping?.domain}">
@@ -118,11 +128,11 @@ export class DenoOutput extends LitElement {
 
     this.#isPromoting= true
     this.requestUpdate()
-    ctx.postMessage(<ClientMessage<ClientMessages.promote>>{
-      type: ClientMessages.promote,
+    ctx.postMessage(<ClientMessage<ClientMessages.denoPromote>>{
+      type: ClientMessages.denoPromote,
       output: {
         id: deployment.projectId,
-        productionDeployment: deployment.id
+        productionDeployment: deployment.id,
       }
     })
   }
@@ -141,16 +151,12 @@ export class DenoOutput extends LitElement {
       }
 
       switch (e.type) {
-        case ClientMessages.deployed: {
-          const payload = e.output
-          this.#promoted = payload
-          break
-        }
-        case ClientMessages.update: {
+        case ClientMessages.denoUpdate: {
           const payload = e.output
-          this.deployed = Boolean(payload.deployed)
-          this.project = payload.project
-          this.deployments = payload.deployments
+          this.state = {
+            ...this.state,
+            ...payload,
+          }
         }
       }
       this.requestUpdate()
diff --git a/src/client/components/vercel.ts b/src/client/components/vercel.ts
index c7f5cdd8..60969c52 100644
--- a/src/client/components/vercel.ts
+++ b/src/client/components/vercel.ts
@@ -114,8 +114,8 @@ export class VercelOutput extends LitElement {
 
     this.#isPromoting = true
     this.requestUpdate()
-    ctx.postMessage(<ClientMessage<ClientMessages.prod>>{
-      type: ClientMessages.prod,
+    ctx.postMessage(<ClientMessage<ClientMessages.vercelProd>>{
+      type: ClientMessages.vercelProd,
       output: { cellIndex: this.content.payload.index }
     })
   }
diff --git a/src/client/index.ts b/src/client/index.ts
index b7304a5c..ad9b972f 100644
--- a/src/client/index.ts
+++ b/src/client/index.ts
@@ -21,16 +21,26 @@ export const activate: ActivationFunction = (context: RendererContext<void>) =>
 
       switch (payload.type) {
         case OutputType.vercel:
+          if(payload.output.error) {
+            renderError(payload.output.error)
+            break
+          }
+
           const vercelElem = document.createElement('vercel-output')
           vercelElem.setAttribute('content', JSON.stringify(payload.output))
           element.appendChild(vercelElem)
           break
         case OutputType.deno:
+          if(payload.output?.error) {
+            renderError(payload.output.error)
+            break
+          }
+
           const deno = payload.output || {}
           const denoElem = document.createElement('deno-output')
-          deno.deployed && denoElem.setAttribute('deployed', deno.deployed.toString())
-          deno.project && denoElem.setAttribute('project', JSON.stringify(deno.project))
-          denoElem.setAttribute('deployments', JSON.stringify(deno.deployments))
+
+          denoElem.setAttribute('state', JSON.stringify(deno))
+
           element.appendChild(denoElem)
           break
         case OutputType.outputItems:
@@ -71,10 +81,14 @@ export const activate: ActivationFunction = (context: RendererContext<void>) =>
           element.appendChild(terminalElement)
           break
         case OutputType.error:
-          element.innerHTML = `⚠️ ${payload.output}`
+          renderError(payload.output)
           break
         default: element.innerHTML = 'No renderer found!'
       }
+
+      function renderError(message: string) {
+        element.innerHTML = `⚠️ ${message}`
+      }
     },
     disposeOutputItem(/* outputId */) {
       // Do any teardown here. outputId is the cell output being deleted, or
diff --git a/src/constants.ts b/src/constants.ts
index aadb6af1..3b9e7ed0 100644
--- a/src/constants.ts
+++ b/src/constants.ts
@@ -15,10 +15,9 @@ export enum OutputType {
 export enum ClientMessages {
   infoMessage = 'common:infoMessage',
   errorMessage = 'common:errorMessage',
-  update = 'deno:deploymentUpdate',
-  deployed = 'deno:finishedDeployment',
-  promote = 'deno:promoteDeployment',
-  prod = 'vercel:promotePreview',
+  denoUpdate = 'deno:deploymentUpdate',
+  denoPromote = 'deno:promoteDeployment',
+  vercelProd = 'vercel:promotePreview',
   mutateAnnotations = 'annotations:mutate',
   terminalStdout = 'terminal:stdout',
   terminalStderr = 'terminal:stderr',
diff --git a/src/extension/cell.ts b/src/extension/cell.ts
new file mode 100644
index 00000000..aaa64e22
--- /dev/null
+++ b/src/extension/cell.ts
@@ -0,0 +1,412 @@
+import {
+  Disposable,
+  NotebookCell,
+  NotebookCellExecution,
+  NotebookCellOutput,
+  NotebookCellOutputItem,
+  NotebookController,
+  NotebookEdit,
+  workspace,
+  WorkspaceEdit
+} from 'vscode'
+
+import { OutputType } from '../constants'
+import { CellOutputPayload, Serializer } from '../types'
+import { Mutex } from '../utils/sync'
+import {
+  getNotebookTerminalFontFamily,
+  getNotebookTerminalFontSize,
+  getNotebookTerminalRows
+} from '../utils/configuration'
+
+import { getAnnotations, replaceOutput, validateAnnotations } from './utils'
+import {
+  ITerminalState,
+  LocalBufferTermState,
+  NotebookTerminalType,
+  XTermState
+} from './terminal/terminalState'
+
+export class NotebookCellManager {
+  #data = new WeakMap<NotebookCell, NotebookCellOutputManager>()
+
+  constructor(
+    protected controller: NotebookController
+  ) { }
+
+  registerCell(cell: NotebookCell): NotebookCellOutputManager {
+    const existing = this.#data.get(cell)
+    if(existing) { return existing }
+
+    const outputs = new NotebookCellOutputManager(cell, this.controller)
+    this.#data.set(cell, outputs)
+
+    return outputs
+  }
+
+  async createNotebookCellExecution(cell: NotebookCell) {
+    const outputs = await this.getNotebookOutputs(cell)
+    return outputs.createNotebookCellExecution()
+  }
+
+  async getNotebookOutputs(cell: NotebookCell): Promise<NotebookCellOutputManager> {
+    const outputs = this.#data.get(cell)
+    if(!outputs) {
+      console.error(`cell at index ${cell.index} has not been registered!`)
+    }
+
+    return outputs ?? this.registerCell(cell)
+  }
+}
+
+type NotebookOutputs = NotebookCellOutput | readonly NotebookCellOutput[]
+
+export class NotebookCellOutputManager {
+  protected outputs: readonly NotebookCellOutput[] = []
+
+  protected enabledOutputs = new Map([
+    [OutputType.annotations, false],
+    [OutputType.deno, false],
+    [OutputType.vercel, false],
+  ])
+
+  protected mutex: Mutex = new Mutex()
+  protected withLock = this.mutex.withLock.bind(this.mutex)
+
+  protected onFinish = Promise.resolve()
+  protected execution?: NotebookCellExecution
+
+  protected terminalState?: ITerminalState
+  protected terminalEnabled = false
+
+  constructor(
+    protected cell: NotebookCell,
+    protected controller: NotebookController
+  ) { }
+
+  protected generateOutputUnsafe(type: OutputType): NotebookCellOutput|undefined {
+    const cell = this.cell
+    const metadata = cell.metadata as Serializer.Metadata
+
+    switch(type) {
+      case OutputType.annotations: {
+        const annotationJson: CellOutputPayload<OutputType.annotations> = {
+          type: OutputType.annotations,
+          output: {
+            annotations: getAnnotations(cell),
+            validationErrors: validateAnnotations(cell)
+          },
+        }
+
+        return new NotebookCellOutput([
+          NotebookCellOutputItem.json(annotationJson, OutputType.annotations),
+          NotebookCellOutputItem.json(annotationJson),
+        ])
+      }
+
+      case OutputType.deno: {
+        const payload: CellOutputPayload<OutputType.deno> = {
+          type: OutputType.deno,
+          output: metadata['runme.dev/denoState'],
+        }
+
+        return new NotebookCellOutput([
+          NotebookCellOutputItem.json(payload, OutputType.deno)
+        ], { deno: { deploy: true } })
+      }
+
+      case OutputType.vercel: {
+        const json: CellOutputPayload<OutputType.vercel> = {
+          type: OutputType.vercel,
+          output: metadata['runme.dev/vercelState'] ?? {
+            outputItems: [],
+          },
+        }
+
+        return new NotebookCellOutput([
+          NotebookCellOutputItem.json(json, OutputType.vercel)
+        ])
+      }
+
+      case OutputType.outputItems:
+      case OutputType.terminal: {
+        const terminalState = this.terminalState
+        if (!terminalState) { return }
+
+        const cellId = getAnnotations(cell)['runme.dev/uuid']
+        if (!cellId) { throw new Error('Cannot open cell terminal with invalid UUID!') }
+
+        if (type === OutputType.terminal) {
+          const editorSettings = workspace.getConfiguration('editor')
+
+          const terminalFontFamily = getNotebookTerminalFontFamily()
+            ?? editorSettings.get<string>('fontFamily', 'Arial')
+
+          const terminalFontSize = getNotebookTerminalFontSize()
+            ?? editorSettings.get<number>('fontSize', 10)
+
+          const json: CellOutputPayload<OutputType.terminal> = {
+            type: OutputType.terminal,
+            output: {
+              'runme.dev/uuid': cellId,
+              terminalFontFamily,
+              terminalFontSize,
+              content: terminalState.serialize(),
+              initialRows: getNotebookTerminalRows(),
+            }
+          }
+
+          return new NotebookCellOutput([
+            NotebookCellOutputItem.json(json, OutputType.terminal),
+          ])
+        } else {
+          const json: CellOutputPayload<OutputType.outputItems> = {
+            type: OutputType.outputItems,
+            output: {
+              content: terminalState.serialize(),
+              mime: 'text/plain',
+            }
+          }
+
+          return new NotebookCellOutput([
+            NotebookCellOutputItem.json(json, OutputType.outputItems)
+          ])
+        }
+      }
+
+      default: {
+        return undefined
+      }
+    }
+  }
+
+  registerCellTerminalState(type: NotebookTerminalType): ITerminalState {
+    let terminalState: ITerminalState
+
+    switch (type) {
+      case 'xterm': {
+        terminalState = new XTermState()
+      } break
+
+      case 'local': {
+        const _terminalState = new LocalBufferTermState()
+        const _write = _terminalState.write
+
+        _terminalState.write = (data) => {
+          _write.call(_terminalState, data)
+          this.refreshOutput(OutputType.outputItems)
+        }
+
+        terminalState = _terminalState
+      } break
+    }
+
+    this.terminalState = terminalState
+    return terminalState
+  }
+
+  getCellTerminalState(): ITerminalState|undefined {
+    return this.terminalState
+  }
+
+  async createNotebookCellExecution(): Promise<RunmeNotebookCellExecution> {
+    await this.onFinish
+
+    return await this.withLock(() => {
+      const execution = this.controller.createNotebookCellExecution(this.cell)
+      this.execution = execution
+
+      const wrapper = new RunmeNotebookCellExecution(execution)
+
+      const resetExecution = async () => {
+        await this.withLock(async () => {
+          if (this.execution === execution) {
+            this.execution = undefined
+          }
+        })
+      }
+
+      this.onFinish = new Promise<void>(async (resolve) => {
+        wrapper.onEnd(async () => {
+          await resetExecution()
+          resolve()
+        })
+
+        if(wrapper.hasEnded) {
+          resolve()
+        }
+      }).finally(resetExecution)
+
+      return wrapper
+    })
+  }
+
+  protected hasOutputTypeUnsafe(type: OutputType, outputs?: readonly NotebookCellOutput[]): boolean {
+    outputs ??= this.cell.outputs
+    return outputs.some(x => x.items.some(y => y.mime === type))
+  }
+
+  async replaceOutputs(outputs: NotebookOutputs) {
+    await this.refreshOutputInternal(() => {
+      this.outputs = outputsAsArray(outputs)
+    })
+  }
+
+  async toggleOutput(type: OutputType) {
+    await this.showOutput(type, () => !this.hasOutputTypeUnsafe(type))
+  }
+
+  async showOutput(type: OutputType, shown: boolean|(() => boolean|Promise<boolean>) = true) {
+    await this.refreshOutputInternal(async () => {
+      this.enabledOutputs.set(type, typeof shown === 'function' ? await shown() : shown)
+    })
+  }
+
+  async toggleTerminal() {
+    await this.showTerminal(() => {
+      return !this.terminalEnabled
+    })
+  }
+
+  async showTerminal(shown: boolean|(() => boolean) = true) {
+    await this.refreshOutputInternal(async () => {
+      this.terminalEnabled = (typeof shown === 'function' ? shown() : shown)
+    })
+  }
+
+  /**
+   * Refreshes the output list
+   *
+   * @param type * If present, only refresh output list if the given OutputType(s)
+   * are present in the outputs, otherwise does nothing
+   */
+  async refreshOutput(type?: OutputType|OutputType[]) {
+    await this.refreshOutputInternal(() => {
+      if(type === undefined) { return }
+
+      const typeSet = Array.isArray(type) ? type : [type]
+      return typeSet.some(t => this.hasOutputTypeUnsafe(t))
+    })
+  }
+
+  /**
+   * Internal refresh ouptut function. Runs under mutex.
+   *
+   * @param mutater Optional callback, which runs after enabled outputs are
+   * retrieved, but before outputs are replaced. This function should be used to
+   * mutate the state of enabled outputs.
+   *
+   * It can also be used to prevent outputs from being replaced at all, by
+   * returning `false`. This is used in the exposed `refreshOutput` function,
+   * where the user can prevent refreshing if a certain output type is not
+   * present.
+   */
+  protected async refreshOutputInternal(mutater?: () => Promise<boolean|void>|boolean|void) {
+    await this.withLock(async () => {
+      await this.getExecutionUnsafe(async (exec) => {
+        for(const key of [...this.enabledOutputs.keys()]) {
+          this.enabledOutputs.set(key, this.hasOutputTypeUnsafe(key))
+        }
+
+        const terminalOutput = this.terminalState?.outputType
+
+        if (terminalOutput) {
+          this.terminalEnabled = this.hasOutputTypeUnsafe(terminalOutput)
+        }
+
+        if (!(await mutater?.() ?? true)) {
+          return
+        }
+
+        let terminalCellOutput = this.terminalEnabled && terminalOutput && this.generateOutputUnsafe(terminalOutput)
+
+        await replaceOutput(exec, [
+          ...[ ...this.enabledOutputs.entries() ]
+            .flatMap(([type, enabled]) => {
+              if (!enabled) { return [] }
+
+              const output = this.generateOutputUnsafe(type)
+              if(!output) { return [] }
+
+              return [ output ]
+            }),
+          ...terminalCellOutput ? [terminalCellOutput] : [],
+          ...this.outputs,
+        ])
+      })
+    })
+  }
+
+  protected async getExecutionUnsafe(cb: (exec: NotebookCellExecution) => Promise<void>|void) {
+    if(this.execution) {
+      await cb?.(this.execution)
+      return
+    }
+
+    const exec = this.controller.createNotebookCellExecution(this.cell)
+    exec.start(Date.now())
+
+    try {
+      await cb(exec)
+    } catch(e) {
+      throw e
+    } finally {
+      exec.end(true)
+    }
+  }
+}
+
+function outputsAsArray(outputs: NotebookOutputs): readonly NotebookCellOutput[] {
+  return Array.isArray(outputs) ? outputs : [ outputs ]
+}
+
+type OnEndCallback = (info: {
+  success?: boolean
+  endTime?: number
+}) => Promise<void>
+
+export class RunmeNotebookCellExecution implements Disposable {
+  private _onEnd?: OnEndCallback
+
+  private _hasEnded = false
+
+  constructor(
+    private exec: NotebookCellExecution,
+  ) { }
+
+  onEnd(cb: OnEndCallback) {
+    this._onEnd = cb
+  }
+
+  start(startTime?: number): void {
+    return this.exec.start(startTime)
+  }
+
+  async end(success: boolean | undefined, endTime?: number): Promise<void> {
+    this.exec.end(success, endTime)
+    await this._onEnd?.({ success, endTime })
+    this._hasEnded = true
+  }
+
+  dispose() { }
+
+  get hasEnded() {
+    return this._hasEnded
+  }
+
+  get underlyingExecution() {
+    return this.exec
+  }
+}
+
+export async function updateCellMetadata(cell: NotebookCell, meta: Partial<Serializer.Metadata>) {
+  const edit = new WorkspaceEdit()
+
+  const notebookEdit = NotebookEdit.updateCellMetadata(cell.index, {
+    ...cell.metadata,
+    ...meta,
+  })
+
+  edit.set(cell.notebook.uri, [notebookEdit])
+  await workspace.applyEdit(edit)
+}
diff --git a/src/extension/commands/index.ts b/src/extension/commands/index.ts
index 3a7b735d..86dc0972 100644
--- a/src/extension/commands/index.ts
+++ b/src/extension/commands/index.ts
@@ -3,13 +3,13 @@ import os from 'node:os'
 
 import {
   NotebookCell, Uri, window, env, NotebookDocument, TextDocument, ViewColumn,
-  workspace, NotebookData, commands, NotebookCellData, NotebookCellKind, ExtensionContext, NotebookCellExecution
+  workspace, NotebookData, commands, NotebookCellData, NotebookCellKind, ExtensionContext
 } from 'vscode'
 import { v4 as uuidv4 } from 'uuid'
 
 import { getBinaryPath, getTLSDir, getTLSEnabled, isNotebookTerminalEnabledForCell } from '../../utils/configuration'
 import { Kernel } from '../kernel'
-import { getAnnotations, getTerminalByCell, openFileAsRunmeNotebook, replaceOutput } from '../utils'
+import { getAnnotations, getTerminalByCell, openFileAsRunmeNotebook } from '../utils'
 import RunmeServer from '../server/runmeServer'
 import { GrpcRunnerEnvironment } from '../runner'
 
@@ -17,42 +17,34 @@ function showWarningMessage () {
   return window.showWarningMessage('Couldn\'t find terminal! Was it already closed?')
 }
 
-export function openTerminal (kernel: Kernel, notebookTerminal: boolean, existingExecution?: NotebookCellExecution) {
+export function openIntegratedTerminal (cell: NotebookCell) {
+  const terminal = getTerminalByCell(cell)
+  if (!terminal) {
+    return showWarningMessage()
+  }
+
+  return terminal.show()
+}
+
+export function toggleTerminal (kernel: Kernel, notebookTerminal: boolean, forceShow = false) {
   return async function (cell: NotebookCell) {
+    if ((isNotebookTerminalEnabledForCell(cell) && notebookTerminal) || !getAnnotations(cell).interactive) {
+      const outputs = await kernel.getCellOutputs(cell)
+
+      if (!forceShow) {
+        await outputs.toggleTerminal()
+      } else {
+        await outputs.showTerminal()
+      }
+
+      return
+    }
+
     const terminal = getTerminalByCell(cell)
     if (!terminal) {
       return showWarningMessage()
     }
 
-    if (isNotebookTerminalEnabledForCell(cell) && notebookTerminal) {
-      const terminalOutput = kernel.getCellTerminalOutputPayload(cell)
-
-      if (terminalOutput) {
-        const runOnExec = async (exec: NotebookCellExecution) => {
-          await replaceOutput(exec, terminalOutput)
-        }
-
-        if (!existingExecution) {
-          let exec: NotebookCellExecution|undefined
-          try {
-            exec = await kernel.createCellExecution(cell)
-            exec.start(Date.now())
-
-            await runOnExec(exec)
-          } catch (e: any) {
-            window.showErrorMessage(e.message)
-          } finally {
-            exec?.end(true)
-            return
-          }
-        } else {
-          await runOnExec(existingExecution)
-        }
-
-        return
-      }
-    }
-
     return terminal.show()
   }
 }
diff --git a/src/extension/executors/deno.ts b/src/extension/executors/deno.ts
index 421a6d8a..b09d95d2 100644
--- a/src/extension/executors/deno.ts
+++ b/src/extension/executors/deno.ts
@@ -2,6 +2,7 @@ import { NotebookCellExecution, TextDocument, window } from 'vscode'
 
 import { DENO_ACCESS_TOKEN_KEY } from '../constants'
 import type { Kernel } from '../kernel'
+import { NotebookCellOutputManager } from '../cell'
 
 import { bash } from './task'
 import { deploy } from './deno/deploy'
@@ -12,6 +13,7 @@ export async function deno (
   this: Kernel,
   exec: NotebookCellExecution,
   doc: TextDocument,
+  outputs: NotebookCellOutputManager,
   runScript?: () => Promise<boolean>,
   environment = ENV_STORE_MANAGER
 ): Promise<boolean> {
@@ -32,11 +34,11 @@ export async function deno (
     /**
      * run actual deno deployment as bash script
      */
-    runScript?.() ?? bash.call(this, exec, doc),
+    runScript?.() ?? bash.call(this, exec, doc, outputs),
     /**
      * fetch data and render custom output
      */
-    deploy.call(this, exec, environment)
+    deploy.call(this, exec, outputs, environment)
   ]).then(
     ([bashSuccess, deploySuccess]) => bashSuccess && deploySuccess
   )
diff --git a/src/extension/executors/deno/deploy.ts b/src/extension/executors/deno/deploy.ts
index 6b6181d8..d0f54ec9 100644
--- a/src/extension/executors/deno/deploy.ts
+++ b/src/extension/executors/deno/deploy.ts
@@ -1,17 +1,18 @@
-import { NotebookCellOutput, NotebookCellOutputItem, NotebookCellExecution } from 'vscode'
+import { NotebookCellExecution } from 'vscode'
 
-import { renderError } from '../utils'
 import { OutputType, ClientMessages } from '../../../constants'
 import { DENO_ACCESS_TOKEN_KEY, DENO_PROJECT_NAME_KEY } from '../../constants'
 import { API } from '../../../utils/deno/api'
 import type { Kernel } from '../../kernel'
-import type { CellOutputPayload, ClientMessage } from '../../../types'
-import { replaceOutput } from '../../utils'
+import type { DenoState } from '../../../types'
 import { ENV_STORE_MANAGER, type IEnvironmentManager } from '..'
+import { postClientMessage } from '../../../utils/messaging'
+import { NotebookCellOutputManager, updateCellMetadata } from '../../cell'
 
 export async function deploy (
   this: Kernel,
   exec: NotebookCellExecution,
+  outputs: NotebookCellOutputManager,
   environment?: IEnvironmentManager
 ): Promise<boolean> {
   environment ??= ENV_STORE_MANAGER
@@ -28,13 +29,14 @@ export async function deploy (
       throw new Error('No token supplied')
     }
 
-    /**
-     * render deno status at the behinning of the operation
-     */
-    replaceOutput(exec, new NotebookCellOutput([
-      NotebookCellOutputItem.json(
-        <CellOutputPayload<OutputType.deno>>{ type: OutputType.deno }, OutputType.deno)
-    ], { deno: { deploy: true } }))
+    const denoState: DenoState = {
+      ...exec.cell.metadata['runme.dev/denoState'],
+      error: undefined
+    }
+
+    updateCellMetadata(exec.cell, { 'runme.dev/denoState': denoState })
+
+    outputs.showOutput(OutputType.deno)
 
     const start = new Date()
     const denoAPI = API.fromToken(token)
@@ -55,14 +57,17 @@ export async function deploy (
       }
 
       deployed = created > start
-      this.messaging.postMessage(<ClientMessage<ClientMessages.update>>{
-        type: ClientMessages.update,
-        output: {
-          deployed,
-          deployments,
-          project
-        }
-      })
+
+      const denoState: DenoState = {
+        ...exec.cell.metadata['runme.dev/denoState'],
+        deployed,
+        deployments: deployments ?? undefined,
+        project,
+        error: undefined
+      }
+
+      updateCellMetadata(exec.cell, { 'runme.dev/denoState': denoState })
+      postClientMessage(this.messaging, ClientMessages.denoUpdate, denoState)
 
       // keep going slower after 20 loops
       const timeout = 1000 + Math.max(0, iteration - 20) * 1000
@@ -71,11 +76,17 @@ export async function deploy (
       iteration++
     }
     if (!deployed) {
-      renderError(exec, 'Timed out')
-      return false
+      throw new Error('Timed out')
     }
   } catch (err: any) {
-    renderError(exec, err.message)
+    const denoState: DenoState = {
+      ...exec.cell.metadata['runme.dev/denoState'],
+      error: err.message,
+    }
+
+    updateCellMetadata(exec.cell, { 'runme.dev/denoState': denoState })
+    outputs.showOutput(OutputType.deno)
+
     return false
   }
 
diff --git a/src/extension/executors/runner.ts b/src/extension/executors/runner.ts
index 4cb530fc..99fde2c8 100644
--- a/src/extension/executors/runner.ts
+++ b/src/extension/executors/runner.ts
@@ -16,16 +16,17 @@ import {
 } from 'vscode'
 import { Subject, debounceTime } from 'rxjs'
 
-import { ClientMessages, OutputType } from '../../constants'
-import { CellOutputPayload, ClientMessage } from '../../types'
+import { ClientMessages } from '../../constants'
+import { ClientMessage } from '../../types'
 import { PLATFORM_OS } from '../constants'
 import { IRunner, IRunnerEnvironment, RunProgramExecution } from '../runner'
-import { getAnnotations, getCmdShellSeq, getTerminalByCell, prepareCmdSeq, replaceOutput } from '../utils'
+import { getAnnotations, getCmdShellSeq, getTerminalByCell, prepareCmdSeq } from '../utils'
 import { postClientMessage } from '../../utils/messaging'
 import { isNotebookTerminalEnabledForCell } from '../../utils/configuration'
 import { Kernel } from '../kernel'
 import { ITerminalState } from '../terminal/terminalState'
-import { openTerminal } from '../commands'
+import { toggleTerminal } from '../commands'
+import { NotebookCellOutputManager } from '../cell'
 
 import { closeTerminalByEnvID } from './task'
 import { getShellPath, parseCommandSeq } from './utils'
@@ -46,6 +47,7 @@ export async function executeRunner(
   messaging: NotebookRendererMessaging,
   cellUUID: string,
   execKey: 'bash' | 'sh',
+  outputs: NotebookCellOutputManager,
   environment?: IRunnerEnvironment,
   environmentManager?: IEnvironmentManager
 ) {
@@ -59,7 +61,7 @@ export async function executeRunner(
 
     if (terminal && terminal.runnerSession) {
       if (!terminal.runnerSession.hasExited()) {
-        openTerminal(kernel, true, exec)(exec.cell)
+        await toggleTerminal(kernel, true, true)(exec.cell)
         return true
       } else {
         terminal.dispose()
@@ -129,8 +131,6 @@ export async function executeRunner(
     terminalState?.write(data)
   }
 
-  program.onDidWrite(writeToTerminalStdout)
-
   program.onDidErr((data) => postClientMessage(messaging, ClientMessages.terminalStderr, {
     'runme.dev/uuid': cellUUID,
     data
@@ -189,23 +189,20 @@ export async function executeRunner(
 
   const mime = mimeType || 'text/plain' as const
 
+  terminalState = await kernel.registerCellTerminalState(exec.cell, revealNotebookTerminal ? 'xterm' : 'local')
+
   if (
-    revealNotebookTerminal &&
     MIME_TYPES_WITH_CUSTOM_RENDERERS.includes(mime) &&
     !isVercelDeployScript(script)
   ) {
-    terminalState = kernel.registerCellTerminalState(exec.cell, 'xterm')
-
-    const terminalOutput = kernel.getCellTerminalOutputPayload(exec.cell)
-
-    if (terminalOutput) {
-      await replaceOutput(exec, terminalOutput)
-    } else {
-      revealNotebookTerminal = false
+    if (revealNotebookTerminal) {
+      program.registerTerminalWindow('notebook')
+      await program.setActiveTerminalWindow('notebook')
     }
 
-    program.registerTerminalWindow('notebook')
-    await program.setActiveTerminalWindow('notebook')
+    program.onDidWrite(writeToTerminalStdout)
+
+    await outputs.showTerminal()
   } else {
     const output: Buffer[] = []
     const outputItems$ = new Subject<NotebookCellOutputItem>()
@@ -214,29 +211,27 @@ export async function executeRunner(
     const _handleOutput = async (data: Uint8Array) => {
       output.push(Buffer.from(data))
 
-      let item = new NotebookCellOutputItem(Buffer.concat(output), mime)
+      let item: NotebookCellOutputItem|undefined = new NotebookCellOutputItem(Buffer.concat(output), mime)
 
       // hacky for now, maybe inheritence is a fitting pattern
       if (isVercelDeployScript(script)) {
-        item = await handleVercelDeployOutput(
-          output, exec.cell.index, vercelProd, environmentManager
+        await handleVercelDeployOutput(
+          exec.cell, outputs, output, exec.cell.index, vercelProd, environmentManager
         )
+
+        item = undefined
       } else if (MIME_TYPES_WITH_CUSTOM_RENDERERS.includes(mime)) {
-        item = NotebookCellOutputItem.json(<CellOutputPayload<OutputType.outputItems>>{
-          type: OutputType.outputItems,
-          output: {
-            content: Buffer.concat(output).toString('base64'),
-            mime
-          }
-        }, OutputType.outputItems)
+        item = undefined
       }
 
-      outputItems$.next(item)
+      if (item) {
+        outputItems$.next(item)
+      }
     }
 
     // debounce by 0.5s because human preception likely isn't as fast
     const sub = outputItems$.pipe(debounceTime(500)).subscribe((item) =>
-      replaceOutput(exec, [new NotebookCellOutput([item])])
+      outputs.replaceOutputs([new NotebookCellOutput([item])])
     )
 
     context.subscriptions.push({ dispose: () => sub.unsubscribe() })
@@ -251,6 +246,9 @@ export async function executeRunner(
       program.close()
     })
   } else {
+    await outputs.replaceOutputs([ ])
+    await outputs.showTerminal()
+
     const taskExecution = new Task(
       { type: 'shell', name: `Runme Task (${RUNME_ID})` },
       TaskScope.Workspace,
diff --git a/src/extension/executors/shell.ts b/src/extension/executors/shell.ts
index 8766e44e..07ae787b 100644
--- a/src/extension/executors/shell.ts
+++ b/src/extension/executors/shell.ts
@@ -4,9 +4,10 @@ import { NotebookCellOutput, NotebookCellOutputItem, NotebookCellExecution } fro
 
 import { OutputType } from '../../constants'
 import type { CellOutputPayload } from '../../types'
+import { NotebookCellOutputManager } from '../cell'
 import { ENV_STORE } from '../constants'
 import type { Kernel } from '../kernel'
-import { getAnnotations, replaceOutput } from '../utils'
+import { getAnnotations } from '../utils'
 
 import { handleVercelDeployOutput, isVercelDeployScript } from './vercel'
 
@@ -17,7 +18,8 @@ async function shellExecutor(
   exec: NotebookCellExecution,
   script: string,
   cwd: string,
-  env: Record<string, string>
+  env: Record<string, string>,
+  outputs: NotebookCellOutputManager,
 ): Promise<boolean> {
   let postScript = script
   let prod = false
@@ -41,11 +43,13 @@ async function shellExecutor(
    */
   async function handleOutput(data: Buffer) {
     outputItems.push(data)
-    let item = new NotebookCellOutputItem(Buffer.concat(outputItems), mime)
+    let item: NotebookCellOutputItem|undefined
 
     // hacky for now, maybe inheritence is a fitting pattern
     if (isVercelDeployScript(script)) {
-      item = await handleVercelDeployOutput(
+      await handleVercelDeployOutput(
+        exec.cell,
+        outputs,
         outputItems,
         index,
         prod,
@@ -59,9 +63,13 @@ async function shellExecutor(
           mime
         }
       }, OutputType.outputItems)
+    } else {
+      item = new NotebookCellOutputItem(Buffer.concat(outputItems), mime)
     }
 
-    replaceOutput(exec, [ new NotebookCellOutput([ item ]) ])
+    if (item) {
+      outputs.replaceOutputs([ new NotebookCellOutput([ item ]) ])
+    }
   }
 
   child.stdout.on('data', handleOutput)
diff --git a/src/extension/executors/task.ts b/src/extension/executors/task.ts
index d9c5f52a..dfecb3cf 100644
--- a/src/extension/executors/task.ts
+++ b/src/extension/executors/task.ts
@@ -10,6 +10,7 @@ import {
 import { getCmdShellSeq, getAnnotations, getTerminalRunmeId } from '../utils'
 import { PLATFORM_OS, ENV_STORE } from '../constants'
 import type { Kernel } from '../kernel'
+import { NotebookCellOutputManager } from '../cell'
 
 import { retrieveShellCommand } from './utils'
 import { sh as inlineSh } from './shell'
@@ -31,7 +32,8 @@ export function closeTerminalByEnvID (id: string, kill?: boolean) {
 async function taskExecutor(
   this: Kernel,
   exec: NotebookCellExecution,
-  doc: TextDocument
+  doc: TextDocument,
+  outputs: NotebookCellOutputManager,
 ): Promise<boolean> {
   const cwd = path.dirname(doc.uri.fsPath)
   const cellText = await retrieveShellCommand(exec)
@@ -63,7 +65,7 @@ async function taskExecutor(
    */
   const isInteractive = getAnnotations(exec.cell).interactive
   if (!isInteractive) {
-    return inlineSh.call(this, exec, cmdLine, cwd, env)
+    return inlineSh.call(this, exec, cmdLine, cwd, env, outputs)
   }
 
   const taskExecution = new Task(
diff --git a/src/extension/executors/utils.ts b/src/extension/executors/utils.ts
index d9da2d14..f5f7ae3e 100644
--- a/src/extension/executors/utils.ts
+++ b/src/extension/executors/utils.ts
@@ -6,7 +6,7 @@ import { NotebookCellOutput, NotebookCellExecution, NotebookCellOutputItem, wind
 import { ENV_STORE } from '../constants'
 import { OutputType } from '../../constants'
 import type { CellOutputPayload } from '../../types'
-import { replaceOutput } from '../utils'
+import { NotebookCellOutputManager } from '../cell'
 
 const ENV_VAR_REGEXP = /(\$\w+)/g
 /**
@@ -14,8 +14,8 @@ const ENV_VAR_REGEXP = /(\$\w+)/g
  */
 const EXPORT_EXTRACT_REGEX = /(\n*)export \w+=(("[^"]*")|('[^']*')|(.+(?=(\n|;))))/gim
 
-export function renderError (exec: NotebookCellExecution, output: string) {
-  return replaceOutput(exec, new NotebookCellOutput([
+export function renderError (outputs: NotebookCellOutputManager, output: string) {
+  return outputs.replaceOutputs(new NotebookCellOutput([
     NotebookCellOutputItem.json(
       <CellOutputPayload<OutputType.error>>{
         type: OutputType.error,
diff --git a/src/extension/executors/vercel.ts b/src/extension/executors/vercel.ts
index 3581adf9..62729369 100644
--- a/src/extension/executors/vercel.ts
+++ b/src/extension/executors/vercel.ts
@@ -1,15 +1,15 @@
 import yargs from 'yargs/yargs'
 import { hideBin } from 'yargs/helpers'
 import type { Argv } from 'yargs'
-import { TextDocument, NotebookCellExecution, NotebookCellOutputItem } from 'vscode'
+import { TextDocument, NotebookCellExecution, NotebookCell } from 'vscode'
 
 import type { Kernel } from '../kernel'
+import { VercelState } from '../../types'
+import { NotebookCellOutputManager, updateCellMetadata } from '../cell'
 import { OutputType } from '../../constants'
-import { CellOutputPayload } from '../../types'
 
 import { bash } from './task'
 import { deploy, login, logout } from './vercel/index'
-import { renderError } from './utils'
 
 import type { IEnvironmentManager } from '.'
 
@@ -19,62 +19,71 @@ export async function vercel (
   this: Kernel,
   exec: NotebookCellExecution,
   doc: TextDocument,
+  outputs: NotebookCellOutputManager,
   runScript?: () => Promise<boolean>,
 ): Promise<boolean> {
   const command = doc.getText()
 
-  /**
-   * limit vercel commands to single lines
-   */
-  if (command.includes('\n')) {
-    renderError(exec, 'Currently only one-liner Vercel commands are supported')
-    return false
-  }
+  try {
+    /**
+     * limit vercel commands to single lines
+     */
+    if (command.includes('\n')) {
+      throw new Error('Currently only one-liner Vercel commands are supported')
+    }
 
-  const parsedArgv: Argv<any> = await yargs(hideBin(command.split(' ')))
-    .version(false)
-    .option('version', { alias: 'v', type: 'boolean' })
-    .option('cwd', { type: 'string' })
-    .option('platform-version', { alias: 'V', type: 'string' })
-    .option('local-config', { alias: 'A', type: 'string' })
-    .option('global-config', { alias: 'Q', type: 'string' })
-    .option('debug', { alias: 'd', type: 'boolean' })
-    .option('force', { alias: 'f', type: 'boolean' })
-    .option('with-cache', { type: 'string' })
-    .option('token', { alias: 't', type: 'string' })
-    .option('public', { alias: 'p', type: 'boolean' })
-    .option('env', { alias: 'e', type: 'string', array: true })
-    .option('build-env', { alias: 'b', type: 'string', array: true })
-    .option('meta', { alias: 'm', type: 'string', array: true })
-    .option('scope', { alias: 'S', type: 'string' })
-    .option('regions', { type: 'string', array: true })
-    .option('prod', { type: 'boolean' })
-    .option('yes', { alias: 'y', type: 'boolean' })
-    .option('github', { type: 'boolean' })
-    .option('gitlab', { type: 'boolean' })
-    .option('bitbucket', { type: 'boolean' })
-  const vercelCommand = ((await parsedArgv.argv)._)[0] || DEFAULT_COMMAND
+    const parsedArgv: Argv<any> = await yargs(hideBin(command.split(' ')))
+      .version(false)
+      .option('version', { alias: 'v', type: 'boolean' })
+      .option('cwd', { type: 'string' })
+      .option('platform-version', { alias: 'V', type: 'string' })
+      .option('local-config', { alias: 'A', type: 'string' })
+      .option('global-config', { alias: 'Q', type: 'string' })
+      .option('debug', { alias: 'd', type: 'boolean' })
+      .option('force', { alias: 'f', type: 'boolean' })
+      .option('with-cache', { type: 'string' })
+      .option('token', { alias: 't', type: 'string' })
+      .option('public', { alias: 'p', type: 'boolean' })
+      .option('env', { alias: 'e', type: 'string', array: true })
+      .option('build-env', { alias: 'b', type: 'string', array: true })
+      .option('meta', { alias: 'm', type: 'string', array: true })
+      .option('scope', { alias: 'S', type: 'string' })
+      .option('regions', { type: 'string', array: true })
+      .option('prod', { type: 'boolean' })
+      .option('yes', { alias: 'y', type: 'boolean' })
+      .option('github', { type: 'boolean' })
+      .option('gitlab', { type: 'boolean' })
+      .option('bitbucket', { type: 'boolean' })
+    const vercelCommand = ((await parsedArgv.argv)._)[0] || DEFAULT_COMMAND
 
-  /**
-   * special commands handled by the kernel
-   */
-  if (vercelCommand === 'deploy') {
-    return deploy.call(this, exec, doc)
-  }
-  if (vercelCommand === 'login') {
-    return login.call(this, exec, parsedArgv)
-  }
-  if (vercelCommand === 'logout') {
-    return logout.call(this, exec)
-  }
+    /**
+     * special commands handled by the kernel
+     */
+    if (vercelCommand === 'deploy') {
+      return deploy.call(this, exec, doc, outputs)
+    }
+    if (vercelCommand === 'login') {
+      return login.call(this, exec, parsedArgv, outputs)
+    }
+    if (vercelCommand === 'logout') {
+      return logout.call(this, exec, outputs)
+    }
 
-  /**
-   * other commands passed to the CLI
-   */
-  return runScript?.() ?? bash.call(this, exec, doc)
+    /**
+     * other commands passed to the CLI
+     */
+    return runScript?.() ?? bash.call(this, exec, doc, outputs)
+  } catch(err: any) {
+    updateCellMetadata(exec.cell, { 'runme.dev/vercelState': { error: err.message, outputItems: [] }})
+    outputs.showOutput(OutputType.vercel)
+
+    return false
+  }
 }
 
 export async function handleVercelDeployOutput(
+  cell: NotebookCell,
+  outputs: NotebookCellOutputManager,
   outputItems: Buffer[],
   index: number,
   prod: boolean,
@@ -94,14 +103,13 @@ export async function handleVercelDeployOutput(
   // should get this from API instead
   const projectName = await environment?.get('PROJECT_NAME')
 
-  const json = <CellOutputPayload<OutputType.vercel>>{
-    type: OutputType.vercel,
-    output: {
-      outputItems: outputItems.map((oi) => oi.toString()),
-      payload: { status, projectName, index, prod }
-    }
+  const vercelState: VercelState = {
+    outputItems: outputItems.map((oi) => oi.toString()),
+    payload: { status, projectName, index, prod }
   }
-  return NotebookCellOutputItem.json(json, OutputType.vercel)
+
+  updateCellMetadata(cell, { 'runme.dev/vercelState': vercelState })
+  await outputs.showOutput(OutputType.vercel)
 }
 
 export function isVercelDeployScript(script: string): boolean {
diff --git a/src/extension/executors/vercel/auth.ts b/src/extension/executors/vercel/auth.ts
index f8565737..2c1ab59b 100644
--- a/src/extension/executors/vercel/auth.ts
+++ b/src/extension/executors/vercel/auth.ts
@@ -8,7 +8,7 @@ import { NotebookCellExecution, NotebookCellOutputItem, NotebookCellOutput, wind
 import type { Argv } from 'yargs'
 
 import { renderError } from '../utils'
-import { replaceOutput } from '../../utils'
+import { NotebookCellOutputManager } from '../../cell'
 
 import { getAuthToken, getConfigFilePath } from './utils'
 
@@ -28,7 +28,8 @@ interface VercelCLILogin {
 
 export async function login (
   exec: NotebookCellExecution,
-  argv: Argv<VercelCLILogin>
+  argv: Argv<VercelCLILogin>,
+  outputs: NotebookCellOutputManager,
 ): Promise<boolean> {
   const args = await argv.argv
   const method = (
@@ -39,12 +40,12 @@ export async function login (
   )
 
   if (!method) {
-    renderError(exec, 'Please select login method.')
+    renderError(outputs, 'Please select login method.')
     return false
   }
 
   if (!method.includes('GitHub') && method !== 'github') {
-    renderError(exec, 'Login method not supported.')
+    renderError(outputs, 'Login method not supported.')
     return false
   }
 
@@ -92,24 +93,24 @@ export async function login (
     await fs.writeFile(configFilePath, JSON.stringify({ token: verifyResponse.token }))
     server.close()
   } catch (err: any) {
-    replaceOutput(exec, new NotebookCellOutput([
+    outputs.replaceOutputs(new NotebookCellOutput([
       NotebookCellOutputItem.text(err.message)
     ]))
     return false
   }
 
   const { username } = await authPromise as any
-  replaceOutput(exec, new NotebookCellOutput([
+  outputs.replaceOutputs(new NotebookCellOutput([
     NotebookCellOutputItem.text(`Logged in as ${username}`)
   ]))
   return true
 }
 
-export async function logout (exec: NotebookCellExecution): Promise<boolean> {
+export async function logout (exec: NotebookCellExecution, outputs: NotebookCellOutputManager): Promise<boolean> {
   let token = await getAuthToken()
 
   if (!token) {
-    replaceOutput(exec, new NotebookCellOutput([
+    outputs.replaceOutputs(new NotebookCellOutput([
       NotebookCellOutputItem.text('Not currently logged in, so logout did nothing')
     ]))
     return true
@@ -127,9 +128,9 @@ export async function logout (exec: NotebookCellExecution): Promise<boolean> {
     })
   } catch (err: any) {
     if (err.status === 403) {
-      renderError(exec, 'Token is invalid so it cannot be revoked')
+      renderError(outputs, 'Token is invalid so it cannot be revoked')
     } else if (err.status !== 200) {
-      renderError(exec, `Failed revoking auth token: ${err.message}`)
+      renderError(outputs, `Failed revoking auth token: ${err.message}`)
     }
   }
 
@@ -140,11 +141,11 @@ export async function logout (exec: NotebookCellExecution): Promise<boolean> {
     const configFilePath = await getConfigFilePath()
     await fs.unlink(configFilePath)
   } catch (err: any) {
-    renderError(exec, `Failed during logout: ${err.message}`)
+    renderError(outputs, `Failed during logout: ${err.message}`)
     return false
   }
 
-  replaceOutput(exec, new NotebookCellOutput([
+  outputs.replaceOutputs(new NotebookCellOutput([
     NotebookCellOutputItem.text('Logged out!')
   ]))
   return true
diff --git a/src/extension/executors/vercel/deploy.ts b/src/extension/executors/vercel/deploy.ts
index d373f121..84e29218 100644
--- a/src/extension/executors/vercel/deploy.ts
+++ b/src/extension/executors/vercel/deploy.ts
@@ -4,13 +4,12 @@ import path from 'node:path'
 import sanitize from 'filenamify'
 import frameworkList, { Framework } from '@vercel/frameworks'
 import { createDeployment, VercelClientOptions, DeploymentOptions } from '@vercel/client'
-import { TextDocument, NotebookCellOutput, NotebookCellOutputItem, NotebookCellExecution, window } from 'vscode'
+import { TextDocument, NotebookCellExecution, window } from 'vscode'
 
-import { renderError } from '../utils'
 import { OutputType } from '../../../constants'
 import type { Kernel } from '../../kernel'
-import type { CellOutputPayload } from '../../../types'
-import { replaceOutput } from '../../utils'
+import type { VercelState } from '../../../types'
+import { NotebookCellOutputManager, updateCellMetadata } from '../../cell'
 
 import { listTeams, getUser, getProject, getProjects, createProject, cancelDeployment, VercelProject } from './api'
 import { getAuthToken, quickPick, updateGitIgnore, createVercelFile } from './utils'
@@ -26,6 +25,7 @@ export async function deploy (
   this: Kernel,
   exec: NotebookCellExecution,
   doc: TextDocument,
+  outputs: NotebookCellOutputManager,
 ): Promise<boolean> {
   let token = await getAuthToken()
   const cwd = path.dirname(doc.uri.fsPath)
@@ -143,29 +143,28 @@ export async function deploy (
     }))
     for await (const event of createDeployment(clientParams, deployParams)) {
       if (event.type === 'error') {
-        renderError(exec, event.payload.message)
-        return false
+        throw Error(event.payload.message)
       }
 
       deploymentId = event.payload.id
-      replaceOutput(exec, new NotebookCellOutput([
-        NotebookCellOutputItem.json(<CellOutputPayload<OutputType.vercel>>{
-          type: OutputType.vercel,
-          output: event
-        }, OutputType.vercel)
-      ]))
+
+      const vercelState: VercelState = {
+        outputItems: [],
+        payload: event.payload,
+        type: event.type,
+      }
+
+      updateCellMetadata(exec.cell, { 'runme.dev/vercelState': vercelState })
+      outputs.showOutput(OutputType.vercel)
 
       if (deployCanceled) {
         break
       }
     }
   } catch (err: any) {
-    replaceOutput(exec, new NotebookCellOutput([
-      NotebookCellOutputItem.json(<CellOutputPayload<OutputType.error>>{
-        type: OutputType.error,
-        output: err.message
-      }, OutputType.error)
-    ]))
+    updateCellMetadata(exec.cell, { 'runme.dev/vercelState': { error: err.message, outputItems: [] }})
+    outputs.showOutput(OutputType.vercel)
+
     return false
   }
 
diff --git a/src/extension/extension.ts b/src/extension/extension.ts
index d88ee574..2af20597 100644
--- a/src/extension/extension.ts
+++ b/src/extension/extension.ts
@@ -5,13 +5,13 @@ import { TelemetryReporter } from 'vscode-telemetry'
 import { Kernel } from './kernel'
 import RunmeServer from './server/runmeServer'
 import RunmeServerError from './server/runmeServerError'
-import { ShowTerminalProvider, BackgroundTaskProvider, StopBackgroundTaskProvider } from './provider/background'
+import { ToggleTerminalProvider, BackgroundTaskProvider, StopBackgroundTaskProvider } from './provider/background'
 import { CopyProvider } from './provider/copy'
 import { getDefaultWorkspace, resetEnv } from './utils'
 import { AnnotationsProvider } from './provider/annotations'
 import { RunmeTaskProvider } from './provider/runmeTask'
 import {
-  openTerminal,
+  toggleTerminal,
   runCLICommand,
   copyCellToClipboard,
   openAsRunmeNotebook,
@@ -20,7 +20,8 @@ import {
   createNewRunmeNotebook,
   welcome,
   tryIt,
-  openFileInRunme
+  openFileInRunme,
+  openIntegratedTerminal
 } from './commands'
 import { WasmSerializer, GrpcSerializer } from './serializer'
 import { RunmeLauncherProvider } from './provider/launcher'
@@ -48,7 +49,9 @@ export class RunmeExtension {
       kernel.useRunner(runner)
     }
 
-    const serializer = grpcSerializer ? new GrpcSerializer(context, server) : new WasmSerializer(context)
+    const serializer = grpcSerializer ?
+      new GrpcSerializer(context, server, kernel) :
+      new WasmSerializer(context, kernel)
 
     /**
      * Start the Runme server
@@ -90,20 +93,19 @@ export class RunmeExtension {
         },
       }),
 
-      notebooks.registerNotebookCellStatusBarItemProvider(Kernel.type, new ShowTerminalProvider()),
+      notebooks.registerNotebookCellStatusBarItemProvider(Kernel.type, new ToggleTerminalProvider(kernel)),
       notebooks.registerNotebookCellStatusBarItemProvider(Kernel.type, new BackgroundTaskProvider()),
       notebooks.registerNotebookCellStatusBarItemProvider(Kernel.type, new CopyProvider()),
       notebooks.registerNotebookCellStatusBarItemProvider(Kernel.type, stopBackgroundTaskProvider),
       notebooks.registerNotebookCellStatusBarItemProvider(Kernel.type, new AnnotationsProvider(kernel)),
-      // notebooks.registerNotebookCellStatusBarItemProvider(Kernel.type, new TerminalViewProvider(kernel)),
 
       stopBackgroundTaskProvider,
 
       codeLensProvider,
 
       commands.registerCommand('runme.resetEnv', resetEnv),
-      RunmeExtension.registerCommand('runme.openTerminal', openTerminal(kernel, !!grpcRunner)),
-      RunmeExtension.registerCommand('runme.openIntegratedTerminal', openTerminal(kernel, false)),
+      RunmeExtension.registerCommand('runme.openIntegratedTerminal', openIntegratedTerminal),
+      RunmeExtension.registerCommand('runme.toggleTerminal', toggleTerminal(kernel, !!grpcRunner)),
       RunmeExtension.registerCommand(
         'runme.runCliCommand',
         runCLI
diff --git a/src/extension/kernel.ts b/src/extension/kernel.ts
index 955e58b8..378e48d0 100644
--- a/src/extension/kernel.ts
+++ b/src/extension/kernel.ts
@@ -7,25 +7,18 @@ import {
   NotebookEditor,
   NotebookCell,
   NotebookCellKind,
-  NotebookCellExecution,
   WorkspaceEdit,
   NotebookEdit,
   NotebookDocument,
-  NotebookCellOutput,
-  NotebookCellOutputItem,
   env,
   Uri
 } from 'vscode'
 import { TelemetryReporter } from 'vscode-telemetry'
 
-import type { CellOutputPayload, ClientMessage, Serializer } from '../types'
-import { ClientMessages, OutputType } from '../constants'
+import type { ClientMessage, Serializer } from '../types'
+import { ClientMessages } from '../constants'
 import { API } from '../utils/deno/api'
-import {
-  getNotebookTerminalFontFamily,
-  getNotebookTerminalFontSize,
-  getNotebookTerminalRows
-} from '../utils/configuration'
+import { postClientMessage } from '../utils/messaging'
 
 import executor, { type IEnvironmentManager, ENV_STORE_MANAGER } from './executors'
 import { DENO_ACCESS_TOKEN_KEY } from './constants'
@@ -33,7 +26,8 @@ import { resetEnv, getKey, getAnnotations, hashDocumentUri, processEnviron, isWi
 import './wasm/wasm_exec.js'
 import { IRunner, IRunnerEnvironment } from './runner'
 import { executeRunner } from './executors/runner'
-import { ITerminalState, NotebookTerminalType, XTermState } from './terminal/terminalState'
+import { ITerminalState, NotebookTerminalType } from './terminal/terminalState'
+import { NotebookCellManager, NotebookCellOutputManager, RunmeNotebookCellExecution } from './cell'
 
 enum ConfirmationItems {
   Yes = 'Yes',
@@ -59,7 +53,7 @@ export class Kernel implements Disposable {
   protected environment?: IRunnerEnvironment
   protected runnerReadyListener?: Disposable
 
-  protected cellTerminalState = new WeakMap<NotebookCell, ITerminalState>()
+  protected cellManager = new NotebookCellManager(this.#controller)
 
   constructor(
     protected context: ExtensionContext,
@@ -83,6 +77,10 @@ export class Kernel implements Disposable {
     )
   }
 
+  registerNotebookCell(cell: NotebookCell) {
+    this.cellManager.registerCell(cell)
+  }
+
   hasExperimentEnabled(key: string, defaultValue?: boolean) {
     return this.#experiments.get(key) || defaultValue
   }
@@ -94,50 +92,13 @@ export class Kernel implements Disposable {
     this.runnerReadyListener?.dispose()
   }
 
-  getCellTerminalState(cell: NotebookCell): ITerminalState | undefined {
-    return this.cellTerminalState.get(cell)
-  }
-
-  registerCellTerminalState(cell: NotebookCell, type: NotebookTerminalType): ITerminalState {
-    let terminalState: ITerminalState
-
-    switch (type) {
-      case 'xterm': {
-        terminalState = new XTermState()
-      } break
-    }
-
-    this.cellTerminalState.set(cell, terminalState)
-
-    return terminalState
+  async getTerminalState(cell: NotebookCell): Promise<ITerminalState|undefined> {
+    return (await this.getCellOutputs(cell)).getCellTerminalState()
   }
 
-  getCellTerminalOutputPayload(cell: NotebookCell): NotebookCellOutput | undefined {
-    const terminalState = this.getCellTerminalState(cell)
-    if (!terminalState) { return }
-
-    const cellId = getAnnotations(cell)['runme.dev/uuid']
-    if (!cellId) { throw new Error('Cannot open cell terminal with invalid UUID!') }
-
-    const editorSettings = workspace.getConfiguration('editor')
-
-    const terminalFontFamily = getNotebookTerminalFontFamily() ?? editorSettings.get<string>('fontFamily', 'Arial')
-    const terminalFontSize = getNotebookTerminalFontSize() ?? editorSettings.get<number>('fontSize', 10)
-
-    const json: CellOutputPayload<OutputType.terminal> = {
-      type: OutputType.terminal,
-      output: {
-        'runme.dev/uuid': cellId,
-        terminalFontFamily,
-        terminalFontSize,
-        content: terminalState.serialize(),
-        initialRows: getNotebookTerminalRows(),
-      }
-    }
-
-    return new NotebookCellOutput([
-      NotebookCellOutputItem.json(json, OutputType.terminal),
-    ])
+  async registerCellTerminalState(cell: NotebookCell, type: NotebookTerminalType): Promise<ITerminalState> {
+    const outputs = await this.cellManager.getNotebookOutputs(cell)
+    return outputs.registerCellTerminalState(type)
   }
 
   async #handleSaveNotebook({ uri, isUntitled, notebookType }: NotebookDocument) {
@@ -154,10 +115,11 @@ export class Kernel implements Disposable {
   }
 
 
-  async #handleOpenNotebook({ uri, isUntitled, notebookType }: NotebookDocument) {
+  async #handleOpenNotebook({ uri, isUntitled, notebookType, getCells }: NotebookDocument) {
     if (notebookType !== Kernel.type) {
       return
     }
+    getCells().forEach(cell => this.registerNotebookCell(cell))
     const isReadme = uri.fsPath.toUpperCase().includes('README')
     const hashed = hashDocumentUri(uri.toString())
     TelemetryReporter.sendTelemetryEvent('notebook.open', {
@@ -217,8 +179,8 @@ export class Kernel implements Disposable {
       }
 
       return
-    } else if (message.type === ClientMessages.promote) {
-      const payload = message as ClientMessage<ClientMessages.promote>
+    } else if (message.type === ClientMessages.denoPromote) {
+      const payload = message
       const token = await this.getEnvironmentManager().get(DENO_ACCESS_TOKEN_KEY)
       if (!token) {
         return
@@ -229,12 +191,11 @@ export class Kernel implements Disposable {
         payload.output.id,
         payload.output.productionDeployment
       )
-      this.messaging.postMessage(<ClientMessage<ClientMessages.deployed>>{
-        type: ClientMessages.deployed,
-        output: deployed,
+      postClientMessage(this.messaging, ClientMessages.denoUpdate, {
+        promoted: deployed.valueOf()
       })
-    } else if (message.type === ClientMessages.prod) {
-      const payload = message as ClientMessage<ClientMessages.prod>
+    } else if (message.type === ClientMessages.vercelProd) {
+      const payload = message as ClientMessage<ClientMessages.vercelProd>
       // eslint-disable-next-line @typescript-eslint/no-unused-vars
       const cell = editor.notebook.cellAt(payload.output.cellIndex)
       if (cell.executionSummary?.success) {
@@ -312,13 +273,18 @@ export class Kernel implements Disposable {
     })
   }
 
-  public async createCellExecution(cell: NotebookCell): Promise<NotebookCellExecution> {
-    return this.#controller.createNotebookCellExecution(cell)
+  public async createCellExecution(cell: NotebookCell): Promise<RunmeNotebookCellExecution> {
+    return await this.cellManager.createNotebookCellExecution(cell)
+  }
+
+  public async getCellOutputs(cell: NotebookCell): Promise<NotebookCellOutputManager> {
+    return await this.cellManager.getNotebookOutputs(cell)
   }
 
   private async _doExecuteCell(cell: NotebookCell): Promise<void> {
-    const runningCell = cell.document
-    const exec = await this.createCellExecution(cell)
+    const runningCell = await workspace.openTextDocument(cell.document.uri)
+    const runmeExec = await this.createCellExecution(cell)
+    const exec = runmeExec.underlyingExecution
 
     const uuid = (cell.metadata as Serializer.Metadata)['runme.dev/uuid']
 
@@ -327,12 +293,13 @@ export class Kernel implements Disposable {
     }
 
     TelemetryReporter.sendTelemetryEvent('cell.startExecute')
-    exec.start(Date.now())
+    runmeExec.start(Date.now())
     let execKey = getKey(runningCell)
 
     let successfulCellExecution: boolean
 
     const environmentManager = this.getEnvironmentManager()
+    const outputs = await this.getCellOutputs(cell)
 
     if (
       this.runner &&
@@ -340,7 +307,7 @@ export class Kernel implements Disposable {
       // TODO(mxs): support windows shells
       !isWindows()
     ) {
-      const runScript = async (execKey: 'sh' | 'bash' = 'bash') => await executeRunner(
+      const runScript = (execKey: 'sh'|'bash' = 'bash') => executeRunner(
         this,
         this.context,
         this.runner!,
@@ -349,6 +316,7 @@ export class Kernel implements Disposable {
         this.messaging,
         uuid,
         execKey,
+        outputs,
         this.environment,
         environmentManager
       )
@@ -362,17 +330,17 @@ export class Kernel implements Disposable {
         successfulCellExecution = await runScript(execKey)
       } else {
         successfulCellExecution = await executor[execKey].call(
-          this, exec, runningCell, runScript, environmentManager
+          this, exec, runningCell, outputs, runScript, environmentManager
         )
       }
     } else {
       /**
        * check if user is running experiment to execute shell via runme cli
        */
-      successfulCellExecution = await executor[execKey].call(this, exec, runningCell)
+      successfulCellExecution =  await executor[execKey].call(this, exec, runningCell, outputs)
     }
     TelemetryReporter.sendTelemetryEvent('cell.endExecute', { 'cell.success': successfulCellExecution?.toString() })
-    exec.end(successfulCellExecution)
+    runmeExec.end(successfulCellExecution)
   }
 
   useRunner(runner: IRunner) {
diff --git a/src/extension/provider/annotations.ts b/src/extension/provider/annotations.ts
index c5008660..5ac64e42 100644
--- a/src/extension/provider/annotations.ts
+++ b/src/extension/provider/annotations.ts
@@ -2,8 +2,6 @@ import {
   commands,
   window,
   NotebookCell,
-  NotebookCellOutput,
-  NotebookCellOutputItem,
   NotebookCellStatusBarItemProvider,
   NotebookCellStatusBarItem,
   NotebookCellStatusBarAlignment,
@@ -11,10 +9,8 @@ import {
 } from 'vscode'
 
 import { OutputType } from '../../constants'
-import { CellOutputPayload } from '../../types'
 import { RunmeExtension } from '../extension'
 import { Kernel } from '../kernel'
-import { getAnnotations, replaceOutput, validateAnnotations } from '../utils'
 
 const NOTEBOOK_SELECTION_COMMAND = '_notebook.selectKernel'
 
@@ -49,41 +45,8 @@ export class AnnotationsProvider implements NotebookCellStatusBarItemProvider {
   }
 
   public async toggleCellAnnotations(cell: NotebookCell): Promise<void> {
-    const annotationsExists = cell.outputs.find((o) =>
-      o.items.find((oi) => oi.mime === OutputType.annotations)
-    )
-
-    let exec
-    try {
-      exec = await this.kernel.createCellExecution(cell)
-      exec.start(Date.now())
-
-      if (annotationsExists) {
-        exec.clearOutput()
-        return
-      }
-
-      const json = <CellOutputPayload<OutputType.annotations>>{
-        type: OutputType.annotations,
-        output: {
-          annotations: getAnnotations(cell),
-          validationErrors: validateAnnotations(cell)
-        },
-      }
-      await replaceOutput(exec, [
-        new NotebookCellOutput([
-          NotebookCellOutputItem.json(json, OutputType.annotations),
-          NotebookCellOutputItem.json(json),
-        ]),
-      ])
-    } catch (e: any) {
-      if (e.message.toString().includes('controller is NOT associated')) {
-        return this.handleNotebookKernelSelection()
-      }
-      window.showErrorMessage(e.message)
-    } finally {
-      exec?.end(true)
-    }
+    const outputs = await this.kernel.getCellOutputs(cell)
+    await outputs.toggleOutput(OutputType.annotations)
   }
 
   async provideCellStatusBarItems(
diff --git a/src/extension/provider/background.ts b/src/extension/provider/background.ts
index 061876b5..8e779b16 100644
--- a/src/extension/provider/background.ts
+++ b/src/extension/provider/background.ts
@@ -2,43 +2,42 @@ import type vscode from 'vscode'
 import { window, EventEmitter, NotebookCellStatusBarItem, NotebookCellStatusBarAlignment, tasks } from 'vscode'
 
 import { getAnnotations, getTerminalByCell } from '../utils'
+import { Kernel } from '../kernel'
 
-export class ShowTerminalProvider implements vscode.NotebookCellStatusBarItemProvider, vscode.Disposable {
+export class ToggleTerminalProvider implements vscode.NotebookCellStatusBarItemProvider, vscode.Disposable {
   private _onDidChangeCellStatusBarItems = new EventEmitter<void>()
   onDidChangeCellStatusBarItems = this._onDidChangeCellStatusBarItems.event
 
-  private _closeTerminalSubscription: vscode.Disposable
+  protected disposables: vscode.Disposable[] = [
+    this._onDidChangeCellStatusBarItems
+  ]
 
-  constructor() {
-    this._closeTerminalSubscription = window.onDidCloseTerminal(() =>
-      this.refreshStatusBarItems()
+  constructor(protected kernel: Kernel) {
+    this.disposables.push(
+      window.onDidCloseTerminal(this.refreshStatusBarItems.bind(this)),
+      tasks.onDidStartTaskProcess(this.refreshStatusBarItems.bind(this)),
+      tasks.onDidEndTaskProcess(this.refreshStatusBarItems.bind(this)),
     )
   }
 
   async provideCellStatusBarItems(cell: vscode.NotebookCell): Promise<vscode.NotebookCellStatusBarItem | undefined> {
-    /**
-     * don't show status item if we run it in non-interactive mode where there is no terminal to open
-     */
-    if (!getAnnotations(cell).interactive) {
-      return
-    }
-
-    const terminal = getTerminalByCell(cell)
+    const terminalState = await this.kernel.getTerminalState(cell)
 
-    if (!Boolean(terminal)) {
+    if (!terminalState) {
       return
     }
 
     const terminalButtonParts = [
       '$(terminal)',
-      'Open Terminal',
+      'Terminal',
     ]
 
     const item = new NotebookCellStatusBarItem(
       terminalButtonParts.join(' '),
       NotebookCellStatusBarAlignment.Right
     )
-    item.command = 'runme.openTerminal'
+    item.command = 'runme.toggleTerminal'
+
     return item
   }
 
@@ -47,8 +46,7 @@ export class ShowTerminalProvider implements vscode.NotebookCellStatusBarItemPro
   }
 
 	public dispose() {
-    this._onDidChangeCellStatusBarItems.dispose()
-    this._closeTerminalSubscription.dispose()
+    this.disposables.forEach(({ dispose }) => dispose())
 	}
 }
 
diff --git a/src/extension/serializer.ts b/src/extension/serializer.ts
index 04de1be0..bd4e6561 100644
--- a/src/extension/serializer.ts
+++ b/src/extension/serializer.ts
@@ -25,6 +25,7 @@ import Languages from './languages'
 import { PLATFORM_OS } from './constants'
 import { canEditFile, initWasm } from './utils'
 import RunmeServer from './server/runmeServer'
+import { Kernel } from './kernel'
 
 declare var globalThis: any
 const DEFAULT_LANG_ID = 'text'
@@ -36,7 +37,10 @@ export abstract class SerializerBase implements NotebookSerializer, Disposable {
   protected readonly languages: Languages
   protected disposables: Disposable[] = []
 
-  constructor(protected context: ExtensionContext) {
+  constructor(
+    protected context: ExtensionContext,
+    protected kernel: Kernel
+  ) {
     this.languages = Languages.fromContext(this.context)
     this.disposables.push(
       workspace.onDidChangeNotebookDocument(
@@ -56,6 +60,8 @@ export abstract class SerializerBase implements NotebookSerializer, Disposable {
   protected handleNotebookChanged(changes: NotebookDocumentChangeEvent) {
     changes.contentChanges.forEach((contentChanges) => {
       contentChanges.addedCells.forEach((cellAdded) => {
+        this.kernel.registerNotebookCell(cellAdded)
+
         if (
           cellAdded.kind !== NotebookCellKind.Code ||
           cellAdded.metadata['runme.dev/uuid'] !== undefined
@@ -245,8 +251,8 @@ export abstract class SerializerBase implements NotebookSerializer, Disposable {
 export class WasmSerializer extends SerializerBase {
   protected readonly ready: ReadyPromise
 
-  constructor(protected context: ExtensionContext) {
-    super(context)
+  constructor(protected context: ExtensionContext, kernel: Kernel) {
+    super(context, kernel)
     const wasmUri = Uri.joinPath(
       this.context.extensionUri,
       'wasm',
@@ -295,8 +301,9 @@ export class GrpcSerializer extends SerializerBase {
   constructor(
     protected context: ExtensionContext,
     protected server: RunmeServer,
+    kernel: Kernel
   ) {
-    super(context)
+    super(context, kernel)
 
     this.ready = new Promise((resolve) => {
       const disposable = server.onTransportReady(() => {
diff --git a/src/extension/terminal/terminalState.ts b/src/extension/terminal/terminalState.ts
index 844f0c78..050bfc35 100644
--- a/src/extension/terminal/terminalState.ts
+++ b/src/extension/terminal/terminalState.ts
@@ -1,15 +1,21 @@
 import { Terminal as XTerm } from 'xterm-headless'
 import { SerializeAddon } from 'xterm-addon-serialize'
 
-export type NotebookTerminalType = 'xterm'
+import { OutputType } from '../../constants'
+
+export type NotebookTerminalType = 'xterm'|'local'
 
 export interface ITerminalState {
   serialize(): string
   write(data: string | Uint8Array): void
   input(data: string, wasUserInput: boolean): void
+
+  readonly outputType: OutputType
 }
 
 export class XTermState implements ITerminalState {
+  readonly outputType = OutputType.terminal
+
   private xterm: XTerm
   private serializer: SerializeAddon
 
@@ -40,3 +46,18 @@ export class XTermState implements ITerminalState {
     }
   }
 }
+
+export class LocalBufferTermState implements ITerminalState {
+  readonly outputType = OutputType.outputItems
+
+  private output: Buffer[] = []
+
+  write(data: string | Uint8Array) {
+    this.output.push(Buffer.from(data))
+  }
+
+  // noop
+  input(): void { }
+
+  serialize(): string { return Buffer.concat(this.output).toString('base64') }
+}
diff --git a/src/extension/utils.ts b/src/extension/utils.ts
index 24387932..d0780c5f 100644
--- a/src/extension/utils.ts
+++ b/src/extension/utils.ts
@@ -110,9 +110,13 @@ export function resetEnv() {
   Object.entries(DEFAULT_ENV).map(([key, val]) => ENV_STORE.set(key, val))
 }
 
-export function getKey(runningCell: vscode.TextDocument): keyof typeof executor {
+export function isDenoScript(runningCell: vscode.TextDocument) {
   const text = runningCell.getText()
-  if (text.indexOf('deployctl deploy') > -1) {
+  return text.indexOf('deployctl deploy') > -1
+}
+
+export function getKey(runningCell: vscode.TextDocument): keyof typeof executor {
+  if (isDenoScript(runningCell)) {
     return 'deno'
   }
   // if (text.startsWith('vercel ')) {
diff --git a/src/types.ts b/src/types.ts
index 3aa35907..69ab4efd 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -41,6 +41,8 @@ export namespace Serializer {
     mimeType?: string
     ['runme.dev/name']?: string
     ['runme.dev/uuid']?: string
+    ['runme.dev/denoState']?: DenoState
+    ['runme.dev/vercelState']?: VercelState
   }
 }
 
@@ -55,20 +57,25 @@ export type CellOutputPayload<T extends OutputType> = T extends any
 
 export type CellOutput = CellOutputPayload<OutputType>
 
-interface DenoPayload {
+export interface DenoState {
+  promoted?: boolean
   deployed?: boolean
   project?: any
   deployments?: any[]
+  error?: any
+}
+
+export interface VercelState {
+  payload?: any
+  outputItems: string[]
+  type?: string
+  error?: any
 }
 
 interface Payload {
   [OutputType.error]: string
-  [OutputType.deno]?: DenoPayload
-  [OutputType.vercel]: {
-    type: string
-    payload?: any
-    outputItems: string[]
-  }
+  [OutputType.deno]?: DenoState
+  [OutputType.vercel]: VercelState
   [OutputType.outputItems]: OutputItemsPayload
   [OutputType.annotations]: {
     annotations?: CellAnnotations
@@ -88,13 +95,12 @@ export type ClientMessage <T extends ClientMessages> = T extends any ? {
   output: ClientMessagePayload[T]
 } : never
 export interface ClientMessagePayload {
-  [ClientMessages.deployed]: boolean
-  [ClientMessages.update]: DenoPayload
-  [ClientMessages.promote]: {
+  [ClientMessages.denoUpdate]: DenoState
+  [ClientMessages.denoPromote]: {
     id: string
     productionDeployment: string
   }
-  [ClientMessages.prod]: {
+  [ClientMessages.vercelProd]: {
     cellIndex: number
   }
   [ClientMessages.mutateAnnotations]: {
diff --git a/src/utils/sync.ts b/src/utils/sync.ts
new file mode 100644
index 00000000..8e3c8123
--- /dev/null
+++ b/src/utils/sync.ts
@@ -0,0 +1,44 @@
+/* Mutex provides mutex feature by building a promise chain.
+  const m = new Mutex();
+  await m.withLock(async () => {
+    // critical section
+  })
+
+  https://github.com/golang/vscode-go/blob/fa820d45d7b9217e949c21ffd13268e20c7e0ee4/src/utils/mutex.ts#L8
+*/
+export class Mutex {
+  private mutex = Promise.resolve()
+
+  private lock(): PromiseLike<() => void> {
+    // Based on https://spin.atomicobject.com/2018/09/10/javascript-concurrency/
+
+    let x: (unlock: () => void) => void
+
+    // add to the promise chain of this mutex.
+    // When all the prior promises in the chain are resolved,
+    // x, which will be the resolve callback of promise B,
+    // will run and cause to unblock the waiter of promise B.
+    this.mutex = this.mutex.then(() => {
+      return new Promise(x) // promise A
+    })
+
+    return new Promise((resolve) => {
+      // promise B
+      x = resolve
+    })
+    // the returned Promise will resolve when all the previous
+    // promises chained in this.mutex resolve.
+  }
+
+  public async withLock<T>(cb: () => Promise<T> | T): Promise<T> {
+    const unlock = await this.lock()
+
+    try {
+      return await cb()
+    } catch (e) {
+      throw e
+    } finally {
+      unlock()
+    }
+  }
+}


Test patch:
diff --git a/tests/e2e/specs/basic.e2e.ts b/tests/e2e/specs/basic.e2e.ts
index 729363d5..69149f0d 100644
--- a/tests/e2e/specs/basic.e2e.ts
+++ b/tests/e2e/specs/basic.e2e.ts
@@ -111,7 +111,7 @@ describe('Runme VS Code Extension', async () => {
       const cell = await notebook.getCell('npm i -g webdriverio')
       await cell.run()
 
-      await cell.openTerminal()
+      // await cell.openTerminal()
 
       const text = (await cell.getCellOutput(OutputType.TerminalView))[0]
 
diff --git a/tests/extension/cell.test.ts b/tests/extension/cell.test.ts
new file mode 100644
index 00000000..3875fef5
--- /dev/null
+++ b/tests/extension/cell.test.ts
@@ -0,0 +1,295 @@
+import { NotebookCell, NotebookCellExecution, NotebookController } from 'vscode'
+import { describe, expect, it, vi } from 'vitest'
+
+import { OutputType } from '../../src/constants'
+import {
+  NotebookCellManager,
+  NotebookCellOutputManager,
+  RunmeNotebookCellExecution
+} from '../../src/extension/cell'
+
+function mockedNotebookCellExecution(): NotebookCellExecution {
+  return {
+    start: vi.fn(),
+    end: vi.fn(),
+  } as unknown as NotebookCellExecution
+}
+
+vi.mock('vscode', async () => {
+  const vscode = await import('../../__mocks__/vscode')
+  return {
+    default: vscode,
+    ...vscode,
+  }
+})
+vi.mock('vscode-telemetry')
+
+vi.mock('../../src/extension/grpc/client', () => ({}))
+vi.mock('../../src/extension/grpc/runnerTypes', () => ({}))
+
+describe('NotebookCellManager', () => {
+  it('can register cells', () => {
+    const manager = new NotebookCellManager({} as any)
+    const cell = {} as any
+
+    manager.registerCell(cell)
+    expect(manager.getNotebookOutputs(cell)).toBeTruthy()
+
+    const errorMock = vi.spyOn(console, 'error')
+
+    expect(manager.getNotebookOutputs({} as any)).resolves.toBeInstanceOf(NotebookCellOutputManager)
+    expect(errorMock.mock.calls[0][0]).toMatch('has not been registered')
+  })
+})
+
+function mockCellExecution(cell: NotebookCell) {
+  const replaceOutput = vi.fn()
+  return {
+    start: vi.fn(),
+    end: vi.fn(),
+    replaceOutput,
+    clearOutput: vi.fn(),
+    cell,
+  } as unknown as NotebookCellExecution
+}
+
+function mockCell(outputs: any[] = [], metadata: any = {}) {
+  return {
+    outputs,
+    metadata,
+  } as unknown as NotebookCell
+}
+
+function mockNotebookController(cell: NotebookCell) {
+  const exec = mockCellExecution(cell)
+  const replaceOutput = vi.mocked(exec.replaceOutput)
+
+  const createExecution = vi.fn().mockReturnValue(exec)
+
+  const controller = {
+    createNotebookCellExecution: createExecution
+  } as unknown as NotebookController
+
+  return { replaceOutput, controller, exec, createExecution }
+}
+
+describe('NotebookCellOutputManager', () => {
+  it('uses current execution for outputs', async () => {
+    const cell = mockCell()
+
+    const { controller, createExecution } = mockNotebookController(cell)
+
+    const outputs = new NotebookCellOutputManager(
+      cell,
+      controller,
+    )
+
+    const exec1 = mockCellExecution(cell)
+    createExecution.mockReturnValue(exec1)
+
+    const runmeExec = await outputs.createNotebookCellExecution()
+    runmeExec.start()
+
+    await outputs.toggleOutput(OutputType.annotations)
+    expect(vi.mocked(exec1.replaceOutput)).toBeCalledTimes(1)
+
+    runmeExec.end(undefined)
+
+    createExecution.mockReturnValue(mockCellExecution(cell))
+    await outputs.toggleOutput(OutputType.annotations)
+
+    expect(vi.mocked(exec1.replaceOutput)).toBeCalledTimes(1)
+  })
+
+  it('waits on current execution before creating a new one', async () => {
+    const cell = mockCell()
+
+    const { controller, createExecution } = mockNotebookController(cell)
+
+    const outputs = new NotebookCellOutputManager(
+      cell,
+      controller,
+    )
+
+    const runmeExec = await outputs.createNotebookCellExecution()
+    runmeExec.start()
+
+    expect(createExecution).toHaveBeenCalledTimes(1)
+
+    outputs.createNotebookCellExecution()
+
+    await new Promise(r => setTimeout(r, 100))
+    expect(createExecution).toHaveBeenCalledTimes(1)
+
+    runmeExec.end(undefined)
+
+    await new Promise(r => setTimeout(r, 100))
+    expect(createExecution).toHaveBeenCalledTimes(2)
+  })
+
+  it('creates a new execution if none exists', async () => {
+    const cell = mockCell()
+
+    const { controller, createExecution, exec } = mockNotebookController(cell)
+
+    const outputs = new NotebookCellOutputManager(
+      cell,
+      controller,
+    )
+
+    await outputs.toggleOutput(OutputType.annotations)
+    expect(createExecution).toHaveBeenCalledOnce()
+
+    expect(vi.mocked(exec.start)).toHaveBeenCalledOnce()
+    expect(vi.mocked(exec.end)).toHaveBeenCalledOnce()
+  })
+
+  it('toggles based on output status if present', async () => {
+    const cell = mockCell([
+      { items: [ { mime: OutputType.annotations } ] },
+    ])
+
+    const { controller, replaceOutput } = mockNotebookController(cell)
+
+    const outputs = new NotebookCellOutputManager(
+      cell,
+      controller,
+    )
+
+    await outputs.toggleOutput(OutputType.annotations)
+    expect(replaceOutput).toBeCalledWith([], undefined)
+  })
+
+  it('toggles based on output status if not present', async () => {
+    const cell = mockCell([ { items: [ ] } ])
+
+    const { controller, replaceOutput } = mockNotebookController(cell)
+
+    const outputs = new NotebookCellOutputManager(
+      cell,
+      controller,
+    )
+
+    await outputs.toggleOutput(OutputType.annotations)
+    const result = replaceOutput.mock.calls[0][0]
+    expect(result).toHaveLength(1)
+    expect(result[0].items).toHaveLength(2)
+    expect(result[0].items[0].mime).toBe(OutputType.annotations)
+  })
+
+  it('supports annotation type', async () => {
+    const cell = mockCell([ { items: [ ] } ])
+
+    const { controller, replaceOutput } = mockNotebookController(cell)
+
+    const outputs = new NotebookCellOutputManager(
+      cell,
+      controller,
+    )
+
+    await outputs.showOutput(OutputType.annotations)
+
+    expect(replaceOutput).toHaveBeenCalledOnce()
+    const result = replaceOutput.mock.calls[0][0]
+
+    expect(result).toHaveLength(1)
+    expect(result[0].items).toHaveLength(2)
+    expect(result[0].items[0].mime).toBe(OutputType.annotations)
+  })
+
+  it('supports vercel type', async () => {
+    const cell = mockCell([ { items: [ ] } ])
+
+    const { controller, replaceOutput } = mockNotebookController(cell)
+
+    const outputs = new NotebookCellOutputManager(
+      cell,
+      controller,
+    )
+
+    await outputs.showOutput(OutputType.vercel)
+
+    expect(replaceOutput).toHaveBeenCalledOnce()
+    const result = replaceOutput.mock.calls[0][0]
+
+    expect(result).toHaveLength(1)
+    expect(result[0].items).toHaveLength(1)
+    expect(result[0].items[0].mime).toBe(OutputType.vercel)
+  })
+
+  it('supports deno type', async () => {
+    const cell = mockCell([ { items: [ ] } ])
+
+    const { controller, replaceOutput } = mockNotebookController(cell)
+
+    const outputs = new NotebookCellOutputManager(
+      cell,
+      controller,
+    )
+
+    await outputs.showOutput(OutputType.deno)
+
+    expect(replaceOutput).toHaveBeenCalledOnce()
+    const result = replaceOutput.mock.calls[0][0]
+
+    expect(result).toHaveLength(1)
+    expect(result[0].items).toHaveLength(1)
+    expect(result[0].items[0].mime).toBe(OutputType.deno)
+  })
+
+  it('does not update cells if refreshing non present type', async () => {
+    const cell = mockCell([ { items: [ ] } ])
+
+    const { controller, replaceOutput } = mockNotebookController(cell)
+
+    const outputs = new NotebookCellOutputManager(
+      cell,
+      controller,
+    )
+
+    await outputs.refreshOutput(OutputType.annotations)
+    expect(replaceOutput).toHaveBeenCalledTimes(0)
+  })
+
+  it('does update cells if refreshing present type', async () => {
+    const cell = mockCell([ { items: [ { mime: OutputType.annotations } ] } ])
+
+    const { controller, replaceOutput } = mockNotebookController(cell)
+
+    const outputs = new NotebookCellOutputManager(
+      cell,
+      controller,
+    )
+
+    await outputs.refreshOutput(OutputType.annotations)
+    expect(replaceOutput).toHaveBeenCalledTimes(1)
+  })
+})
+
+describe('RunmeNotebookCellExecution', () => {
+  it('runs onEnd when end is called', () => {
+    const exec = mockedNotebookCellExecution()
+    const runmeExec = new RunmeNotebookCellExecution(exec)
+
+    runmeExec.start(100)
+    expect(vi.mocked(exec.start)).toBeCalledTimes(1)
+    expect(vi.mocked(exec.start)).toBeCalledWith(100)
+
+    const onEndFunc = vi.fn()
+    runmeExec.onEnd(onEndFunc)
+
+    runmeExec.end(false, 200)
+    expect(vi.mocked(exec.end)).toBeCalledTimes(1)
+    expect(vi.mocked(exec.end)).toBeCalledWith(false, 200)
+
+    expect(onEndFunc).toBeCalledTimes(1)
+    expect(onEndFunc).toBeCalledWith({ success: false, endTime: 200 })
+  })
+
+  it('gives underlying execution', () => {
+    const exec = mockedNotebookCellExecution()
+    const runmeExec = new RunmeNotebookCellExecution(exec)
+
+    expect(runmeExec.underlyingExecution).toStrictEqual(exec)
+  })
+})
diff --git a/tests/extension/commands/index.test.ts b/tests/extension/commands/index.test.ts
index e2c0b728..c7534932 100644
--- a/tests/extension/commands/index.test.ts
+++ b/tests/extension/commands/index.test.ts
@@ -16,7 +16,7 @@ import {
 } from 'vscode'
 
 import {
-  openTerminal,
+  toggleTerminal,
   copyCellToClipboard,
   runCLICommand,
   openAsRunmeNotebook,
@@ -27,13 +27,13 @@ import {
   tryIt,
   openFileInRunme
 } from '../../../src/extension/commands'
-import { getTerminalByCell, getAnnotations, replaceOutput, openFileAsRunmeNotebook } from '../../../src/extension/utils'
+import { getTerminalByCell, getAnnotations, openFileAsRunmeNotebook } from '../../../src/extension/utils'
 import { getBinaryPath, isNotebookTerminalEnabledForCell } from '../../../src/utils/configuration'
 
 vi.mock('vscode', () => import(path.join(process.cwd(), '__mocks__', 'vscode')))
 vi.mock('vscode-telemetry')
 vi.mock('../../../src/extension/utils', () => ({
-  getAnnotations: vi.fn(),
+  getAnnotations: vi.fn(() => ({})),
   getTerminalByCell: vi.fn(),
   replaceOutput: vi.fn(),
   openFileAsRunmeNotebook: vi.fn(),
@@ -60,16 +60,20 @@ beforeEach(() => {
   vi.mocked(window.showWarningMessage).mockClear()
   vi.mocked(window.showInformationMessage).mockClear()
   vi.mocked(getAnnotations).mockClear()
+  vi.mocked(isNotebookTerminalEnabledForCell).mockReset()
+  vi.mocked(getTerminalByCell).mockReset()
 })
 
 test('openTerminal without notebook terminal', () => {
-  const func = openTerminal({} as any, false)
+  const func = toggleTerminal({ } as any, false)
 
   vi.mocked(isNotebookTerminalEnabledForCell).mockReturnValueOnce(false)
+  vi.mocked(getAnnotations).mockReturnValueOnce(({ interactive: true } as any))
   expect(func({} as any)).resolves.toBe(undefined)
   expect(window.showWarningMessage).toBeCalledTimes(1)
 
   vi.mocked(isNotebookTerminalEnabledForCell).mockReturnValueOnce(false)
+  vi.mocked(getAnnotations).mockReturnValueOnce(({ interactive: true } as any))
   vi.mocked(getTerminalByCell).mockReturnValue({ show: vi.fn().mockReturnValue('showed') } as any)
   expect(func({} as any)).resolves.toBe('showed')
 })
@@ -77,27 +81,22 @@ test('openTerminal without notebook terminal', () => {
 test('openTerminal with notebook terminal', async () => {
   vi.mocked(isNotebookTerminalEnabledForCell).mockReset()
 
-  const payload = { data: 'data' }
-
-  const execution = {
-    start: vi.fn(),
-    end: vi.fn(),
+  const outputs = {
+    toggleTerminal: vi.fn(),
+    showTerminal: vi.fn(),
   }
 
   const kernel = {
-    getCellTerminalOutputPayload: vi.fn().mockReturnValue(payload),
-    createCellExecution: vi.fn().mockResolvedValue(execution)
+    getCellOutputs: vi.fn().mockReturnValue(outputs)
   }
-  const func = openTerminal(kernel as any, true)
+  const func = toggleTerminal(kernel as any, true)
 
   vi.mocked(getTerminalByCell).mockReturnValueOnce({ show: () => { } } as any)
   vi.mocked(isNotebookTerminalEnabledForCell).mockReturnValueOnce(true)
+
   await func({} as any)
+  expect(outputs.toggleTerminal).toBeCalledTimes(1)
   expect(isNotebookTerminalEnabledForCell).toBeCalledTimes(1)
-  expect(kernel.createCellExecution).toBeCalledTimes(1)
-  expect(execution.start).toBeCalledTimes(1)
-  expect(execution.end).toBeCalledTimes(1)
-  expect(vi.mocked(replaceOutput)).toBeCalledWith(execution, payload)
 })
 
 test('copyCellToClipboard', () => {
diff --git a/tests/extension/kernel.test.ts b/tests/extension/kernel.test.ts
index 78c8ff2a..057d1c0d 100644
--- a/tests/extension/kernel.test.ts
+++ b/tests/extension/kernel.test.ts
@@ -123,6 +123,14 @@ suite('_executeAll', async () => {
 
 test('_doExecuteCell', async () => {
   const k = new Kernel({} as any)
+
+  k.createCellExecution = vi.fn().mockResolvedValue({
+    start: vi.fn(),
+    end: vi.fn(),
+    underlyingExecution: vi.fn(),
+  })
+  k.getCellOutputs = vi.fn().mockResolvedValue({ })
+
   await k['_doExecuteCell']({
     document: { uri: { fsPath: '/foo/bar' } },
     metadata: { 'runme.dev/uuid': '849448b2-3c41-4323-920e-3098e71302ce' }
diff --git a/tests/extension/provider/annotations.test.ts b/tests/extension/provider/annotations.test.ts
index 3432cf23..a6b44e85 100644
--- a/tests/extension/provider/annotations.test.ts
+++ b/tests/extension/provider/annotations.test.ts
@@ -4,7 +4,6 @@ import { commands, NotebookCellKind } from 'vscode'
 import { AnnotationsProvider } from '../../../src/extension/provider/annotations'
 import { Kernel } from '../../../src/extension/kernel'
 import { OutputType } from '../../../src/constants'
-import { replaceOutput } from '../../../src/extension/utils'
 
 vi.mock('vscode')
 vi.mock('vscode-telemetry')
@@ -106,17 +105,13 @@ describe('Runme Annotations', () => {
         }]
       }
 
-      const clearOutput = vi.fn()
-      const end = vi.fn()
-      kernel.createCellExecution = vi.fn().mockResolvedValue({
-        start: vi.fn(),
-        end,
-        clearOutput,
+      const toggleOutput = vi.fn()
+      kernel.getCellOutputs = vi.fn().mockResolvedValue({
+        toggleOutput
       })
       await annotationsProvider.toggleCellAnnotations(cell as any)
-      expect(kernel.createCellExecution).toBeCalledTimes(1)
-      expect(clearOutput).toBeCalledTimes(1)
-      expect(end).toBeCalledTimes(1)
+      expect(toggleOutput).toBeCalledTimes(1)
+      expect(toggleOutput).toBeCalledWith(OutputType.annotations)
     })
 
     it('should replace the output when the annotation is not rendered', async () => {
@@ -132,15 +127,13 @@ describe('Runme Annotations', () => {
         outputs: []
       }
 
-      const end = vi.fn()
-      kernel.createCellExecution = vi.fn().mockResolvedValue({
-        start: vi.fn(),
-        end,
+      const toggleOutput = vi.fn()
+      kernel.getCellOutputs = vi.fn().mockResolvedValue({
+        toggleOutput
       })
       await annotationsProvider.toggleCellAnnotations(cell as any)
-      expect(kernel.createCellExecution).toBeCalledTimes(1)
-      expect(replaceOutput).toBeCalledTimes(1)
-      expect(end).toBeCalledTimes(1)
+      expect(toggleOutput).toBeCalledTimes(1)
+      expect(toggleOutput).toBeCalledWith(OutputType.annotations)
     })
   })
 })
diff --git a/tests/extension/provider/background.test.ts b/tests/extension/provider/background.test.ts
index 9c60aabe..8f05557a 100644
--- a/tests/extension/provider/background.test.ts
+++ b/tests/extension/provider/background.test.ts
@@ -1,9 +1,8 @@
-import { window } from 'vscode'
 import { vi, describe, expect, beforeEach, it } from 'vitest'
 
 import { getAnnotations, getTerminalByCell } from '../../../src/extension/utils'
 import {
-  ShowTerminalProvider,
+  ToggleTerminalProvider,
   BackgroundTaskProvider,
   StopBackgroundTaskProvider
 } from '../../../src/extension/provider/background'
@@ -19,58 +18,32 @@ vi.mock('../../../src/extension/utils', async () => {
 
 describe('ShowTerminalProvider', () => {
   beforeEach(() => {
-    vi.mocked(getTerminalByCell).mockClear()
-    vi.mocked(getAnnotations).mockClear()
+    vi.mocked(getTerminalByCell).mockReset()
+    vi.mocked(getAnnotations).mockReset()
   })
 
-  it('dont show pid if cell is non interactive', async () => {
-    vi.mocked(getAnnotations).mockReturnValueOnce({ interactive: false } as any)
-    const p = new ShowTerminalProvider()
-    expect(await p.provideCellStatusBarItems('cell' as any)).toBe(undefined)
-    expect(getAnnotations).toBeCalledTimes(1)
-    expect(getTerminalByCell).toBeCalledTimes(0)
-    expect(getAnnotations).toBeCalledWith('cell')
-  })
+  it('dont show if no terminal state', async () => {
+    const kernel = {
+      getTerminalState: vi.fn().mockResolvedValue(undefined)
+    } as any
 
-  it('dont show pid if terminal could not be found', async () => {
-    vi.mocked(getAnnotations).mockReturnValueOnce({ interactive: true } as any)
-    vi.mocked(getTerminalByCell).mockReturnValueOnce(undefined)
-    const p = new ShowTerminalProvider()
-    expect(await p.provideCellStatusBarItems('cell' as any)).toBe(undefined)
-    expect(getTerminalByCell).toBeCalledTimes(1)
-    expect(getTerminalByCell).toBeCalledWith('cell')
-  })
+    const p = new ToggleTerminalProvider(kernel)
 
-  it('return status item with pid ', async () => {
-    vi.mocked(getAnnotations).mockReturnValueOnce({ interactive: true } as any)
-    vi.mocked(getTerminalByCell).mockReturnValueOnce({ processId: Promise.resolve(123) } as any)
-    const p = new ShowTerminalProvider()
-    const item = await p.provideCellStatusBarItems('cell' as any)
-    expect(item).toEqual({
-      label: '$(terminal) Open Terminal',
-      command: 'runme.openTerminal',
-      alignment: 2
-    })
+    expect(await p.provideCellStatusBarItems({} as any)).toBe(undefined)
   })
 
-  it('will stop showing pid if terminal is destroyed', async () => {
-    let changeActiveTerminal: (() => void)[] = []
-    vi.mocked<any>(window.onDidCloseTerminal).mockImplementationOnce(c => changeActiveTerminal.push(c))
+  it('show if no terminal state', async () => {
+    const kernel = {
+      getTerminalState: vi.fn().mockResolvedValue({})
+    } as any
 
-    vi.mocked(getAnnotations).mockReturnValueOnce({ interactive: true } as any)
-    vi.mocked(getTerminalByCell).mockReturnValueOnce({ processId: Promise.resolve(123) } as any)
-    const p1 = new ShowTerminalProvider()
-    p1.refreshStatusBarItems = vi.fn()
-    const item = await p1.provideCellStatusBarItems('cell' as any)
-    expect(item).toBeTruthy()
+    const p = new ToggleTerminalProvider(kernel)
 
-    changeActiveTerminal.forEach((c) => c())
-    expect(p1.refreshStatusBarItems).toBeCalledTimes(1)
-
-    vi.mocked(getAnnotations).mockReturnValueOnce({ interactive: true } as any)
-    vi.mocked(getTerminalByCell).mockReturnValueOnce(undefined)
-    const p2 = new ShowTerminalProvider()
-    expect(await p2.provideCellStatusBarItems('cell' as any)).toBe(undefined)
+    expect(await p.provideCellStatusBarItems({} as any)).toEqual({
+      alignment: 2,
+      command: 'runme.toggleTerminal',
+      label: '$(terminal) Terminal',
+    })
   })
 })
 
@@ -80,8 +53,8 @@ describe('BackgroundTaskProvider', () => {
   }
 
   beforeEach(() => {
-    vi.mocked(getTerminalByCell).mockClear()
-    vi.mocked(getAnnotations).mockClear()
+    vi.mocked(getTerminalByCell).mockReset()
+    vi.mocked(getAnnotations).mockReset()
   })
 
   it('dont show bg task label if cell is non a background task', async () => {
@@ -106,11 +79,15 @@ describe('BackgroundTaskProvider', () => {
       interactive: true,
       background: true
     } as any)
+    vi.mocked(getTerminalByCell).mockReturnValueOnce({
+      processId: 123
+    } as any)
     const p = new BackgroundTaskProvider()
     const item = await p.provideCellStatusBarItems(cell as any)
     expect(item).toEqual({
-      label: 'Background Task',
-      alignment: 2
+      label: 'PID: 123',
+      alignment: 2,
+      command: 'runme.openIntegratedTerminal',
     })
   })
 })
@@ -122,8 +99,8 @@ describe('StopBackgroundTaskProvider', () => {
   }
 
   beforeEach(() => {
-    vi.mocked(getTerminalByCell).mockClear()
-    vi.mocked(getAnnotations).mockClear()
+    vi.mocked(getTerminalByCell).mockReset()
+    vi.mocked(getAnnotations).mockReset()
   })
 
   it('dont show bg task label if cell is non a background task', async () => {