DEV Community

Tomasz Wegrzanowski
Tomasz Wegrzanowski

Posted on • Updated on

Electron Adventures: Episode 17: Terminal Input

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):

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(),
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

<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>
Enter fullscreen mode Exit fullscreen mode

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.

  input {
    background-color: #666;
  }
Enter fullscreen mode Exit fullscreen mode

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
<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>
Enter fullscreen mode Exit fullscreen mode

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.

<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>
Enter fullscreen mode Exit fullscreen mode

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.

<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>
Enter fullscreen mode Exit fullscreen mode

Result

And here's the result:

Episode 17 screenshot

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.

Discussion (0)