Repository: stateful/vscode-runme
Pull request #282 (2023-03-07 18:50:13)

Support terminal dimensions in gRPC runner
Solves #252 

Added a section to `examples/README.md` to test against.

mxsdev: Currently need new build of protobuf definitions to continue @sourishkrout, see slack
mxsdev: Functionality good, would like to write unit tests before merge.

Patch:
diff --git a/examples/README.md b/examples/README.md
index cc19095b..ab56a633 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -137,3 +137,10 @@ def hello():
 ```sh { interactive=false, mimeType=image/png }
 curl https://lever-client-logos.s3.us-west-2.amazonaws.com/a8ff9b1f-f313-4632-b90f-1f7ae7ee807f-1638388150933.png 2>/dev/null
 ```
+
+## Terminal Dimensions
+
+```sh { closeTerminalOnSuccess=false }
+echo Rows: $(tput lines)
+echo Columns: $(tput cols)
+```
diff --git a/package-lock.json b/package-lock.json
index 3fcd92d9..ceb17163 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,16 +1,16 @@
 {
   "name": "runme",
-  "version": "0.7.3-edge.0",
+  "version": "0.7.3-edge.1",
   "lockfileVersion": 2,
   "requires": true,
   "packages": {
     "": {
       "name": "runme",
-      "version": "0.7.3-edge.0",
+      "version": "0.7.3-edge.1",
       "hasInstallScript": true,
       "license": "Apache-2.0",
       "dependencies": {
-        "@buf/stateful_runme.community_timostamm-protobuf-ts": "2.8.2-20230217155517-b1ae5526a72e.1",
+        "@buf/stateful_runme.community_timostamm-protobuf-ts": "^2.8.2-20230307185721-114a366533aa.1",
         "@grpc/grpc-js": "^1.8.4",
         "@protobuf-ts/grpc-transport": "^2.8.2",
         "@protobuf-ts/runtime": "^2.8.2",
@@ -1728,8 +1728,8 @@
       "dev": true
     },
     "node_modules/@buf/stateful_runme.community_timostamm-protobuf-ts": {
-      "version": "2.8.2-20230217155517-b1ae5526a72e.1",
-      "resolved": "https://buf.build/gen/npm/v1/@buf/stateful_runme.community_timostamm-protobuf-ts/2.8.2-20230217155517-b1ae5526a72e.1/tarball",
+      "version": "2.8.2-20230307185721-114a366533aa.1",
+      "resolved": "https://buf.build/gen/npm/v1/@buf/stateful_runme.community_timostamm-protobuf-ts/2.8.2-20230307185721-114a366533aa.1/tarball",
       "peerDependencies": {
         "@protobuf-ts/runtime": "^2.8.2",
         "@protobuf-ts/runtime-rpc": "^2.8.2"
@@ -24628,8 +24628,8 @@
       "dev": true
     },
     "@buf/stateful_runme.community_timostamm-protobuf-ts": {
-      "version": "2.8.2-20230217155517-b1ae5526a72e.1",
-      "resolved": "https://buf.build/gen/npm/v1/@buf/stateful_runme.community_timostamm-protobuf-ts/2.8.2-20230217155517-b1ae5526a72e.1/tarball",
+      "version": "2.8.2-20230307185721-114a366533aa.1",
+      "resolved": "https://buf.build/gen/npm/v1/@buf/stateful_runme.community_timostamm-protobuf-ts/2.8.2-20230307185721-114a366533aa.1/tarball",
       "requires": {}
     },
     "@cspotcode/source-map-support": {
diff --git a/package.json b/package.json
index 04381f42..3db983c2 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "runme",
-  "version": "0.7.3-edge.0",
+  "version": "0.7.3-edge.1",
   "publisher": "stateful",
   "displayName": "Runme",
   "description": "Run your README.md as an interactive notebook",
@@ -454,7 +454,7 @@
     "webpack-cli": "^5.0.1"
   },
   "dependencies": {
-    "@buf/stateful_runme.community_timostamm-protobuf-ts": "2.8.2-20230217155517-b1ae5526a72e.1",
+    "@buf/stateful_runme.community_timostamm-protobuf-ts": "^2.8.2-20230307185721-114a366533aa.1",
     "@grpc/grpc-js": "^1.8.4",
     "@protobuf-ts/grpc-transport": "^2.8.2",
     "@protobuf-ts/runtime": "^2.8.2",
diff --git a/src/extension/executors/runner.ts b/src/extension/executors/runner.ts
index 649675e5..507a5c33 100644
--- a/src/extension/executors/runner.ts
+++ b/src/extension/executors/runner.ts
@@ -12,7 +12,6 @@ import {
   NotebookCellExecution,
   TextDocument,
   ExtensionContext,
-  Disposable,
   NotebookRendererMessaging,
   workspace
 } from 'vscode'
@@ -64,7 +63,7 @@ export async function executeRunner(
   }
 
   const annotations = getAnnotations(exec.cell)
-  const { interactive, mimeType, background } = annotations
+  const { interactive, mimeType, background, closeTerminalOnSuccess } = annotations
 
   let execution: RunProgramExecution = {
     type: 'commands', commands
@@ -96,7 +95,7 @@ export async function executeRunner(
     tty: interactive
   })
 
-  const disposables: Disposable[] = [program]
+  context.subscriptions.push(program)
 
   program.onDidWrite((data) => postClientMessage(messaging, ClientMessages.terminalStdout, {
     'runme.dev/uuid': cellUUID,
@@ -158,9 +157,7 @@ export async function executeRunner(
       program.close()
     })
 
-    disposables.push({ dispose: () => program.close() })
     await program.run()
-
   } else {
     const taskExecution = new Task(
       { type: 'shell', name: `Runme Task (${RUNME_ID})` },
@@ -206,8 +203,6 @@ export async function executeRunner(
       ])
     }
 
-    disposables.push({ dispose: () => execution.terminate() })
-
     exec.token.onCancellationRequested(() => {
       try {
         // runs `program.close()` implicitly
@@ -239,13 +234,11 @@ export async function executeRunner(
       /**
        * only close terminal if execution passed and desired by user
        */
-      if (e.exitCode === 0 && annotations.closeTerminalOnSuccess) {
+      if (e.exitCode === 0 && closeTerminalOnSuccess && !background) {
         closeTerminalByEnvID(RUNME_ID)
       }
     })
   }
-  const dispose: Disposable = { dispose() { disposables.forEach(d => d.dispose()) }, }
-  context.subscriptions.push(dispose)
 
   return await new Promise<boolean>((resolve, reject) => {
     program.onDidClose((code) => {
@@ -278,12 +271,15 @@ export async function executeRunner(
     if (background && interactive) {
       setTimeout(
         () => {
-          closeTerminalByEnvID(RUNME_ID)
+          if (closeTerminalOnSuccess) {
+            closeTerminalByEnvID(RUNME_ID)
+          }
+
           resolve(true)
         },
         BACKGROUND_TASK_HIDE_TIMEOUT
       )
     }
-  }).finally(() => { dispose.dispose() })
+  })
 }
 
diff --git a/src/extension/runner.ts b/src/extension/runner.ts
index 92ff6371..f6114b9a 100644
--- a/src/extension/runner.ts
+++ b/src/extension/runner.ts
@@ -1,10 +1,11 @@
 import { GrpcTransport } from '@protobuf-ts/grpc-transport'
 import { DuplexStreamingCall } from '@protobuf-ts/runtime-rpc/build/types/duplex-streaming-call'
 import {
-  Pseudoterminal,
-  Event,
+  type Pseudoterminal,
+  type Event,
+  type Disposable,
+  type TerminalDimensions,
   EventEmitter,
-  Disposable,
 } from 'vscode'
 import { RpcError } from '@protobuf-ts/runtime-rpc'
 
@@ -17,6 +18,7 @@ import {
   ExecuteStop,
   GetSessionRequest,
   Session,
+  Winsize,
 } from './grpc/runnerTypes'
 import { RunnerServiceClient } from './grpc/client'
 import { getShellPath } from './executors/utils'
@@ -41,6 +43,7 @@ export interface RunProgramOptions {
     RunProgramExecution
   tty?: boolean
   environment?: IRunnerEnvironment
+  terminalDimensions?: TerminalDimensions
 }
 
 export interface IRunner extends Disposable {
@@ -386,6 +389,14 @@ export class GrpcRunnerProgramSession implements IRunnerProgramSession {
 
     this.initialized = true
 
+    this.opts.envs ??= []
+
+    if(this.opts.tty) {
+      this.opts.envs.push('TERM=xterm-256color')
+    } else {
+      this.opts.envs.push('TERM=')
+    }
+
     await this.session.requests.send(GrpcRunnerProgramSession.runOptionsToExecuteRequest(this.opts))
   }
 
@@ -450,7 +461,7 @@ export class GrpcRunnerProgramSession implements IRunnerProgramSession {
     }))
   }
 
-  open(): void {
+  open(initialDimensions?: TerminalDimensions): void {
     // Workaround to force terminal to close if opened after early exit
     // TODO(mxs): find a better solution here
     if(this.hasExited()) {
@@ -458,6 +469,8 @@ export class GrpcRunnerProgramSession implements IRunnerProgramSession {
       return
     }
 
+    this.opts.terminalDimensions = initialDimensions
+
     // in pty, we wait for open to run
     this.run()
   }
@@ -506,6 +519,12 @@ export class GrpcRunnerProgramSession implements IRunnerProgramSession {
     this.dispose()
   }
 
+  setDimensions(dimensions: TerminalDimensions): void {
+    this.session.requests.send(ExecuteRequest.create({
+      winsize: terminalDimensionsToWinsize(dimensions)
+    }))
+  }
+
   hasExited() {
     return this.exitReason
   }
@@ -516,7 +535,7 @@ export class GrpcRunnerProgramSession implements IRunnerProgramSession {
   }
 
   static runOptionsToExecuteRequest(
-    { programName, args, cwd, environment, exec, tty, envs }: RunProgramOptions
+    { programName, args, cwd, environment, exec, tty, envs, terminalDimensions }: RunProgramOptions
   ): ExecuteRequest {
     if(environment && !(environment instanceof GrpcRunnerEnvironment)) {
       throw new Error('Expected gRPC environment!')
@@ -530,7 +549,8 @@ export class GrpcRunnerProgramSession implements IRunnerProgramSession {
       sessionId: environment?.getSessionId(),
       programName,
       ...exec?.type === 'commands' && { commands: exec.commands },
-      ...exec?.type === 'script' && { script: exec.script }
+      ...exec?.type === 'script' && { script: exec.script },
+      ...terminalDimensions && { winsize: terminalDimensionsToWinsize(terminalDimensions) },
     })
   }
 }
@@ -557,3 +577,10 @@ export class GrpcRunnerEnvironment implements IRunnerEnvironment {
     return await this.client.deleteSession({ id: this.getSessionId() })
   }
 }
+
+function terminalDimensionsToWinsize({ rows, columns }: TerminalDimensions): Winsize {
+  return Winsize.create({
+    cols: columns,
+    rows,
+  })
+}


Test patch:
diff --git a/tests/extension/runner.test.ts b/tests/extension/runner.test.ts
index 819abd87..51bdab76 100644
--- a/tests/extension/runner.test.ts
+++ b/tests/extension/runner.test.ts
@@ -12,6 +12,7 @@ import {
   RunProgramOptions
 } from '../../src/extension/runner'
 
+
 vi.mock('../../src/extension/utils', () => ({
   getGrpcHost: vi.fn().mockReturnValue('127.0.0.1:7863')
 }))
@@ -87,6 +88,12 @@ vi.mock('@buf/stateful_runme.community_timostamm-protobuf-ts/runme/runner/v1/run
   default: { },
   CreateSessionRequest: {
     create: vi.fn((x: any) => x)
+  },
+  ExecuteRequest: {
+    create: vi.fn((x: any) => x)
+  },
+  Winsize: {
+    create: vi.fn((x: any) => x)
   }
 }))
 
@@ -250,6 +257,41 @@ suite('grpc Runner', () => {
       expect(closeListener).toBeCalledTimes(1)
       expect(closeListener).toBeCalledWith(1)
     })
+
+    test('initial request has winsize', async () => {
+      const { duplex, session } = await createNewSession({
+        tty: true
+      })
+
+      session.open({ columns: 50, rows: 20 })
+
+      expect(duplex.requests.send).toBeCalledTimes(1)
+      expect((duplex.requests.send.mock.calls[0] as any)[0]).toMatchObject({
+        tty: true,
+        winsize: {
+          cols: 50,
+          rows: 20,
+        }
+      })
+    })
+
+    test('further requests have winsize', async () => {
+      const { duplex, session } = await createNewSession({
+        tty: true
+      })
+
+      session.open({ columns: 50, rows: 20 })
+
+      session.setDimensions({ columns: 60, rows: 30 })
+
+      expect(duplex.requests.send).toBeCalledTimes(2)
+      expect((duplex.requests.send.mock.calls[1] as any)[0]).toStrictEqual({
+        winsize: {
+          cols: 60,
+          rows: 30,
+        }
+      })
+    })
   })
 })