Electron Adventures: Episode 17: Terminal Input
Tags: electron, javascript, svelte
Published: 2021-08-09 22:59:45

Our terminal app is getting better. The next step is adding some ways to interact with commands we run. These are three primary ways:

* input some text (by default in whole lines; not by character)
* tell command that input is done (Control-D in traditional terminal)
* tell command to stop (Control-C in traditional terminal)

### `runCommand` in `preload.js`

We're changing it again. There's a lot of events coming from the app (`input`, `endInput`, `kill`), and a lot of events we send from the app (`onout`, `onerr`, `ondone`):

```javascript
let runCommand = ({command, onout, onerr, ondone}) => {
  const proc = child_process.spawn(
    command,
    [],
    {
      shell: true,
      stdio: ["pipe", "pipe", "pipe"],
    },
  )
  proc.stdout.on("data", (data) => onout(data.toString()))
  proc.stderr.on("data", (data) => onerr(data.toString()))
  proc.on("close", (code) => ondone(code))
  return {
    kill: () => proc.kill(),
    input: (data) => proc.stdin.write(data),
    endInput: () => proc.stdin.end(),
  }
}
```

We changed `stdin` from `ignore` to `pipe` as it's now active, and now we return an object with three methods for app to use to talk to our process.

### Move all the logic out of `App.svelte`

Initially all the logic for dealing with commands was in `App.svelte` and `HistoryEntry.svelte` was display only class.

This needs to be flipped - there's way too much in `App.svelte`, so let's rename `HistoryEntry.svelte` to `Command.svelte` and move all the logic there instead.

```svelte
<script>
  import Command from "./Command.svelte"
  import CommandInput from "./CommandInput.svelte"

  let history = []

  async function onsubmit(command) {
    let entry = {command}
    history.push(entry)
    history = history
  }
</script>

<h1>Svelte Terminal App</h1>

<div id="terminal">
  <div id="history">
    {#each history as entry}
      <Command command={entry.command} />
    {/each}
  </div>

  <CommandInput {onsubmit} />
</div>

<style>
:global(body) {
  background-color: #444;
  color: #fff;
  font-family: monospace;
}
</style>
```

### Input box styling in `CommandInput.svelte`

It's a small thing, but because now we have multiple input boxes at the same time, I changed its color a bit to make it more distinct.

```css
  input {
    background-color: #666;
  }
```

### `Command.svelte` template

There's a lot of things we want to do:

* add input field for entering text
* add some buttons for end of input, and for killing command
* remove spinner icon as it's redundant now - running command will have input field, done command will not
* instead of interactions being stdout first, then stderr, we want to intertwine stdin, stdout, and stderr as they are happening, so we can see things better

```svelte
<div class='history-entry'>
  <div class='input-line'>
    <span class='prompt'>$</span>
    <span class='command'>{command}</span>
  </div>
  {#each interactions as interaction}
    <div class={interaction.type}>{interaction.data}</div>
  {/each}
  {#if running}
    <form on:submit|preventDefault={submit}>
      <input type="text" bind:value={input} />
      <button type="button" on:click={endInput}>End Input</button>
      <button type="button" on:click={kill}>Kill</button>
    </form>
  {/if}
  {#if error}
    <Icon data={exclamationTriangle} />
  {/if}
</div>
```

### `Command.svelte` script

All the existing logic from `App.svelte` as well as a bunch of new logic goes here.

The code should be clear enough. `interactions` is an array of objects, each of which has a `type` and `data` property. `type` is either `stdin`, `stdout`, or `stderr`. `data` is the actual text that was send or received.

```svelte
<script>
  import Icon from "svelte-awesome"
  import { exclamationTriangle } from "svelte-awesome/icons"

  export let command

  let running = true
  let interactions = []
  let error = false
  let input = ""

  function onout(data) {
    interactions.push({data, type: "stdout"})
    interactions = interactions
  }
  function onerr(data) {
    interactions.push({data, type: "stderr"})
    interactions = interactions
  }
  function ondone(code) {
    running = false
    error = (code !== 0)
  }
  function endInput() {
    proc.endInput()
  }
  function kill() {
    proc.kill()
  }
  function submit() {
    let data = input+"\n"
    interactions.push({data, type: "stdin"})
    interactions = interactions
    proc.input(data)
    input = ""
  }
  let proc = window.api.runCommand({command,onout,onerr,ondone})
</script>
```

### `Command.svelte` styling

Styling just matches what we already did, except I changed input's background color a little to distinguish inputs from the rest of the terminal.

```html
<style>
  .history-entry {
    padding-bottom: 0.5rem;
  }

  .stdin {
    color: #ffa;
    white-space: pre;
  }

  .stdout {
    color: #afa;
    white-space: pre;
  }

  .stderr {
    color: #faa;
    white-space: pre;
  }

  .input-line {
    display: flex;
    gap: 0.5rem;
  }

  .command {
    color: #ffa;
    flex: 1;
  }

  form {
    flex: 1;
    display: flex;
  }

  input {
    flex: 1;
    font-family: inherit;
    background-color: #666;
    color: inherit;
    border: none;
  }
</style>
```

### Result


And here's the result:

![Episode 17 screenshot](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/nad0rvpgenfed7oovykg.png)

The terminal still has some limitations, most obviously:

* running a command creates new unfocused input box, so you need to focus on it manually; then when command finishes, you need to manually focus on input for the new command
* keyboard shortcuts like Control-D and Control-C don't work
* `cd` command doesn't work
* any command that generates binary data, too much data, or data that's not line-based text, will work very poorly

But it's still going quite well.

For the next episode we'll take a break from our terminal app and try to code something different.

As usual, [all the code for the episode is here](https://github.com/taw/electron-adventures/tree/master/episode-17-terminal-input).