Repository: stateful/vscode-runme
Pull request #524 (2023-05-18 18:40:13)

Close cell output
Introducing a close-button component, only supported by the Annotations component so far.


https://github.com/stateful/vscode-runme/assets/4001529/eadfeaaa-a070-43e3-bd08-d895cb4d4737

Fixes https://github.com/stateful/vscode-runme/issues/500

degrammer: @sourishkrout  please take a look and if everything looks good, I will add support to the other cell output, for now, added to the configuration section.
sourishkrout: @degrammer looks great 👍 .
degrammer: > @degrammer looks great 👍 .

Awesome, this is now ready for review
sourishkrout: @mxsdev can you jump in and review, please. Thanks!

Patch:
diff --git a/src/client/components/annotations.ts b/src/client/components/annotations.ts
index fefedc3d..06d90757 100644
--- a/src/client/components/annotations.ts
+++ b/src/client/components/annotations.ts
@@ -2,11 +2,12 @@ import { LitElement, css, html } from 'lit'
 import { customElement, property } from 'lit/decorators.js'
 import { when } from 'lit/directives/when.js'
 
-import { ClientMessages } from '../../constants'
+import { ClientMessages, OutputType } from '../../constants'
 import type { ClientMessage, CellAnnotations, CellAnnotationsErrorResult } from '../../types'
-import { getContext } from '../utils'
+import { closeOutput, getContext } from '../utils'
 import { CellAnnotationsSchema, AnnotationSchema } from '../../schema'
-import '@vscode/webview-ui-toolkit/dist/data-grid/index'
+
+import './closeCellButton'
 
 type AnnotationsMutation = Partial<CellAnnotations>
 type AnnotationsKey = keyof typeof AnnotationSchema
@@ -36,7 +37,8 @@ export class Annotations extends LitElement {
       flex-direction: column;
       align-items: flex-start;
       gap: 1rem;
-      width: 94%;
+      width: inherited;
+      position: relative;
     }
 
     .annotation-container h4 {
@@ -187,7 +189,7 @@ export class Annotations extends LitElement {
       type="text"
       value="${text}"
       @change="${this.#onChange}"
-      @keyup="${ this.#onChange}"
+      @keyup="${this.#onChange}"
       placeholder=${placeHolder}
       size="50"
       class="annotation-item"
@@ -247,9 +249,16 @@ export class Annotations extends LitElement {
       </div>`
     })
 
-    return html`<section class="annotation-container ${errorCount ? 'has-errors' : ''}">
+    return html`
+    <section class="annotation-container ${errorCount ? 'has-errors' : ''}">
       <h4>Configure cell's execution behavior:</h4>
       ${markup}
+      <close-cell-button @closed="${() => {
+        return closeOutput({
+          uuid: (this.annotations && this.annotations['runme.dev/uuid']) || '',
+          outputType: OutputType.annotations
+        })
+      } }" />
       ${when(
       errorCount,
       () => html`<p class="error-item">This configuration block contains errors, using the default values instead</p>`,
diff --git a/src/client/components/closeCellButton.ts b/src/client/components/closeCellButton.ts
new file mode 100644
index 00000000..148b8ae6
--- /dev/null
+++ b/src/client/components/closeCellButton.ts
@@ -0,0 +1,98 @@
+import { LitElement, css, html } from 'lit'
+import { customElement } from 'lit/decorators.js'
+@customElement('close-cell-button')
+export class CloseCellButton extends LitElement {
+
+    /* eslint-disable */
+    static styles = css`
+        :host {
+            --button-icon-hover-background: var(--vscode-toolbar-hoverBackground);
+            --tooltip-background: #343434;
+        }
+
+        .close-button  .control { 
+            outline: none;
+        }
+
+        .close-button {
+            position: absolute;
+            top: 5px;
+            right: 5px;
+        }
+
+        .close-button, .close-button:hover {
+            border: none;
+        }
+
+        @media (prefers-color-scheme: light) {
+            .icon-close {
+                background-image: url("data:image/svg+xml,%0A%3Csvg width='16' height='16' viewBox='0 0 16 16' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M8.00028 8.70711L11.6467 12.3536L12.3538 11.6465L8.70739 8.00001L12.3538 4.35356L11.6467 3.64645L8.00028 7.2929L4.35384 3.64645L3.64673 4.35356L7.29317 8.00001L3.64673 11.6465L4.35384 12.3536L8.00028 8.70711Z' fill='%23424242'/%3E%3C/svg%3E%0A");
+            }
+        }
+        
+        @media (prefers-color-scheme: dark) {
+            .icon-close {
+                background-image: url("data:image/svg+xml,%0A%3Csvg width='16' height='16' viewBox='0 0 16 16' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M8.00004 8.70711L11.6465 12.3536L12.3536 11.6465L8.70714 8.00001L12.3536 4.35356L11.6465 3.64645L8.00004 7.2929L4.35359 3.64645L3.64648 4.35356L7.29293 8.00001L3.64648 11.6465L4.35359 12.3536L8.00004 8.70711Z' fill='%23C5C5C5'/%3E%3C/svg%3E%0A");
+            }
+        }
+        
+        .tooltip .tooltiptext {
+            visibility: hidden;
+            width: 50px;
+            background-color: var(--tooltip-background);
+            color: #fff;
+            text-align: center;
+            padding: 5px 0;
+            position: absolute;
+            z-index: 1;
+            bottom: 150%;
+            right: -60%;
+            margin-left: -60px;
+            top: 126%;
+            height:15px;
+            box-shadow:2px 2px 4px -2px var(--vscode-input-border);
+        }
+        
+        .tooltip .tooltiptext::after {
+            content: " ";
+            position: absolute;
+            bottom: 100%;  /* At the top of the tooltip */
+            left: 50%;
+            margin-left: -5px;
+            border-width: 5px;
+            border-style: solid;
+            border-color: transparent transparent var(--tooltip-background) transparent;
+        }
+
+        .tooltip .tooltiptext {
+            transition-delay: 1s;
+        }
+
+        .tooltip:hover .tooltiptext {
+            visibility: visible;
+        }
+
+        .tooltip:not(:hover) .tooltiptext {
+            transition-delay: 0s;
+        }
+    `
+
+    private onClose(e: Event) {
+        if (e.defaultPrevented) {
+            e.preventDefault()
+        }
+        const event = new CustomEvent('closed')
+        this.dispatchEvent(event)
+    }
+
+    render() {
+        return html`
+        <div class='close-button tooltip'>
+            <span class="tooltiptext">Close</span>
+            <vscode-button class="control" appearance='icon' aria-label='Close' @click='${this.onClose}'>
+                <span class='icon icon-close'></span>
+            </vscode-button>
+        </div>
+        `
+    }
+}
\ No newline at end of file
diff --git a/src/client/components/outputItems.ts b/src/client/components/outputItems.ts
index 20272768..edb3d04f 100644
--- a/src/client/components/outputItems.ts
+++ b/src/client/components/outputItems.ts
@@ -3,9 +3,11 @@ import { customElement, property } from 'lit/decorators.js'
 
 import '@vscode/webview-ui-toolkit/dist/button/index'
 
-import { getContext } from '../utils'
+import { closeOutput, getContext } from '../utils'
 import { ClientMessage } from '../../types'
-import { ClientMessages } from '../../constants'
+import { ClientMessages, OutputType } from '../../constants'
+
+import './closeCellButton'
 
 @customElement('shell-output-items')
 export class ShellOutputItems extends LitElement {
@@ -37,9 +39,18 @@ export class ShellOutputItems extends LitElement {
   @property({ type: String })
   content = ''
 
+  @property({ type: String })
+  uuid?: string
+
   // Render the UI as a function of component state
   render() {
     return html`<section class="output-items">
+    <close-cell-button @closed="${() => {
+        return closeOutput({
+          uuid: this.uuid!,
+          outputType: OutputType.outputItems
+        })
+      }}" />
       <vscode-button appearance="secondary" @click="${this.#copy}">
         <svg
           class="icon" width="16" height="16" viewBox="0 0 16 16"
@@ -55,7 +66,7 @@ export class ShellOutputItems extends LitElement {
     </span>`
   }
 
-  #copy () {
+  #copy() {
     const ctx = getContext()
 
     if (!ctx.postMessage) {
diff --git a/src/client/components/terminal.ts b/src/client/components/terminal.ts
index faef5a82..8d2148de 100644
--- a/src/client/components/terminal.ts
+++ b/src/client/components/terminal.ts
@@ -7,11 +7,13 @@ import { Unicode11Addon } from 'xterm-addon-unicode11'
 import { WebLinksAddon } from 'xterm-addon-web-links'
 
 import { FitAddon, type ITerminalDimensions } from '../fitAddon'
-import { ClientMessages } from '../../constants'
-import { getContext } from '../utils'
+import { ClientMessages, OutputType } from '../../constants'
+import { closeOutput, getContext } from '../utils'
 import { onClientMessage, postClientMessage } from '../../utils/messaging'
 import { stripANSI } from '../../utils/ansi'
 
+import './closeCellButton'
+
 interface IWindowSize {
   width: number
   height: number
@@ -52,6 +54,7 @@ export class TerminalView extends LitElement {
       user-select: none;
       -ms-user-select: none;
       -webkit-user-select: none;
+      border-radius: 5px;
     }
 
     .xterm.focus,
@@ -248,6 +251,7 @@ export class TerminalView extends LitElement {
       display: flex;
       flex-direction: column;
       gap: 5px;
+      position:relative;
     }
 
     .xterm-drag-handle {
@@ -440,11 +444,13 @@ export class TerminalView extends LitElement {
     window.addEventListener('mouseup', onMouseUp)
     window.addEventListener('mousemove', onMouseMove)
 
-    this.disposables.push({dispose: () => {
-      dragHandle.removeEventListener('mousedown', onMouseDown)
-      window.removeEventListener('mouseup', onMouseUp)
-      window.removeEventListener('mousemove', onMouseMove)
-    }})
+    this.disposables.push({
+      dispose: () => {
+        dragHandle.removeEventListener('mousedown', onMouseDown)
+        window.removeEventListener('mouseup', onMouseUp)
+        window.removeEventListener('mousemove', onMouseMove)
+      }
+    })
 
     return dragHandle
   }
@@ -521,6 +527,12 @@ export class TerminalView extends LitElement {
   render() {
     return html`<section>
       <div id="terminal"></div>
+      <close-cell-button @closed="${() => {
+        return closeOutput({
+          uuid: this.uuid!,
+          outputType: OutputType.terminal
+        })
+      }}" />
       <div class="button-group">
         <vscode-button appearance="secondary" @click="${this.#copy.bind(this)}">
           <svg
@@ -556,8 +568,8 @@ export class TerminalView extends LitElement {
 
 function convertXTermDimensions(dimensions: ITerminalDimensions): TerminalDimensions
 function convertXTermDimensions(dimensions: undefined): undefined
-function convertXTermDimensions(dimensions: ITerminalDimensions|undefined): TerminalDimensions|undefined
-function convertXTermDimensions(dimensions?: ITerminalDimensions): TerminalDimensions|undefined {
+function convertXTermDimensions(dimensions: ITerminalDimensions | undefined): TerminalDimensions | undefined
+function convertXTermDimensions(dimensions?: ITerminalDimensions): TerminalDimensions | undefined {
   if (!dimensions) { return undefined }
 
   const { rows, cols } = dimensions
diff --git a/src/client/components/vercel.ts b/src/client/components/vercel.ts
index 60969c52..ee5b4d56 100644
--- a/src/client/components/vercel.ts
+++ b/src/client/components/vercel.ts
@@ -2,9 +2,11 @@ import { LitElement, css, html } from 'lit'
 import { customElement, property } from 'lit/decorators.js'
 import { when } from 'lit/directives/when.js'
 
-import { ClientMessages } from '../../constants'
+import { ClientMessages, OutputType } from '../../constants'
 import type { ClientMessage } from '../../types'
-import { getContext } from '../utils'
+import { closeOutput, getContext } from '../utils'
+
+import './closeCellButton'
 
 @customElement('vercel-output')
 export class VercelOutput extends LitElement {
@@ -26,6 +28,7 @@ export class VercelOutput extends LitElement {
       flex-direction: row;
       gap: 50px;
       align-items: flex-start;
+      position:relative;
     }
 
     img {
@@ -103,6 +106,12 @@ export class VercelOutput extends LitElement {
           👌 Promoted
         `)}
       </div>
+      <close-cell-button @closed="${() => {
+        return closeOutput({
+          uuid: this.content.payload.uuid,
+          outputType: OutputType.vercel
+        })
+      }}" />
     </section>`
   }
 
diff --git a/src/client/index.ts b/src/client/index.ts
index ad9b972f..cbb2c258 100644
--- a/src/client/index.ts
+++ b/src/client/index.ts
@@ -55,6 +55,7 @@ export const activate: ActivationFunction = (context: RendererContext<void>) =>
            * output items, e.g. copy to clipboard
            */
           const outputItemElem = document.createElement('shell-output-items')
+          outputItemElem.setAttribute('uuid', payload.output.uuid)
           outputItemElem.setAttribute('content', content)
           element.appendChild(outputItemElem)
           break
diff --git a/src/client/utils.ts b/src/client/utils.ts
index aa6d5315..b37aefcf 100644
--- a/src/client/utils.ts
+++ b/src/client/utils.ts
@@ -1,5 +1,8 @@
 import type { RendererContext } from 'vscode-notebook-renderer'
 
+import { postClientMessage } from '../utils/messaging'
+import { ClientMessages, OutputType } from '../constants'
+
 let context: RendererContext<void> | undefined
 
 export function getContext () {
@@ -11,4 +14,12 @@ export function getContext () {
 
 export function setContext (c: RendererContext<void>) {
   context = c
+}
+
+export function closeOutput({ uuid, outputType}: { uuid: string, outputType: OutputType}) {
+  const ctx = getContext()
+  ctx.postMessage && postClientMessage(ctx, ClientMessages.closeCellOutput, {
+      uuid,
+      outputType
+  })
 }
\ No newline at end of file
diff --git a/src/constants.ts b/src/constants.ts
index 602ec1a7..034df179 100644
--- a/src/constants.ts
+++ b/src/constants.ts
@@ -15,6 +15,7 @@ export enum OutputType {
 export enum ClientMessages {
   infoMessage = 'common:infoMessage',
   errorMessage = 'common:errorMessage',
+  closeCellOutput = 'common:closeCellOutput',
   denoUpdate = 'deno:deploymentUpdate',
   denoPromote = 'deno:promoteDeployment',
   vercelProd = 'vercel:promotePreview',
@@ -26,7 +27,7 @@ export enum ClientMessages {
   terminalFocus = 'terminal:focus',
   terminalOpen = 'terminal:open',
   openLink = 'terminal:openLink',
-  activeThemeChanged = 'theme:changed'
+  activeThemeChanged = 'theme:changed',
 }
 
 // [pretty print, languageId, destination]
diff --git a/src/extension/cell.ts b/src/extension/cell.ts
index aaa64e22..452244ac 100644
--- a/src/extension/cell.ts
+++ b/src/extension/cell.ts
@@ -2,10 +2,12 @@ import {
   Disposable,
   NotebookCell,
   NotebookCellExecution,
+  NotebookCellKind,
   NotebookCellOutput,
   NotebookCellOutputItem,
   NotebookController,
   NotebookEdit,
+  NotebookEditor,
   workspace,
   WorkspaceEdit
 } from 'vscode'
@@ -36,7 +38,7 @@ export class NotebookCellManager {
 
   registerCell(cell: NotebookCell): NotebookCellOutputManager {
     const existing = this.#data.get(cell)
-    if(existing) { return existing }
+    if (existing) { return existing }
 
     const outputs = new NotebookCellOutputManager(cell, this.controller)
     this.#data.set(cell, outputs)
@@ -51,7 +53,7 @@ export class NotebookCellManager {
 
   async getNotebookOutputs(cell: NotebookCell): Promise<NotebookCellOutputManager> {
     const outputs = this.#data.get(cell)
-    if(!outputs) {
+    if (!outputs) {
       console.error(`cell at index ${cell.index} has not been registered!`)
     }
 
@@ -60,6 +62,10 @@ export class NotebookCellManager {
 }
 
 type NotebookOutputs = NotebookCellOutput | readonly NotebookCellOutput[]
+interface ICellOption {
+  editor: NotebookEditor
+  uuid: string
+}
 
 export class NotebookCellOutputManager {
   protected outputs: readonly NotebookCellOutput[] = []
@@ -84,17 +90,18 @@ export class NotebookCellOutputManager {
     protected controller: NotebookController
   ) { }
 
-  protected generateOutputUnsafe(type: OutputType): NotebookCellOutput|undefined {
+  protected generateOutputUnsafe(type: OutputType): NotebookCellOutput | undefined {
     const cell = this.cell
     const metadata = cell.metadata as Serializer.Metadata
 
-    switch(type) {
+    switch (type) {
       case OutputType.annotations: {
         const annotationJson: CellOutputPayload<OutputType.annotations> = {
           type: OutputType.annotations,
           output: {
             annotations: getAnnotations(cell),
-            validationErrors: validateAnnotations(cell)
+            validationErrors: validateAnnotations(cell),
+            uuid: cell.metadata['runme.dev/uuid']
           },
         }
 
@@ -152,7 +159,7 @@ export class NotebookCellOutputManager {
               terminalFontFamily,
               terminalFontSize,
               content: terminalState.serialize(),
-              initialRows: getNotebookTerminalRows(),
+              initialRows: getNotebookTerminalRows()
             }
           }
 
@@ -165,6 +172,7 @@ export class NotebookCellOutputManager {
             output: {
               content: terminalState.serialize(),
               mime: 'text/plain',
+              uuid: cellId
             }
           }
 
@@ -205,7 +213,7 @@ export class NotebookCellOutputManager {
     return terminalState
   }
 
-  getCellTerminalState(): ITerminalState|undefined {
+  getCellTerminalState(): ITerminalState | undefined {
     return this.terminalState
   }
 
@@ -232,7 +240,7 @@ export class NotebookCellOutputManager {
           resolve()
         })
 
-        if(wrapper.hasEnded) {
+        if (wrapper.hasEnded) {
           resolve()
         }
       }).finally(resetExecution)
@@ -256,7 +264,7 @@ export class NotebookCellOutputManager {
     await this.showOutput(type, () => !this.hasOutputTypeUnsafe(type))
   }
 
-  async showOutput(type: OutputType, shown: boolean|(() => boolean|Promise<boolean>) = true) {
+  async showOutput(type: OutputType, shown: boolean | (() => boolean | Promise<boolean>) = true) {
     await this.refreshOutputInternal(async () => {
       this.enabledOutputs.set(type, typeof shown === 'function' ? await shown() : shown)
     })
@@ -268,7 +276,7 @@ export class NotebookCellOutputManager {
     })
   }
 
-  async showTerminal(shown: boolean|(() => boolean) = true) {
+  async showTerminal(shown: boolean | (() => boolean) = true) {
     await this.refreshOutputInternal(async () => {
       this.terminalEnabled = (typeof shown === 'function' ? shown() : shown)
     })
@@ -280,9 +288,9 @@ export class NotebookCellOutputManager {
    * @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[]) {
+  async refreshOutput(type?: OutputType | OutputType[]) {
     await this.refreshOutputInternal(() => {
-      if(type === undefined) { return }
+      if (type === undefined) { return }
 
       const typeSet = Array.isArray(type) ? type : [type]
       return typeSet.some(t => this.hasOutputTypeUnsafe(t))
@@ -301,10 +309,10 @@ export class NotebookCellOutputManager {
    * where the user can prevent refreshing if a certain output type is not
    * present.
    */
-  protected async refreshOutputInternal(mutater?: () => Promise<boolean|void>|boolean|void) {
+  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()]) {
+        for (const key of [...this.enabledOutputs.keys()]) {
           this.enabledOutputs.set(key, this.hasOutputTypeUnsafe(key))
         }
 
@@ -321,14 +329,14 @@ export class NotebookCellOutputManager {
         let terminalCellOutput = this.terminalEnabled && terminalOutput && this.generateOutputUnsafe(terminalOutput)
 
         await replaceOutput(exec, [
-          ...[ ...this.enabledOutputs.entries() ]
+          ...[...this.enabledOutputs.entries()]
             .flatMap(([type, enabled]) => {
               if (!enabled) { return [] }
 
               const output = this.generateOutputUnsafe(type)
-              if(!output) { return [] }
+              if (!output) { return [] }
 
-              return [ output ]
+              return [output]
             }),
           ...terminalCellOutput ? [terminalCellOutput] : [],
           ...this.outputs,
@@ -337,8 +345,8 @@ export class NotebookCellOutputManager {
     })
   }
 
-  protected async getExecutionUnsafe(cb: (exec: NotebookCellExecution) => Promise<void>|void) {
-    if(this.execution) {
+  protected async getExecutionUnsafe(cb: (exec: NotebookCellExecution) => Promise<void> | void) {
+    if (this.execution) {
       await cb?.(this.execution)
       return
     }
@@ -348,7 +356,7 @@ export class NotebookCellOutputManager {
 
     try {
       await cb(exec)
-    } catch(e) {
+    } catch (e) {
       throw e
     } finally {
       exec.end(true)
@@ -357,7 +365,7 @@ export class NotebookCellOutputManager {
 }
 
 function outputsAsArray(outputs: NotebookOutputs): readonly NotebookCellOutput[] {
-  return Array.isArray(outputs) ? outputs : [ outputs ]
+  return Array.isArray(outputs) ? outputs : [outputs]
 }
 
 type OnEndCallback = (info: {
@@ -410,3 +418,25 @@ export async function updateCellMetadata(cell: NotebookCell, meta: Partial<Seria
   edit.set(cell.notebook.uri, [notebookEdit])
   await workspace.applyEdit(edit)
 }
+
+export async function getCellByUuId(options: ICellOption): Promise<NotebookCell | undefined> {
+  const { editor, uuid } = options
+  for (const document of workspace.notebookDocuments) {
+    for (const cell of document.getCells()) {
+      if (
+        cell.kind !== NotebookCellKind.Code ||
+        cell.document.uri.fsPath !== editor.notebook.uri.fsPath) {
+        continue
+      }
+
+      if (cell.metadata?.['runme.dev/uuid'] === undefined) {
+        console.error(`[Runme] Cell with index ${cell.index} lacks uuid`)
+        continue
+      }
+
+      if (cell.metadata?.['runme.dev/uuid'] === uuid) {
+        return cell
+      }
+    }
+  }
+}
diff --git a/src/extension/kernel.ts b/src/extension/kernel.ts
index d7fb7857..a18e5591 100644
--- a/src/extension/kernel.ts
+++ b/src/extension/kernel.ts
@@ -28,7 +28,8 @@ import './wasm/wasm_exec.js'
 import { IRunner, IRunnerEnvironment } from './runner'
 import { executeRunner } from './executors/runner'
 import { ITerminalState, NotebookTerminalType } from './terminal/terminalState'
-import { NotebookCellManager, NotebookCellOutputManager, RunmeNotebookCellExecution } from './cell'
+import { NotebookCellManager, NotebookCellOutputManager, RunmeNotebookCellExecution, getCellByUuId } from './cell'
+import { handleCellOutputMessage } from './messages/cellOutput'
 
 enum ConfirmationItems {
   Yes = 'Yes',
@@ -95,7 +96,7 @@ export class Kernel implements Disposable {
     this.runnerReadyListener?.dispose()
   }
 
-  async getTerminalState(cell: NotebookCell): Promise<ITerminalState|undefined> {
+  async getTerminalState(cell: NotebookCell): Promise<ITerminalState | undefined> {
     return (await this.getCellOutputs(cell)).getCellTerminalState()
   }
 
@@ -212,6 +213,17 @@ export class Kernel implements Disposable {
       return window.showInformationMessage(message.output as string)
     } else if (message.type === ClientMessages.openLink) {
       return env.openExternal(Uri.parse(message.output))
+    } else if (message.type === ClientMessages.closeCellOutput) {
+      const cell = await getCellByUuId({ editor, uuid: message.output.uuid })
+      if (!cell) {
+        return
+      }
+      return handleCellOutputMessage({
+        message,
+        cell,
+        kernel: this,
+        outputType: message.output.outputType
+      })
     } else if (
       message.type.startsWith('terminal:')
     ) {
@@ -311,7 +323,7 @@ export class Kernel implements Disposable {
       // TODO(mxs): support windows shells
       !isWindows()
     ) {
-      const runScript = (execKey: 'sh'|'bash' = 'bash') => executeRunner(
+      const runScript = (execKey: 'sh' | 'bash' = 'bash') => executeRunner(
         this,
         this.context,
         this.runner!,
@@ -341,7 +353,7 @@ export class Kernel implements Disposable {
       /**
        * check if user is running experiment to execute shell via runme cli
        */
-      successfulCellExecution =  await executor[execKey].call(this, exec, runningCell, outputs)
+      successfulCellExecution = await executor[execKey].call(this, exec, runningCell, outputs)
     }
     TelemetryReporter.sendTelemetryEvent('cell.endExecute', { 'cell.success': successfulCellExecution?.toString() })
     runmeExec.end(successfulCellExecution)
diff --git a/src/extension/messages/cellOutput.ts b/src/extension/messages/cellOutput.ts
new file mode 100644
index 00000000..25ab9c83
--- /dev/null
+++ b/src/extension/messages/cellOutput.ts
@@ -0,0 +1,23 @@
+import { NotebookCell } from 'vscode'
+
+import { ClientMessages, OutputType } from '../../constants'
+import { ClientMessage } from '../../types'
+import { Kernel } from '../kernel'
+
+export interface ICellOutputMessage {
+    message: ClientMessage<ClientMessages>
+    cell: NotebookCell
+    kernel: Kernel
+    outputType: OutputType
+}
+
+export async function handleCellOutputMessage(options: ICellOutputMessage): Promise<void> {
+    const { message, cell, kernel, outputType } = options
+    if (message.type === ClientMessages.closeCellOutput) {
+        const outputs = await kernel.getCellOutputs(cell)
+        if ([OutputType.terminal, OutputType.outputItems].includes(outputType)) {
+            return outputs.showTerminal(false)
+        }
+        return outputs.showOutput(outputType, false)
+    }
+}
\ No newline at end of file
diff --git a/src/types.ts b/src/types.ts
index 3d565fed..ed06163d 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -73,6 +73,7 @@ interface Payload {
   [OutputType.annotations]: {
     annotations?: CellAnnotations
     validationErrors?: CellAnnotationsErrorResult
+    uuid?: string
   }
   [OutputType.terminal]: {
     ['runme.dev/uuid']: string
@@ -124,11 +125,16 @@ export interface ClientMessagePayload {
   }
   [ClientMessages.activeThemeChanged]: string
   [ClientMessages.openLink]: string
+  [ClientMessages.closeCellOutput]: {
+    uuid: string
+    outputType: OutputType
+  }
 }
 
 export interface OutputItemsPayload {
   content: string
   mime: string
+  uuid: string
 }
 
 export interface RunmeTaskDefinition extends TaskDefinition {


Test patch:
diff --git a/tests/extension/messages/cellOutput.test.ts b/tests/extension/messages/cellOutput.test.ts
new file mode 100644
index 00000000..a2c676c8
--- /dev/null
+++ b/tests/extension/messages/cellOutput.test.ts
@@ -0,0 +1,81 @@
+import { NotebookCell } from 'vscode'
+import { suite, vi, test, expect } from 'vitest'
+
+import { handleCellOutputMessage } from '../../../src/extension/messages/cellOutput'
+import { ClientMessages, OutputType } from '../../../src/constants'
+import { Kernel } from '../../../src/extension/kernel'
+
+vi.mock('vscode')
+vi.mock('vscode-telemetry')
+vi.mock('../../../src/extension/runner', () => ({}))
+
+suite('Handle CellOutput messages', () => {
+
+    const mockOutput = (type: OutputType) => {
+        const cell = {
+            outputs: [{
+                items: [
+                    { id: '', items: [], metadata: {}, mime: type }
+                ]
+            }],
+            executionSummary: {
+                success: false
+            },
+            metadata: {},
+        } as unknown as NotebookCell
+        const kernel = new Kernel({} as any)
+        const showOutput = vi.fn().mockImplementation(() => { })
+        const showTerminal = vi.fn().mockImplementation(() => { })
+        kernel.getCellOutputs = vi.fn().mockResolvedValue({
+            showTerminal,
+            showOutput,
+        })
+
+        return {
+            showTerminal,
+            showOutput,
+            kernel,
+            cell
+        }
+    }
+
+    const expectOutput = async (type: OutputType) => {
+        const { kernel, cell, showOutput, showTerminal } = mockOutput(type)
+        await handleCellOutputMessage({
+            kernel,
+            cell,
+            outputType: type,
+            message: {
+                type: ClientMessages.closeCellOutput,
+                output: {
+                    uuid: 'a17249e7-4b5f-4b40-8037-10902dd446c9',
+                    outputType: type
+                }
+            }
+        })
+        if ([OutputType.terminal, OutputType.outputItems]
+            .includes(type)) {
+            expect(showTerminal).toBeCalledTimes(1)
+            expect(showTerminal).toBeCalledWith(false)
+        } else {
+            expect(showOutput).toBeCalledTimes(1)
+            expect(showOutput).toBeCalledWith(type, false)
+        }
+    }
+
+    test('Annotations cell output', async () => {
+        return expectOutput(OutputType.annotations)
+    })
+
+    test('Terminal cell output', async () => {
+        return expectOutput(OutputType.terminal)
+    })
+
+    test('OutputItems cell output', async () => {
+        return expectOutput(OutputType.outputItems)
+    })
+
+    test('Vercel cell output', async () => {
+        return expectOutput(OutputType.vercel)
+    })
+})
\ No newline at end of file